Compare commits

..
Author SHA1 Message Date
HampusandGitHub 85a03a9e39 fix(app): apply the screen share audio toggle to the surface being shared (#1813) 2026-08-21 17:04:22 +02:00
51ee6567b4 chore(i18n): update public marketing catalogs (#1812)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-21 16:31:01 +02:00
090220a29d chore(marketing): advance pointer f39eced → 02e3a2c (#1811)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-21 16:30:54 +02:00
HampusandGitHub e7973b8be0 fix(api): derive voice reconciliation candidate ttl from real sweep spacing (#1810) 2026-08-21 15:49:50 +02:00
HampusandGitHub b5324c9223 perf(gateway): stop materializing all members on the guild connect path (#1809) 2026-08-21 14:00:19 +02:00
HampusandGitHub edb8d80077 ci: source the s3 provider for downloads and static from repo variables (#1803) 2026-08-20 21:46:26 +02:00
HampusandGitHub ddee116339 feat(api): route downloads through the configured downloads provider (#1802) 2026-08-20 21:44:53 +02:00
HampusandGitHub bdacaea4a8 feat(config): add an optional separate s3 provider for downloads (#1801) 2026-08-20 21:34:19 +02:00
HampusandGitHub 9e28e02b5d ci(rust): pin the floating toolchains to the version images build with (#1800) 2026-08-20 21:27:12 +02:00
HampusandGitHub 3527dc95a2 fix(rust): silence result_large_err on axum response error paths (#1799) 2026-08-20 21:18:18 +02:00
HampusandGitHub 27c7b2722d chore(admin): regenerate openapi schemas for the admin acl cap (#1798) 2026-08-20 21:17:04 +02:00
HampusandGitHub ba96f52ed6 fix(schema): allow assigning every admin ACL to a user (#1797) 2026-08-20 21:09:56 +02:00
HampusandGitHub 2c8b3ff45c fix(build): build the messages and users images with scylla support (#1796) 2026-08-20 20:30:16 +02:00
HampusandGitHub 631bc2307a fix(slowmode): stop the local cooldown from outgrowing the channel setting (#1795) 2026-08-20 19:56:02 +02:00
HampusandGitHub 8f4f9a8601 refactor(media): share one external proxy url codec across every service (#1794) 2026-08-20 19:55:40 +02:00
HampusandGitHub 1c920f966e feat(media): emit external proxy urls with a readable path and extension (#1793) 2026-08-20 18:44:45 +02:00
HampusandGitHub 2b1de38949 chore(i18n): regenerate catalogs after the voice engine removal (#1791) 2026-08-20 17:45:49 +02:00
HampusandGitHub d5daf61dbd fix(voice): recalibrate stored participant and stream volumes too (#1790) 2026-08-20 17:45:17 +02:00
HampusandGitHub 06c42ce02f refactor(desktop): delete the unused rust voice module (#1789) 2026-08-20 17:44:55 +02:00
HampusandGitHub 4f21430880 refactor(voice): drop the native only surface from voice_engine_v2 and narrow the desktop bridge (#1788) 2026-08-20 17:44:37 +02:00
HampusandGitHub 9731bac40f refactor(voice): remove the native voice engine from the renderer (#1787) 2026-08-20 17:43:38 +02:00
HampusandGitHub b144e650f2 fix(voice): make deafen reach watched screen share audio (#1786) 2026-08-20 17:43:19 +02:00
HampusandGitHub ef6536644c fix(voice): stop the in call speaker slider clamping a boosted output volume (#1785) 2026-08-20 17:42:59 +02:00
HampusandGitHub 17eab43245 fix(voice): retune the remote playback leveller and measure it in float (#1784) 2026-08-20 17:42:40 +02:00
HampusandGitHub fbd7f1e3b8 fix(voice): compose participant gain in linear space and split the ceilings (#1783) 2026-08-20 17:42:21 +02:00
HampusandGitHub 25af7516a4 fix(voice): widen the volume boost leg and add a master soft clip limiter (#1782) 2026-08-20 17:42:02 +02:00
HampusandGitHub c020eded31 fix(voice): stop forcing automatic gain control off on every capture profile (#1781) 2026-08-20 17:41:44 +02:00
HampusandGitHub 5331c0216a fix(voice): keep a remote track pinned at zero volume across re-attach (#1780) 2026-08-20 17:40:53 +02:00
HampusandGitHub 528777926c fix(api): stop one unreachable community from breaking bookmarks (#1779) 2026-08-20 15:50:51 +02:00
HampusandGitHub 47b5c3d4f0 fix(app): expose the guild id on guild list items again (#1778) 2026-08-20 14:39:24 +02:00
HampusandGitHub d9bfca66d6 fix(app): keep search results in server order instead of per channel (#1777) 2026-08-20 13:10:47 +02:00
HampusandGitHub ded51b65d3 fix(app): restore the mobile voice message recorder (#1776) 2026-08-20 04:33:24 +02:00
HampusandGitHub ddc8837d4f fix(app): scale embed and attachment width caps with zoom (#1775) 2026-08-20 04:33:17 +02:00
HampusandGitHub df16957c95 fix(app): scale tooltip max-width with app zoom (#1774) 2026-08-20 04:33:10 +02:00
HampusandGitHub f38b19d4bd fix(app): let the caret move past emoji and mentions in the composer (#1768) 2026-08-19 23:41:05 +02:00
HampusandGitHub f7cd4f2c74 docs(operator): explain how to reclaim space after upgrades (#1767) 2026-08-19 23:31:15 +02:00
a078392888 chore(i18n): update public marketing catalogs (#1719)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-19 22:46:33 +02:00
HampusandGitHub 3060f23f8c chore(marketing): bump the marketing submodule pointer (#1766) 2026-08-19 22:29:10 +02:00
HampusandGitHub 6a5f0d4d29 fix(i18n): simplify the marketing translation and saved media strings (#1764) 2026-08-19 22:25:01 +02:00
HampusandGitHub 91d989dc7c fix(app): point the language notice at Weblate instead of email (#1763) 2026-08-19 22:05:39 +02:00
HampusandGitHub 7c82804df6 fix(app): stop sensitive content options overlapping in long locales (#1762) 2026-08-19 19:53:46 +02:00
HampusandGitHub b7de83f7fc fix(app): stop the Firefox microphone failure and self-mute storm (#1761) 2026-08-19 19:32:41 +02:00
HampusandGitHub 97bd022bee fix(app): render the discovery language icon at bold weight (#1760) 2026-08-19 19:08:48 +02:00
HampusandGitHub 62324df039 feat(app): add a language filter button to the discovery navbar (#1759) 2026-08-19 18:46:39 +02:00
HampusandGitHub 9f78b3d9a6 feat(app): retry failed image loads with escalating delay and connectivity waits (#1758) 2026-08-19 18:25:34 +02:00
HampusandGitHub 362a89f2b2 fix(app): keep embed icons mounted so they reserve space while loading (#1757) 2026-08-19 18:00:08 +02:00
HampusandGitHub ce41960fcd perf(app): load embed author and footer icons through the image cache (#1756) 2026-08-19 17:38:00 +02:00
HampusandGitHub 2083eaddd6 feat(app): wrap pasted links in every composer that allows masked links (#1755) 2026-08-19 17:19:39 +02:00
HampusandGitHub d398ebc44b test: drop desktop test files that no runner executes (#1753) 2026-08-19 14:58:59 +02:00
HampusandGitHub 59887ad404 fix(app): wrap pasted links from any source, not just Fluxer's own clipboard (#1752) 2026-08-19 14:57:13 +02:00
HampusandGitHub 5aa283e8e0 fix(desktop): repair a stale Linux .desktop entry instead of preserving it (#1751) 2026-08-19 14:56:29 +02:00
HampusandGitHub d9ed256a4b fix(desktop): pin Electron to 43.4.0 to restore KDE and XFCE tray icons (#1750) 2026-08-19 14:45:58 +02:00
HampusandGitHub a297f89b83 fix(markdown): parse a table that follows a text line without a blank line (#1749) 2026-08-19 14:24:33 +02:00
HampusandGitHub 4a16921242 fix(app): stop macOS window chrome from swallowing clicks below the titlebar (#1748) 2026-08-19 13:05:42 +02:00
HampusandGitHub a6f83c4fb1 feat(app): wrap the selection in a link when pasting a URL (#1747) 2026-08-19 12:55:15 +02:00
HampusandGitHub 7b5c82c6cf fix(app): keep quick switcher focus when results are recomputed (#1746) 2026-08-19 12:55:04 +02:00
HampusandGitHub 30a61ce90f chore(i18n): refresh catalogs and translate new strings (#1745) 2026-08-19 12:46:51 +02:00
HampusandGitHub 22bc2cab74 fix(app): offset toasts below the window chrome on macOS (#1744) 2026-08-19 12:42:08 +02:00
HampusandGitHub 53df9a0d6d fix(app): include remainder seconds in slowmode durations (#1743) 2026-08-19 12:41:56 +02:00
HampusandGitHub f9b7ec4d0b fix(app): stop the bio editor placeholder from overflowing (#1742) 2026-08-19 12:41:44 +02:00
HampusandGitHub 569abf57b7 fix(app): keep the edited marker on attachment-only messages (#1741) 2026-08-19 12:41:31 +02:00
HampusandGitHub ae8e32d809 fix(app): render modals above a fullscreen voice call (#1740) 2026-08-19 12:41:19 +02:00
HampusandGitHub a81b1abec7 fix(api): reject alt text edits on forwarded messages (#1739) 2026-08-19 12:41:07 +02:00
HampusandGitHub 8836565c32 fix(app): keep the guild verification timer stable across switches (#1738) 2026-08-19 12:40:55 +02:00
HampusandGitHub a1b595d52f fix(app): apply the current system theme when relaunching (#1737) 2026-08-19 12:40:43 +02:00
HampusandGitHub abb71ed558 fix(app): prompt for a restart when the system titlebar toggle changes (#1736) 2026-08-19 12:40:32 +02:00
HampusandGitHub c006d413ac fix(desktop): bump Electron to 43.4.1 to restore the Linux tray icon (#1735) 2026-08-19 12:40:20 +02:00
HampusandGitHub fa11acae15 fix(desktop): use the masked icon for AppImage desktop integration (#1734) 2026-08-19 12:40:09 +02:00
HampusandGitHub 300f467ad0 fix(desktop): stop the AppImage adding a duplicate menu entry each launch (#1733) 2026-08-19 12:39:57 +02:00
HampusandGitHub 698469fa96 perf(app): defer media capture routing and skip offscreen skeleton layout (#1732) 2026-08-19 02:58:01 +02:00
HampusandGitHub 591e9fe2ba fix(api): only finalize self-serve refunds once the provider confirms (#1731) 2026-08-19 02:57:58 +02:00
HampusandGitHub d148b4e5b7 feat(app): show 25 unread messages per channel in the inbox (#1729) 2026-08-19 01:06:00 +02:00
HampusandGitHub 324f333bb5 test: drop stale hosted instance config and voice engine boundary tests (#1728) 2026-08-19 00:48:06 +02:00
414 changed files with 17920 additions and 36084 deletions
+4 -4
View File
@@ -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
+8 -8
View File
@@ -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
+2 -2
View File
@@ -89,7 +89,7 @@ jobs:
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: stable
toolchain: "1.93.0"
components: clippy, rustfmt
- name: Install pnpm
@@ -286,7 +286,7 @@ jobs:
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: stable
toolchain: "1.93.0"
components: rustfmt
- name: Sync ci helper dependencies
Generated
+9
View File
@@ -1777,6 +1777,7 @@ dependencies = [
"axum",
"base64",
"clap",
"fluxer_common",
"hmac 0.13.0",
"hyper 1.10.1",
"hyper-util",
@@ -1800,6 +1801,7 @@ dependencies = [
"anyhow",
"base64",
"fluxer-svc",
"fluxer_common",
"hmac 0.13.0",
"moka",
"reqwest",
@@ -1836,6 +1838,7 @@ dependencies = [
"cc",
"clap",
"criterion",
"fluxer_common",
"hex",
"hmac 0.13.0",
"http 1.4.2",
@@ -1875,6 +1878,7 @@ dependencies = [
"chrono",
"criterion",
"fluxer-svc",
"fluxer_common",
"fluxer_markdown_parser",
"futures",
"hmac 0.13.0",
@@ -1943,6 +1947,7 @@ dependencies = [
"encoding_rs",
"entities",
"fluxer-svc",
"fluxer_common",
"hmac 0.13.0",
"infer",
"moka",
@@ -2038,10 +2043,14 @@ dependencies = [
"aws-credential-types",
"aws-sigv4",
"axum",
"base64",
"hmac 0.13.0",
"maxminddb",
"moka",
"reqwest",
"serde_json",
"sha2 0.11.0",
"thiserror",
"time",
"tracing",
"urlencoding",
+2 -2
View File
@@ -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"
}
},
+1
View File
@@ -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> {
-35
View File
@@ -434,41 +434,6 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
}
}
#[tokio::test]
async fn hosted_instance_config_hides_self_host_setup_controls() {
let app = setup().await;
let body = get(&app, "/instance-config", &[]).await;
assert_full_layout(&body);
assert!(body.contains("Registration Controls"), "{body}");
assert!(body.contains("Runtime Integrations"), "{body}");
assert!(body.contains("Gateway Rollout Configuration"), "{body}");
assert!(!body.contains("Public App Identity"), "{body}");
assert!(!body.contains("Setup complete"), "{body}");
assert!(!body.contains("Community & Policy"), "{body}");
assert!(!body.contains("Single community"), "{body}");
assert!(!body.contains("Direct messages &amp; friends"), "{body}");
assert!(!body.contains("Premium model"), "{body}");
assert!(!body.contains("Optional services"), "{body}");
assert!(!body.contains("Registration Fields"), "{body}");
assert!(
!body.contains("Collect date of birth during registration"),
"{body}"
);
assert!(
!body.contains("/instance-config?action=update_app_public"),
"{body}"
);
assert!(
!body.contains("/instance-config?action=update_app_registration"),
"{body}"
);
assert!(
!body.contains("/instance-config?action=update_policy"),
"{body}"
);
}
#[tokio::test]
async fn instance_config_registration_tables_show_copyable_urls_and_compact_pending_actions() {
let app = setup().await;
@@ -29,12 +29,16 @@
"main": "./src/MediaProxyUtils.ts",
"types": "./src/MediaProxyUtils.ts",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@types/node": "catalog:"
},
"devDependencies": {
"@typescript/native-preview": "catalog:"
"@typescript/native-preview": "catalog:",
"vitest": "catalog:",
"vite-tsconfig-paths": "catalog:"
}
}
@@ -0,0 +1,106 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
buildExternalMediaProxyPath,
buildV2ExternalMediaProxyPath,
reconstructOriginalUrl,
} from '@pkgs/media_proxy_utils/src/ExternalMediaProxyPathCodec';
import {describe, expect, it} from 'vitest';
const ROUND_TRIP_URLS = [
'https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp',
'https://static.klipy.com/ii/HkAKKCzZ.webp?v=query_param&goes=here',
'https://example.com:8443/a.png',
'https://avatars.githubusercontent.com/u/241303489?v=4',
'http://example.com/plain.gif',
'https://example.com/deep/nested/path/to/file.jpeg',
'https://example.com/file.png?a=1&b=2&c=3',
'https://example.com/spaced%20name.png',
'https://example.com/unicode/%C3%A5%C3%A4%C3%B6.png',
'https://example.com/file.png?redirect=https%3A%2F%2Fother.example%2Fx.png',
'https://sub.domain.example.co.uk/a/b.webp',
];
describe('buildExternalMediaProxyPath', () => {
it('emits the plain path shape with the extension last', () => {
expect(buildExternalMediaProxyPath('https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp')).toBe(
'https/static.klipy.com/ii/c8/28/HkAKKCzZ.webp',
);
});
it('puts an encoded query, leading question mark included, ahead of the protocol', () => {
expect(buildExternalMediaProxyPath('https://static.klipy.com/ii/HkAKKCzZ.webp?v=query_param&goes=here')).toBe(
'%3Fv%3Dquery_param%26goes%3Dhere/https/static.klipy.com/ii/HkAKKCzZ.webp',
);
});
it('keeps a non default port on the host segment', () => {
expect(buildExternalMediaProxyPath('https://example.com:8443/a.png')).toBe('https/example.com:8443/a.png');
});
it('preserves the http scheme', () => {
expect(buildExternalMediaProxyPath('http://example.com/a.gif')).toBe('http/example.com/a.gif');
});
it('handles a root url with no path', () => {
expect(buildExternalMediaProxyPath('https://example.com/')).toBe('https/example.com');
});
it('never emits the v2 prefix any more', () => {
for (const url of ROUND_TRIP_URLS) {
expect(buildExternalMediaProxyPath(url).startsWith('v2/')).toBe(false);
}
});
it('ends in the source file extension so extension based cdn caching applies', () => {
for (const [url, ext] of [
['https://example.com/a.webp', '.webp'],
['https://example.com/a.png?x=1', '.png'],
['https://example.com/a/b/c.jpeg', '.jpeg'],
] as const) {
expect(buildExternalMediaProxyPath(url).endsWith(ext)).toBe(true);
}
});
it('rejects a url it cannot parse', () => {
expect(() => buildExternalMediaProxyPath('not a url')).toThrow();
});
});
describe('reconstructOriginalUrl', () => {
it('round trips every supported shape', () => {
for (const url of ROUND_TRIP_URLS) {
expect(reconstructOriginalUrl(buildExternalMediaProxyPath(url))).toBe(url);
}
});
it('decodes an externally produced path verbatim', () => {
expect(
reconstructOriginalUrl('%3Fv%3Dquery_param%26goes%3Dhere/https/static.klipy.com/ii/c8/28/HkAKKCzZ.webp'),
).toBe('https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp?v=query_param&goes=here');
});
it('does not double the question mark when the query segment carries one', () => {
const decoded = reconstructOriginalUrl('%3Fa%3D1/https/example.com/x.png');
expect(decoded).toBe('https://example.com/x.png?a=1');
expect(decoded).not.toContain('??');
});
it('still accepts a query segment without a leading question mark', () => {
expect(reconstructOriginalUrl('a%3D1/https/example.com/x.png')).toBe('https://example.com/x.png?a=1');
});
it('still decodes v2 paths so links already sent keep working', () => {
expect(reconstructOriginalUrl(buildV2ExternalMediaProxyPath('https://example.com/a.png?x=1'))).toBe(
'https://example.com/a.png?x=1',
);
});
it('rejects a path that has no host after the protocol', () => {
expect(() => reconstructOriginalUrl('https')).toThrow();
});
it('rejects an empty v2 payload', () => {
expect(() => reconstructOriginalUrl('v2/')).toThrow();
});
});
@@ -74,7 +74,8 @@ function reconstructLegacyOriginalUrl(proxyUrlPath: string): string {
const query = encodedQuery ? decodeLegacyComponent(encodedQuery) : '';
const path = decodeLegacyComponent(encodedPath);
const {hostname, port} = decodeLegacyHostAndPort(hostPart);
return `${protocol}://${hostname}${port ? `:${port}` : ''}/${path}${query ? `?${query}` : ''}`;
const normalizedQuery = query.startsWith('?') ? query.slice(1) : query;
return `${protocol}://${hostname}${port ? `:${port}` : ''}/${path}${normalizedQuery ? `?${normalizedQuery}` : ''}`;
}
function reconstructV2OriginalUrl(proxyUrlPath: string): string {
@@ -85,11 +86,28 @@ function reconstructV2OriginalUrl(proxyUrlPath: string): string {
return decodeV2PathComponent(encodedOriginalUrl);
}
export function buildExternalMediaProxyPath(inputUrl: string): string {
export function buildV2ExternalMediaProxyPath(inputUrl: string): string {
const parsedUrl = new URL(inputUrl);
return `${V2_PATH_PREFIX}${encodeV2PathComponent(parsedUrl.toString())}`;
}
export function buildExternalMediaProxyPath(inputUrl: string): string {
const parsedUrl = new URL(inputUrl);
const protocol = parsedUrl.protocol.replace(/:$/u, '');
const host = parsedUrl.port ? `${parsedUrl.hostname}:${parsedUrl.port}` : parsedUrl.hostname;
const path = parsedUrl.pathname
.replace(/^\//u, '')
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/');
const segments = parsedUrl.search ? [encodeURIComponent(parsedUrl.search)] : [];
segments.push(protocol, host);
if (path) {
segments.push(path);
}
return segments.join('/');
}
export function reconstructOriginalUrl(proxyUrlPath: string): string {
const reconstructedUrl = proxyUrlPath.startsWith(V2_PATH_PREFIX)
? reconstructV2OriginalUrl(proxyUrlPath)
@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfigs/package.json",
"compilerOptions": {
"paths": {
"@fluxer/*": ["../../../packages/*", "../../../packages/*/src/index.ts"],
"@pkgs/*": ["../*"]
}
},
"include": ["src/**/*"]
}
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
test: {
globals: true,
environment: 'node',
include: ['**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
},
},
});
+10
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
import {resolveDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
import {parseGeoipSourceConfig, resolveGeoipRuntimeSourceConfig} from '@pkgs/geoip/src/GeoipStartup';
import type {APIConfig, BlueskyOAuthConfig} from './config/APIConfig';
@@ -237,6 +238,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
cacheMinTtlSeconds: master.services.api.embeds.cache_min_ttl_seconds,
cacheRespectRemoteTtl: master.services.api.embeds.cache_respect_remote_ttl,
},
s3Downloads: resolveDownloadsProvider(master),
s3: {
endpoint: s3Config.endpoint,
presignedUrlBase: s3Config.presigned_url_base,
@@ -461,6 +463,14 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
taskName: apiWorkerConfig?.task as WorkerTaskName | undefined,
enableCronScheduler: apiWorkerConfig?.enable_cron_scheduler,
enableVoiceReconciliation: apiWorkerConfig?.enable_voice_reconciliation ?? true,
voiceReconciliation: {
intervalMs: apiWorkerConfig?.voice_reconciliation?.interval_ms,
staggerDelayMs: apiWorkerConfig?.voice_reconciliation?.stagger_delay_ms,
lockTtlSeconds: apiWorkerConfig?.voice_reconciliation?.lock_ttl_seconds,
cadenceTtlSeconds: apiWorkerConfig?.voice_reconciliation?.cadence_ttl_seconds,
gatewayOnlyGraceMs: apiWorkerConfig?.voice_reconciliation?.gateway_only_grace_ms,
liveKitOnlyGraceMs: apiWorkerConfig?.voice_reconciliation?.livekit_only_grace_ms,
},
laneConcurrencyOverrides: {
realtime: apiWorkerConfig?.lane_concurrency_overrides?.realtime,
unfurl: apiWorkerConfig?.lane_concurrency_overrides?.unfurl,
@@ -93,19 +93,7 @@ export class MessageEditService {
assertGuildMemberCanCommunicate(member);
}
if (data.message_snapshots !== undefined) {
const isAuthor = message.authorId === userId;
const canManage = isAuthor ? true : await hasPermission(Permissions.MANAGE_MESSAGES);
if (!isAuthor && !canManage) {
throw new MissingPermissionsError();
}
const updatedMessage = await this.withMessageLock(channelId, messageId, () =>
this.deps.persistenceService.updateSnapshotAttachments({
message,
snapshotEdits: data.message_snapshots ?? [],
}),
);
await this.deps.dispatchService.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
return updatedMessage;
throw new MissingPermissionsError();
}
const user = await this.deps.userRepository.findUnique(userId);
this.deps.validationService.validateMessageEditable(message);
@@ -929,9 +929,8 @@ export class MessageSendService {
algorithm: 'leaky_bucket',
});
if (!slowmodeResult.allowed) {
const retryAfter = Math.max(0, slowmodeResult.resetTime.getTime() - Date.now());
throw new SlowmodeRateLimitError({
retryAfter,
retryAfter: slowmodeResult.retryAfter,
retryAfterDecimal: slowmodeResult.retryAfterDecimal,
});
}
@@ -52,4 +52,27 @@ describe('Slowmode Enforcement', () => {
expect(messages).toHaveLength(1);
expect(messages[0]?.id).toBe(firstMessage.id);
});
it('reports the slowmode retry window in seconds on the Retry-After header', async () => {
const rateLimitPerUser = 5;
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const member = members[0]!;
await ensureSessionStarted(harness, member.token);
const channel = await createChannel(harness, owner.token, guild.id, 'slowmode-channel');
await updateChannel(harness, owner.token, channel.id, {
rate_limit_per_user: rateLimitPerUser,
});
await sendChannelMessage(harness, member.token, channel.id, 'first message');
const {response, json} = await createBuilder<{code: string; retry_after: number}>(harness, member.token)
.post(`/channels/${channel.id}/messages`)
.body({content: 'second message'})
.expect(400, APIErrorCodes.SLOWMODE_RATE_LIMITED)
.executeWithResponse();
const headerRetryAfter = Number(response.headers.get('Retry-After'));
expect(Number.isInteger(headerRetryAfter)).toBe(true);
expect(headerRetryAfter).toBeGreaterThan(0);
expect(headerRetryAfter).toBeLessThanOrEqual(rateLimitPerUser);
expect(json.retry_after).toBeGreaterThan(0);
expect(json.retry_after).toBeLessThanOrEqual(rateLimitPerUser);
expect(headerRetryAfter - json.retry_after).toBeLessThan(1);
});
});
+10
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ResolvedDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
import type {WorkerTaskName} from '../worker/WorkerLaneConfig';
export type APIWorkerMode = 'all_lanes' | 'single_lane' | 'single_task';
@@ -150,6 +151,7 @@ export interface APIConfig {
static: string;
};
};
s3Downloads: ResolvedDownloadsProvider;
email: {
enabled: boolean;
provider: 'smtp' | 'none';
@@ -359,6 +361,14 @@ export interface APIConfig {
taskName?: WorkerTaskName;
enableCronScheduler?: boolean;
enableVoiceReconciliation: boolean;
voiceReconciliation: {
intervalMs: number | undefined;
staggerDelayMs: number | undefined;
lockTtlSeconds: number | undefined;
cadenceTtlSeconds: number | undefined;
gatewayOnlyGraceMs: number | undefined;
liveKitOnlyGraceMs: number | undefined;
};
laneConcurrencyOverrides: {
realtime?: number;
unfurl?: number;
@@ -187,3 +187,26 @@ describe('StorageService.copyObjectWithMetadataStripping', () => {
]);
});
});
describe('provider selection', () => {
interface ClientProbe {
client: {config: {region: () => Promise<string>; endpoint?: () => Promise<{hostname: string}>}};
}
it('defaults to the shared S3 configuration', async () => {
const service = new StorageService() as unknown as ClientProbe;
expect(await service.client.config.region()).toBe(Config.s3.region);
});
it('uses an explicitly supplied provider instead of the shared one', async () => {
const service = new StorageService({
endpoint: 'https://downloads.example.net',
forcePathStyle: false,
region: 'eu-central-9',
accessKeyId: 'DL_KEY',
secretAccessKey: 'DL_SECRET',
}) as unknown as ClientProbe;
expect(await service.client.config.region()).toBe('eu-central-9');
expect(await service.client.config.region()).not.toBe(Config.s3.region);
});
});
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {S3ProviderSettings} from '@fluxer/config/src/S3DownloadsProvider';
import assert from 'node:assert/strict';
import {createHash} from 'node:crypto';
import fs from 'node:fs';
@@ -105,27 +106,36 @@ function extractStreamFromGet(out: GetObjectCommandOutput): Readable {
export class StorageService implements IStorageService {
private readonly client: S3Client;
private readonly presignClient: S3Client;
private readonly provider: S3ProviderSettings;
constructor() {
this.client = buildPooledS3Client({
constructor(provider?: S3ProviderSettings) {
this.provider = provider ?? {
endpoint: Config.s3.endpoint,
presignedUrlBase: Config.s3.presignedUrlBase,
forcePathStyle: Config.s3.forcePathStyle,
region: Config.s3.region,
accessKeyId: Config.s3.accessKeyId,
secretAccessKey: Config.s3.secretAccessKey,
};
this.client = buildPooledS3Client({
endpoint: this.provider.endpoint,
region: this.provider.region,
accessKeyId: this.provider.accessKeyId,
secretAccessKey: this.provider.secretAccessKey,
forcePathStyle: true,
});
this.presignClient = buildPooledS3Client({
endpoint: this.resolvePresignEndpoint(),
region: Config.s3.region,
accessKeyId: Config.s3.accessKeyId,
secretAccessKey: Config.s3.secretAccessKey,
forcePathStyle: Config.s3.forcePathStyle,
region: this.provider.region,
accessKeyId: this.provider.accessKeyId,
secretAccessKey: this.provider.secretAccessKey,
forcePathStyle: this.provider.forcePathStyle,
});
}
private resolvePresignEndpoint(): string {
const fallbackEndpoint = Config.s3.endpoint;
const configuredEndpoint = Config.s3.presignedUrlBase;
const fallbackEndpoint = this.provider.endpoint;
const configuredEndpoint = this.provider.presignedUrlBase;
if (!configuredEndpoint) {
return fallbackEndpoint;
}
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {Config} from '../Config';
import {createDownloadsStorageService} from './StorageServiceFactory';
describe('createDownloadsStorageService', () => {
it('returns null when no downloads override is configured', () => {
expect(Config.s3Downloads.isOverridden).toBe(false);
expect(createDownloadsStorageService()).toBeNull();
});
it('resolves the downloads provider to the shared provider by default', () => {
expect(Config.s3Downloads.settings.endpoint).toBe(Config.s3.endpoint);
expect(Config.s3Downloads.settings.region).toBe(Config.s3.region);
expect(Config.s3Downloads.settings.accessKeyId).toBe(Config.s3.accessKeyId);
});
});
@@ -1,8 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '../Config';
import type {IStorageService} from './IStorageService';
import {StorageService} from './StorageService';
export function createStorageService(): IStorageService {
return new StorageService();
}
export function createDownloadsStorageService(): IStorageService | null {
if (!Config.s3Downloads.isOverridden) {
return null;
}
return new StorageService(Config.s3Downloads.settings);
}
@@ -60,7 +60,7 @@ import {KVActivityTracker} from '../infrastructure/KVActivityTracker';
import {KVBulkMessageDeletionQueueService} from '../infrastructure/KVBulkMessageDeletionQueueService';
import {NatsUnfurlerService} from '../infrastructure/NatsUnfurlerService';
import {PremiumStateReconciliationQueueService} from '../infrastructure/PremiumStateReconciliationQueueService';
import {createStorageService} from '../infrastructure/StorageServiceFactory';
import {createDownloadsStorageService, createStorageService} from '../infrastructure/StorageServiceFactory';
import {UserCacheService} from '../infrastructure/UserCacheService';
import {createUsersServiceClient} from '../infrastructure/UsersServiceClient';
import {VirusScanService} from '../infrastructure/VirusScanService';
@@ -193,6 +193,10 @@ export const getStorageService: () => IStorageService = (() => {
const fallback = singleton(() => createStorageService());
return () => _injectedStorageService ?? fallback();
})();
const getDownloadsStorageService: () => IStorageService = (() => {
const override = singleton(() => createDownloadsStorageService());
return () => override() ?? getStorageService();
})();
export const getErrorI18nService = singleton(() => new ErrorI18nService());
export const getLimitConfigService = singleton(
() => new LimitConfigService(getInstanceConfigRepository(), getCacheService(), getKVClient()),
@@ -262,7 +266,7 @@ export function getKVAccountDeletionQueue(): KVAccountDeletionQueueService {
return accountDeletionQueue;
}
export const getDownloadService = singleton(() => new DownloadService(getStorageService()));
export const getDownloadService = singleton(() => new DownloadService(getDownloadsStorageService()));
export const getThemeService = singleton(() => new ThemeService(getStorageService()));
const getNcmecReporter = singleton(() => new NcmecReporter({config: createNcmecApiConfig(), fetch}));
const getNcmecRepository = singleton(() => new NcmecRepository());
+13 -4
View File
@@ -27402,9 +27402,9 @@
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 25,
"maximum": 50,
"format": "int32",
"description": "Number of messages to return for this channel (1-25)"
"description": "Number of messages to return for this channel (1-50)"
},
"before": {"$ref": "#/components/schemas/SnowflakeType"},
"after": {"$ref": "#/components/schemas/SnowflakeType"},
@@ -33253,12 +33253,20 @@
"payment_intent_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"charge_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"refund_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"refunded_amount_cents": {"type": "integer", "format": "int53"},
"refunded_amount_cents": {
"type": "integer",
"format": "int53",
"description": "Amount actually refunded so far, in the currency minor unit; 0 until the provider confirms success"
},
"invoice_amount_paid_cents": {"type": "integer", "format": "int53"},
"currency": {"type": "string"},
"subscription_id": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Subscription that was cancelled along with the refund, when applicable"
},
"status": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Provider status of the refund (e.g. pending, succeeded, failed); money only moved once succeeded"
}
},
"required": [
@@ -33269,7 +33277,8 @@
"refunded_amount_cents",
"invoice_amount_paid_cents",
"currency",
"subscription_id"
"subscription_id",
"status"
]
},
"ReadStateAckResponse": {
@@ -13,7 +13,7 @@ import type {
SelfServeRefundResponse,
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
import type Stripe from 'stripe';
import type {UserID} from '../../BrandedTypes';
import {createUserID, type UserID} from '../../BrandedTypes';
import {Config} from '../../Config';
import {Logger} from '../../Logger';
import {getBillingRepository} from '../../middleware/ServiceRegistry';
@@ -206,6 +206,51 @@ export class StripeRefundService {
};
}
private async countPriorTerminalFailures(invoiceId: string): Promise<number> {
const priorRefunds = await getBillingRepository().refunds.listByInvoice(invoiceId);
return priorRefunds.filter((r) => r.status === 'failed' || r.status === 'canceled').length;
}
private async finalizeIfSucceeded(refund: Stripe.Refund): Promise<void> {
if (refund.status !== 'succeeded' || refund.metadata?.refund_kind !== 'self_serve') {
return;
}
const userIdRaw = refund.metadata.user_id;
if (!userIdRaw) {
return;
}
let userId: UserID;
try {
userId = createUserID(BigInt(userIdRaw));
} catch {
return;
}
const user = await this.userRepository.findUnique(userId);
if (!user || user.firstRefundAt) {
return;
}
const subscriptionId = refund.metadata.subscription_id;
if (subscriptionId) {
try {
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
} catch (error) {
Logger.warn(
{error, userId: user.id.toString(), subscriptionId},
'Self-serve refund confirmed but subscription cancellation failed; will reconcile via webhook',
);
}
}
await this.userRepository.patchUpsert(user.id, {first_refund_at: new Date()}, user.toRow());
Logger.info(
{userId: user.id.toString(), refundId: refund.id, subscriptionId: subscriptionId || null},
'Self-serve refund confirmed succeeded; cooldown and cancellation finalized',
);
}
async handleRefundWebhookEvent(refund: Stripe.Refund): Promise<void> {
await this.finalizeIfSucceeded(refund);
}
async refundLatestPurchase(userId: UserID): Promise<SelfServeRefundResponse> {
const stripe = this.ensureStripe();
const user = await this.getRequiredUser(userId);
@@ -220,6 +265,14 @@ export class StripeRefundService {
if (this.cooldownExpiresAt(user)) {
throw new StripeRefundCooldownActiveError();
}
const priorFailures = await this.countPriorTerminalFailures(target.invoiceId);
const idempotencyKey = [
'self-serve-refund',
user.id.toString(),
target.invoiceId,
target.paymentIntentId ?? target.chargeId,
...(priorFailures > 0 ? [`retry-${priorFailures}`] : []),
].join(':');
let refund: Stripe.Response<Stripe.Refund>;
try {
refund = await stripe.refunds.create(
@@ -232,11 +285,10 @@ export class StripeRefundService {
invoice_id: target.invoiceId,
refund_kind: 'self_serve',
refund_window_days: String(SELF_SERVE_REFUND_WINDOW_DAYS),
...(target.subscriptionId ? {subscription_id: target.subscriptionId} : {}),
},
},
{
idempotencyKey: `self-serve-refund:${user.id}:${target.invoiceId}:${target.paymentIntentId ?? target.chargeId}`,
},
{idempotencyKey},
);
} catch (error) {
Logger.warn(
@@ -254,36 +306,29 @@ export class StripeRefundService {
} catch (mirrorErr) {
Logger.error({mirrorErr, refundId: refund.id}, 'Mirror upsert failed after Stripe write; reconciler will heal');
}
if (target.subscriptionId) {
try {
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
} catch (error) {
Logger.warn(
{error, userId: user.id.toString(), subscriptionId: target.subscriptionId},
'Self-serve refund issued but subscription cancellation failed; will reconcile via webhook',
);
}
}
await this.userRepository.patchUpsert(user.id, {first_refund_at: new Date()}, user.toRow());
await this.finalizeIfSucceeded(refund);
const succeeded = refund.status === 'succeeded';
Logger.info(
{
userId: user.id.toString(),
invoiceId: target.invoiceId,
refundId: refund.id,
status: refund.status,
amountCents: refund.amount,
subscriptionId: target.subscriptionId,
},
'Self-serve refund issued',
succeeded ? 'Self-serve refund issued' : 'Self-serve refund created; awaiting confirmation from provider',
);
return {
invoice_id: target.invoiceId,
payment_intent_id: target.paymentIntentId,
charge_id: target.chargeId,
refund_id: refund.id,
refunded_amount_cents: refund.amount,
refunded_amount_cents: succeeded ? refund.amount : 0,
invoice_amount_paid_cents: target.amountPaidCents,
currency: target.currency,
subscription_id: target.subscriptionId,
subscription_id: succeeded ? target.subscriptionId : null,
status: refund.status ?? 'pending',
};
}
}
@@ -28,6 +28,7 @@ import {StripeGiftReversalHandler} from './StripeGiftReversalHandler';
import type {StripeGiftService} from './StripeGiftService';
import {StripePaymentFraudService} from './StripePaymentFraudService';
import type {StripePremiumService} from './StripePremiumService';
import type {StripeRefundService} from './StripeRefundService';
import {StripeSubscriptionReconciler} from './StripeSubscriptionReconciler';
import {StripeSubscriptionWebhookHandler} from './StripeSubscriptionWebhookHandler';
@@ -61,6 +62,7 @@ export class StripeWebhookService {
adminRepository: AdminRepository,
snowflakeService: ISnowflakeService,
private billingRepository: BillingRepository,
private refundService: StripeRefundService,
) {
this.checkoutHandler = new StripeCheckoutWebhookHandler(
stripe,
@@ -336,6 +338,7 @@ export class StripeWebhookService {
livemode: event.livemode,
}),
);
await this.refundService.handleRefundWebhookEvent(r);
break;
}
case 'invoice.created':
@@ -8,6 +8,7 @@ import type {
import {HttpResponse, http} from 'msw';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount, type TestAccount} from '../../auth/tests/AuthTestUtils';
import {createUserID} from '../../BrandedTypes';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {createStripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
import {server} from '../../test/msw/server';
@@ -105,18 +106,30 @@ function invoiceListHandler(invoices: ReadonlyArray<MockStripeInvoice>) {
});
}
function refundCreateHandler() {
function refundCreateHandler(opts?: {
status?: 'succeeded' | 'pending' | 'failed';
failureReason?: string;
onRequest?: (idempotencyKey: string | null) => void;
}) {
return http.post(`${STRIPE_API_BASE}/v1/refunds`, async ({request}) => {
opts?.onRequest?.(request.headers.get('idempotency-key'));
const formData = await request.formData();
const params = Object.fromEntries(formData.entries());
const metadata: Record<string, string> = {};
for (const [key, value] of Object.entries(params)) {
const match = key.match(/^metadata\[(.+)\]$/);
if (match) metadata[match[1]] = value as string;
}
return HttpResponse.json({
id: 're_test_self_serve',
object: 'refund',
amount: Number.parseInt((params.amount as string) ?? '0', 10),
currency: 'usd',
status: 'succeeded',
status: opts?.status ?? 'succeeded',
failure_reason: opts?.failureReason ?? null,
payment_intent: params.payment_intent ?? null,
charge: params.charge ?? null,
metadata,
});
});
}
@@ -274,5 +287,62 @@ describe('StripeRefundService self-serve refund', () => {
.expect(400, APIErrorCodes.STRIPE_NO_PURCHASE_HISTORY)
.execute();
});
test('does not finalize cooldown or cancel the subscription while the refund is still pending at the provider', async () => {
server.use(...createStripeApiHandlers().handlers);
server.use(
invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY})]),
refundCreateHandler({status: 'pending'}),
);
const account = await createTestAccount(harness);
await setStripeIds(harness, account, {
stripe_customer_id: MOCK_CUSTOMER_ID,
stripe_subscription_id: MOCK_SUBSCRIPTION_ID,
});
const response = await createBuilder<SelfServeRefundResponse>(harness, account.token)
.post('/premium/refund-latest')
.execute();
expect(response.status).toBe('pending');
expect(response.refunded_amount_cents).toBe(0);
expect(response.subscription_id).toBeNull();
const {UserRepository} = await import('../../user/repositories/UserRepository');
const updatedUser = await new UserRepository().findUnique(createUserID(BigInt(account.userId)));
expect(updatedUser!.firstRefundAt).toBeNull();
});
test('does not finalize cooldown or cancel the subscription when the refund fails at the provider', async () => {
server.use(...createStripeApiHandlers().handlers);
server.use(
invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY})]),
refundCreateHandler({status: 'failed', failureReason: 'unknown'}),
);
const account = await createTestAccount(harness);
await setStripeIds(harness, account, {
stripe_customer_id: MOCK_CUSTOMER_ID,
stripe_subscription_id: MOCK_SUBSCRIPTION_ID,
});
const response = await createBuilder<SelfServeRefundResponse>(harness, account.token)
.post('/premium/refund-latest')
.execute();
expect(response.status).toBe('failed');
expect(response.refunded_amount_cents).toBe(0);
expect(response.subscription_id).toBeNull();
const {UserRepository} = await import('../../user/repositories/UserRepository');
const updatedUser = await new UserRepository().findUnique(createUserID(BigInt(account.userId)));
expect(updatedUser!.firstRefundAt).toBeNull();
});
test('retries with a fresh idempotency key once a prior attempt has failed at the provider', async () => {
server.use(...createStripeApiHandlers().handlers);
server.use(invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY})]));
const account = await createTestAccount(harness);
await setStripeIds(harness, account, {stripe_customer_id: MOCK_CUSTOMER_ID});
const idempotencyKeys: Array<string | null> = [];
server.use(refundCreateHandler({status: 'failed', onRequest: (key) => idempotencyKeys.push(key)}));
await createBuilder<SelfServeRefundResponse>(harness, account.token).post('/premium/refund-latest').execute();
await createBuilder<SelfServeRefundResponse>(harness, account.token).post('/premium/refund-latest').execute();
expect(idempotencyKeys).toHaveLength(2);
expect(idempotencyKeys[0]).not.toBeNull();
expect(idempotencyKeys[1]).not.toBeNull();
expect(idempotencyKeys[1]).not.toBe(idempotencyKeys[0]);
expect(idempotencyKeys[1]).toContain('retry-1');
});
});
});
@@ -2,12 +2,14 @@
import crypto from 'node:crypto';
import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {HttpResponse, http} from 'msw';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {createUserID} from '../../BrandedTypes';
import {Config} from '../../Config';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {createMockWebhookPayload, type StripeWebhookEventData} from '../../test/msw/handlers/StripeApiHandlers';
import {server} from '../../test/msw/server';
import {createBuilder} from '../../test/TestRequestBuilder';
import {UserRepository} from '../../user/repositories/UserRepository';
import {setupSyncStripeWebhookWorker} from './StripeWebhookTestUtils';
@@ -341,4 +343,90 @@ describe('Stripe Webhook Refund', () => {
expect(updatedRedeemer.premium_type).toBe(UserPremiumTypes.LIFETIME);
});
});
describe('refund.updated', () => {
test('finalizes self-serve cooldown and cancels the subscription once the refund is confirmed succeeded', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const userRepository = new UserRepository();
const subscriptionId = 'sub_test_webhook_finalize';
await userRepository.patchUpsert(
userId,
{stripe_subscription_id: subscriptionId},
(await userRepository.findUnique(userId))!.toRow(),
);
server.use(
http.delete('https://api.stripe.com/v1/subscriptions/:id', ({params}) =>
HttpResponse.json({id: params.id, object: 'subscription', status: 'canceled'}),
),
);
await sendWebhook({
type: 'refund.updated',
data: {
object: {
id: 'pyr_test_webhook_finalize',
status: 'succeeded',
amount: 2499,
currency: 'brl',
metadata: {
refund_kind: 'self_serve',
user_id: account.userId.toString(),
invoice_id: 'in_test_webhook_finalize',
subscription_id: subscriptionId,
},
},
},
});
const updatedUser = await userRepository.findUnique(userId);
expect(updatedUser!.firstRefundAt).not.toBeNull();
});
test('does not finalize cooldown while the refund is still pending', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const userRepository = new UserRepository();
await sendWebhook({
type: 'refund.updated',
data: {
object: {
id: 'pyr_test_webhook_pending',
status: 'pending',
amount: 2499,
currency: 'brl',
metadata: {
refund_kind: 'self_serve',
user_id: account.userId.toString(),
invoice_id: 'in_test_webhook_pending',
},
},
},
});
const updatedUser = await userRepository.findUnique(userId);
expect(updatedUser!.firstRefundAt).toBeNull();
});
});
describe('refund.failed', () => {
test('does not finalize cooldown when the refund ultimately fails', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const userRepository = new UserRepository();
await sendWebhook({
type: 'refund.failed',
data: {
object: {
id: 'pyr_test_webhook_failed',
status: 'failed',
failure_reason: 'unknown',
amount: 2499,
currency: 'brl',
metadata: {
refund_kind: 'self_serve',
user_id: account.userId.toString(),
invoice_id: 'in_test_webhook_failed',
},
},
},
});
const updatedUser = await userRepository.findUnique(userId);
expect(updatedUser!.firstRefundAt).toBeNull();
});
});
});
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {describe, expect, it} from 'vitest';
import {UserContentServiceTestHooks} from './UserContentService';
const {isUnreachableEntityError} = UserContentServiceTestHooks;
describe('isUnreachableEntityError', () => {
it('treats a deleted or left community as unreachable rather than fatal', () => {
expect(isUnreachableEntityError(new UnknownGuildError())).toBe(true);
});
it('treats a gone channel and a lost permission as unreachable', () => {
expect(isUnreachableEntityError(new UnknownChannelError())).toBe(true);
expect(isUnreachableEntityError(new MissingPermissionsError())).toBe(true);
});
it('leaves a deleted message to the delete path instead of marking it unavailable', () => {
expect(isUnreachableEntityError(new UnknownMessageError())).toBe(false);
});
it('still lets unexpected failures surface', () => {
expect(isUnreachableEntityError(new Error('database is on fire'))).toBe(false);
expect(isUnreachableEntityError(null)).toBe(false);
});
});
@@ -6,6 +6,7 @@ import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownCha
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
import {MaxBookmarksError} from '@fluxer/errors/src/domains/core/MaxBookmarksError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {HarvestExpiredError} from '@fluxer/errors/src/domains/moderation/HarvestExpiredError';
import {HarvestFailedError} from '@fluxer/errors/src/domains/moderation/HarvestFailedError';
import {HarvestNotReadyError} from '@fluxer/errors/src/domains/moderation/HarvestNotReadyError';
@@ -94,6 +95,13 @@ function normalizeProviderEnvironment(
return platform === 'ios_apns' ? DEFAULT_APNS_PROVIDER_ENVIRONMENT : null;
}
const isUnreachableEntityError = (error: unknown): boolean =>
error instanceof MissingPermissionsError ||
error instanceof UnknownChannelError ||
error instanceof UnknownGuildError;
export const UserContentServiceTestHooks = {isUnreachableEntityError};
export class UserContentService {
private readonly updatePropagator: BaseUserUpdatePropagator;
private readonly userRepository: UserContentRepository;
@@ -138,11 +146,7 @@ export class UserContentService {
messageId: mention.messageId,
});
} catch (error) {
if (
error instanceof UnknownMessageError ||
error instanceof MissingPermissionsError ||
error instanceof UnknownChannelError
) {
if (error instanceof UnknownMessageError || isUnreachableEntityError(error)) {
return null;
}
throw error;
@@ -188,7 +192,7 @@ export class UserContentService {
await this.userRepository.deleteSavedMessage(userId, savedMessage.messageId);
return null;
}
if (error instanceof MissingPermissionsError || error instanceof UnknownChannelError) {
if (isUnreachableEntityError(error)) {
status = 'missing_permissions';
} else {
throw error;
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {candidateTtlSecondsFor} from './VoiceReconciliationWorker';
const INTERVAL_MS = 15000;
const GATEWAY_ONLY_GRACE_MS = 10000;
function ttlFor(observedSweepSpacingMs: number): number {
return candidateTtlSecondsFor({
intervalMs: INTERVAL_MS,
observedSweepSpacingMs,
graceMs: GATEWAY_ONLY_GRACE_MS,
});
}
describe('candidateTtlSecondsFor', () => {
it('outlives the gap between two consecutive observations of the same key', () => {
for (const observedSweepSpacingMs of [0, 45_000, 136_000, 300_000, 596_000, 900_000]) {
expect(ttlFor(observedSweepSpacingMs) * 1000).toBeGreaterThan(observedSweepSpacingMs);
}
});
it('outlives a sweep gap far longer than the tick interval', () => {
expect(ttlFor(596_000) * 1000).toBeGreaterThan(596_000);
});
it('grows with the observed sweep spacing rather than the tick interval', () => {
expect(ttlFor(596_000)).toBeGreaterThan(ttlFor(136_000));
expect(ttlFor(136_000)).toBeGreaterThan(ttlFor(0));
});
it('keeps a floor that survives a single long sweep before any spacing is observed', () => {
expect(ttlFor(0)).toBeGreaterThanOrEqual(300);
});
it('stays bounded so a stale candidate cannot outlive its connection indefinitely', () => {
expect(ttlFor(Number.MAX_SAFE_INTEGER)).toBeLessThanOrEqual(3600);
});
});
@@ -96,9 +96,24 @@ const DEFAULT_STAGGER_DELAY_MS = 25;
const DEFAULT_LOCK_TTL_SECONDS = 180;
const DEFAULT_GATEWAY_ONLY_GRACE_MS = 10000;
const DEFAULT_LIVEKIT_ONLY_GRACE_MS = 60000;
const MIN_CANDIDATE_TTL_SECONDS = 300;
const MAX_CANDIDATE_TTL_SECONDS = 3600;
const CANDIDATE_TTL_SWEEP_MULTIPLIER = 3;
const LAST_SWEEP_KEY_TTL_SECONDS = 86400;
const ROOM_KEY_PREFIX = 'voice:room:server:';
export function candidateTtlSecondsFor(input: {
intervalMs: number;
observedSweepSpacingMs: number;
graceMs: number;
}): number {
const spacingMs = Math.max(input.intervalMs, input.observedSweepSpacingMs);
const ttlSeconds = Math.ceil((spacingMs * CANDIDATE_TTL_SWEEP_MULTIPLIER + input.graceMs * 2) / 1000);
return Math.min(MAX_CANDIDATE_TTL_SECONDS, Math.max(MIN_CANDIDATE_TTL_SECONDS, ttlSeconds));
}
const VOICE_RECONCILIATION_LOCK_KEY = 'voice:reconcile:lock';
const VOICE_RECONCILIATION_CADENCE_KEY = 'voice:reconcile:cadence';
const VOICE_RECONCILIATION_LAST_SWEEP_KEY = 'voice:reconcile:last-sweep-at';
const GATEWAY_ONLY_CANDIDATE_KEY_PREFIX = 'voice:reconcile:gateway-only:';
const LIVEKIT_ONLY_CANDIDATE_KEY_PREFIX = 'voice:reconcile:livekit-only:';
@@ -115,8 +130,7 @@ export class VoiceReconciliationWorker {
private readonly cadenceTtlSeconds: number;
private readonly gatewayOnlyGraceMs: number;
private readonly liveKitOnlyGraceMs: number;
private readonly gatewayOnlyCandidateTtlSeconds: number;
private readonly liveKitOnlyCandidateTtlSeconds: number;
private observedSweepSpacingMs = 0;
private intervalHandle: NodeJS.Timeout | null = null;
private reconciling = false;
private reconciliationLockLost = false;
@@ -136,14 +150,6 @@ export class VoiceReconciliationWorker {
this.cadenceTtlSeconds = options.cadenceTtlSeconds ?? Math.max(1, Math.ceil((this.intervalMs * 3) / 1000));
this.gatewayOnlyGraceMs = options.gatewayOnlyGraceMs ?? DEFAULT_GATEWAY_ONLY_GRACE_MS;
this.liveKitOnlyGraceMs = options.liveKitOnlyGraceMs ?? DEFAULT_LIVEKIT_ONLY_GRACE_MS;
this.gatewayOnlyCandidateTtlSeconds = Math.max(
60,
Math.ceil((this.intervalMs * 4 + this.gatewayOnlyGraceMs * 4) / 1000),
);
this.liveKitOnlyCandidateTtlSeconds = Math.max(
60,
Math.ceil((this.intervalMs * 4 + this.liveKitOnlyGraceMs * 4) / 1000),
);
}
start(): void {
@@ -156,6 +162,7 @@ export class VoiceReconciliationWorker {
intervalMs: this.intervalMs,
gatewayOnlyGraceMs: this.gatewayOnlyGraceMs,
liveKitOnlyGraceMs: this.liveKitOnlyGraceMs,
gatewayOnlyCandidateTtlSeconds: this.candidateTtlSeconds(this.gatewayOnlyGraceMs),
},
'Starting VoiceReconciliationWorker',
);
@@ -175,6 +182,7 @@ export class VoiceReconciliationWorker {
async reconcile(): Promise<void> {
const startTime = Date.now();
await this.recordSweepSpacing(startTime);
const discovery = await this.discoverActiveRooms();
this.logger.info(
{
@@ -233,6 +241,8 @@ export class VoiceReconciliationWorker {
const durationMs = Date.now() - startTime;
this.logger.info(
{
observedSweepSpacingMs: this.observedSweepSpacingMs,
gatewayOnlyCandidateTtlSeconds: this.candidateTtlSeconds(this.gatewayOnlyGraceMs),
roomsChecked,
totalConfirmed,
totalRepaired,
@@ -300,6 +310,37 @@ export class VoiceReconciliationWorker {
}
}
private candidateTtlSeconds(graceMs: number): number {
return candidateTtlSecondsFor({
intervalMs: this.intervalMs,
observedSweepSpacingMs: this.observedSweepSpacingMs,
graceMs,
});
}
private async recordSweepSpacing(startedAt: number): Promise<void> {
try {
const previous = await this.kvClient.get(VOICE_RECONCILIATION_LAST_SWEEP_KEY);
const previousAt = previous === null ? Number.NaN : Number(previous);
if (Number.isFinite(previousAt) && startedAt > previousAt) {
this.observedSweepSpacingMs = Math.max(this.observedSweepSpacingMs, startedAt - previousAt);
const gatewayOnlyCandidateTtlSeconds = this.candidateTtlSeconds(this.gatewayOnlyGraceMs);
if (this.observedSweepSpacingMs >= gatewayOnlyCandidateTtlSeconds * 1000) {
this.logger.warn(
{
observedSweepSpacingMs: this.observedSweepSpacingMs,
gatewayOnlyCandidateTtlSeconds,
},
'Reconciliation sweeps are further apart than the candidate TTL; divergent voice states will be deferred forever',
);
}
}
await this.kvClient.setex(VOICE_RECONCILIATION_LAST_SWEEP_KEY, LAST_SWEEP_KEY_TTL_SECONDS, String(startedAt));
} catch (error) {
this.logger.warn({error}, 'Failed to record reconciliation sweep spacing');
}
}
private async acquireCadenceLease(): Promise<boolean> {
try {
return await this.kvClient.setnx(VOICE_RECONCILIATION_CADENCE_KEY, '1', this.cadenceTtlSeconds);
@@ -993,7 +1034,7 @@ export class VoiceReconciliationWorker {
}
await this.kvClient.setex(
key,
this.gatewayOnlyCandidateTtlSeconds,
this.candidateTtlSeconds(this.gatewayOnlyGraceMs),
Number.isFinite(firstSeen) ? String(firstSeen) : String(now),
);
return false;
@@ -1024,7 +1065,7 @@ export class VoiceReconciliationWorker {
}
await this.kvClient.setex(
key,
this.liveKitOnlyCandidateTtlSeconds,
this.candidateTtlSeconds(this.liveKitOnlyGraceMs),
Number.isFinite(firstSeen) ? String(firstSeen) : String(now),
);
return false;
@@ -243,6 +243,12 @@ export async function initializeWorkerDependencies(snowflakeService: ISnowflakeS
voiceRoomStore,
kvClient,
logger: Logger,
intervalMs: Config.worker.voiceReconciliation.intervalMs,
staggerDelayMs: Config.worker.voiceReconciliation.staggerDelayMs,
lockTtlSeconds: Config.worker.voiceReconciliation.lockTtlSeconds,
cadenceTtlSeconds: Config.worker.voiceReconciliation.cadenceTtlSeconds,
gatewayOnlyGraceMs: Config.worker.voiceReconciliation.gatewayOnlyGraceMs,
liveKitOnlyGraceMs: Config.worker.voiceReconciliation.liveKitOnlyGraceMs,
})
: null;
if (Config.voice.enabled && voiceTopology !== null) {
@@ -10,6 +10,7 @@ import {AgeVerificationService} from '../../stripe/services/AgeVerificationServi
import {StripeCheckoutService} from '../../stripe/services/StripeCheckoutService';
import {StripeGiftService} from '../../stripe/services/StripeGiftService';
import {StripePremiumService} from '../../stripe/services/StripePremiumService';
import {StripeRefundService} from '../../stripe/services/StripeRefundService';
import {StripeSubscriptionService} from '../../stripe/services/StripeSubscriptionService';
import {StripeWebhookService} from '../../stripe/services/StripeWebhookService';
import type {IUserRepositoryAggregate} from '../../user/repositories/IUserRepositoryAggregate';
@@ -80,6 +81,7 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => {
const ageVerificationService = deps.stripe
? new AgeVerificationService(deps.stripe, deps.userRepository, deps.gatewayService, deps.cacheService)
: null;
const refundService = new StripeRefundService(deps.stripe, deps.userRepository, subscriptionService);
const webhookService = new StripeWebhookService(
deps.stripe,
checkoutService,
@@ -99,6 +101,7 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => {
deps.adminRepository,
deps.snowflakeService,
deps.billingRepository,
refundService,
);
await webhookService.handleWebhook({body, signature});
};
@@ -52,6 +52,15 @@ source edits in this package.
Forces Opus RED/FEC, stereo signaling, 10 ms packet time, no DTX, and a
510 kbps maximum average bitrate in local offers and remote answers.
10. **Remote audio volume restore at exactly zero** (`src/room/track/RemoteAudioTrack.ts`)
`attach()`, `connectWebAudio()` and `getVolume()` guarded the remembered
`elementVolume` with a truthiness check, so a track deliberately held at `0`
came back at full volume whenever it was re-attached or its Web Audio graph
was rebuilt. All three guards now test `!== undefined`. Note that remote
gains above `1.0` are only legal because `setVolume()` takes the Web Audio
`gainNode` branch; the `el.volume` branch would throw `IndexSizeError`.
`webAudioMix` must stay unconditional.
## Updating from upstream
1. Check the upstream changelog for the target version.
@@ -64,7 +64,7 @@ export default class RemoteAudioTrack extends RemoteTrack<Track.Kind.Audio> {
}
getVolume(): number {
if (this.elementVolume) {
if (this.elementVolume !== undefined) {
return this.elementVolume;
}
if (isReactNative()) {
@@ -113,7 +113,7 @@ export default class RemoteAudioTrack extends RemoteTrack<Track.Kind.Audio> {
element.muted = true;
}
if (this.elementVolume) {
if (this.elementVolume !== undefined) {
this.setVolume(this.elementVolume);
}
@@ -169,7 +169,7 @@ export default class RemoteAudioTrack extends RemoteTrack<Track.Kind.Audio> {
lastNode.connect(this.gainNode);
this.gainNode.connect(context.destination);
if (this.elementVolume) {
if (this.elementVolume !== undefined) {
this.gainNode.gain.setTargetAtTime(this.elementVolume, 0, 0.1);
}
@@ -3,6 +3,10 @@
import Accessibility from '@app/features/accessibility/state/Accessibility';
import {mergeFrozenUnreadOrder} from '@app/features/app/components/floating/UnreadChannelOrder';
import styles from '@app/features/app/components/floating/UnreadChannelsContent.module.css';
import {
BULK_PREVIEW_CHANNEL_BATCH_SIZE,
UNREAD_PREVIEW_MESSAGE_LIMIT,
} from '@app/features/app/components/floating/UnreadPreviewBudget';
import previewStyles from '@app/features/app/components/shared/MessagePreview.module.css';
import {Endpoints} from '@app/features/app/constants/Endpoints';
import {renderChannelStream} from '@app/features/channel/components/ChannelMessageStream';
@@ -131,8 +135,6 @@ interface ChannelPreviewData {
const INITIAL_VISIBLE_CHANNELS = 10;
const LOAD_MORE_CHUNK = 10;
const UNREAD_PREVIEW_MESSAGE_LIMIT = 5;
const BULK_PREVIEW_CHANNEL_BATCH_SIZE = 10;
interface CacheEntry {
cacheKey: string;
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
BULK_PREVIEW_CHANNEL_BATCH_SIZE,
UNREAD_PREVIEW_MESSAGE_LIMIT,
} from '@app/features/app/components/floating/UnreadPreviewBudget';
import {BulkMessageFetchRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
import {describe, expect, it} from 'vitest';
const buildBatch = (limit: number) => ({
requests: Array.from({length: BULK_PREVIEW_CHANNEL_BATCH_SIZE}, (_, index) => ({
channel_id: String(1000000000000000000n + BigInt(index)),
limit,
})),
});
describe('unread preview fetch budget', () => {
it('keeps the anchored window inside the bulk fetch schema', () => {
expect(() => BulkMessageFetchRequest.parse(buildBatch(UNREAD_PREVIEW_MESSAGE_LIMIT * 2))).not.toThrow();
});
it('keeps the unanchored window inside the bulk fetch schema', () => {
expect(() => BulkMessageFetchRequest.parse(buildBatch(UNREAD_PREVIEW_MESSAGE_LIMIT))).not.toThrow();
});
it('rejects a batch one channel wider than the client sends', () => {
const oversized = buildBatch(UNREAD_PREVIEW_MESSAGE_LIMIT * 2);
oversized.requests.push({channel_id: '1000000000000000099', limit: UNREAD_PREVIEW_MESSAGE_LIMIT * 2});
expect(() => BulkMessageFetchRequest.parse(oversized)).toThrow();
});
});
@@ -0,0 +1,4 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const UNREAD_PREVIEW_MESSAGE_LIMIT = 25;
export const BULK_PREVIEW_CHANNEL_BATCH_SIZE = 5;
@@ -3,7 +3,3 @@
.nativeDragRegion {
-webkit-app-region: none;
}
:global(html.platform-native.platform-macos) .nativeDragRegion {
-webkit-app-region: drag;
}
@@ -332,6 +332,7 @@ export const GuildListItemPresentation = forwardRef<
role="button"
tabIndex={0}
data-guild-list-focus-item="true"
data-guild-id={guild.id}
onLongPress={onLongPress}
disabled={false}
data-flx="app.sidebar-nav.guild-list-item-presentation.guild-list-item.click"
@@ -5,7 +5,7 @@ import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {clsx} from 'clsx';
import type {CSSProperties, FC, ReactNode} from 'react';
import type {FC, ReactNode} from 'react';
const REVEAL_SPOILER_DESCRIPTOR = msg({
message: 'Reveal spoiler',
@@ -23,24 +23,14 @@ interface SpoilerOverlayProps {
label?: string;
inline?: boolean;
className?: string;
style?: CSSProperties;
}
export const SpoilerOverlay: FC<SpoilerOverlayProps> = ({
hidden,
onReveal,
children,
label,
inline,
className,
style,
}) => {
export const SpoilerOverlay: FC<SpoilerOverlayProps> = ({hidden, onReveal, children, label, inline, className}) => {
const {i18n} = useLingui();
const ariaLabel = label ?? i18n._(REVEAL_SPOILER_DESCRIPTOR);
return (
<div
className={clsx(styles.container, inline && styles.inline, hidden && styles.hidden, className)}
style={style}
data-flx="app.spoiler-overlay.container"
>
<div className={styles.content} aria-hidden={hidden} data-flx="app.spoiler-overlay.content">
@@ -19,10 +19,12 @@ export interface PlaceholderMessageGroup {
readonly usernameWidth: number;
readonly timestampWidth: number;
readonly attachment: PlaceholderAttachmentSize | null;
readonly height: number;
}
interface MutablePlaceholderMessageGroup extends PlaceholderMessageGroup {
attachment: PlaceholderAttachmentSize | null;
height: number;
}
export interface PlaceholderSpecs {
@@ -100,20 +102,20 @@ function generatePlaceholderSpecs(options: PlaceholderGenerationOptions): Placeh
for (let line = 0; line < lineCount; line++) {
lineWidths.push(LINE_WIDTH_MIN + random() * LINE_WIDTH_RANGE);
}
const groupHeight = compact
? MESSAGE_HEIGHT_COMPACT * lineCount
: COZY_LEAD_MESSAGE_HEIGHT + COZY_GROUPED_MESSAGE_HEIGHT * (lineCount - 1);
groups.push({
lineWidths,
usernameWidth: USERNAME_WIDTH_MIN + random() * USERNAME_WIDTH_RANGE,
timestampWidth: TIMESTAMP_WIDTH_MIN + random() * TIMESTAMP_WIDTH_RANGE,
attachment: null,
height: groupHeight,
});
if (index > 0) {
totalHeight += groupSpacing;
}
if (compact) {
totalHeight += MESSAGE_HEIGHT_COMPACT * lineCount;
} else {
totalHeight += COZY_LEAD_MESSAGE_HEIGHT + COZY_GROUPED_MESSAGE_HEIGHT * (lineCount - 1);
}
totalHeight += groupHeight;
}
const availableGroupIndices = Array.from(Array(groups.length).keys());
for (let index = 0; index < attachments && availableGroupIndices.length > 0; index++) {
@@ -123,6 +125,7 @@ function generatePlaceholderSpecs(options: PlaceholderGenerationOptions): Placeh
height: randomInRange(random, ATTACHMENT_HEIGHT_MIN, ATTACHMENT_HEIGHT_MAX),
};
groups[groupIndex].attachment = attachment;
groups[groupIndex].height += attachment.height + ATTACHMENT_MARGIN;
totalHeight += attachment.height + ATTACHMENT_MARGIN;
}
return {compact, compactAvatarsVisible, groups, totalHeight, groupSpacing};
@@ -44,6 +44,7 @@
.messageGroup {
position: relative;
z-index: 0;
content-visibility: auto;
}
.group {
@@ -52,7 +52,10 @@ function CozyScrollFillerSkeletonGroup({
return (
<flx-message-list-skeleton-group
className={flxElementClassName(styles.messageGroup)}
style={{marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing)}}
style={{
marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing),
containIntrinsicSize: `auto ${remFromPx(group.height)}`,
}}
data-flx="app.skeleton.scroll-filler-skeleton.cozy-scroll-filler-skeleton-group.message-group"
>
<flx-message-list-skeleton-group-row
@@ -170,7 +173,10 @@ function CompactScrollFillerSkeletonGroup({
return (
<flx-message-list-skeleton-group
className={flxElementClassName(styles.messageGroup)}
style={{marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing)}}
style={{
marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing),
containIntrinsicSize: `auto ${remFromPx(group.height)}`,
}}
data-flx="app.skeleton.scroll-filler-skeleton.compact-scroll-filler-skeleton-group.message-group"
>
<flx-message-list-skeleton-compact-messages
@@ -28,6 +28,8 @@ export const SUPPORT_EMAIL = '[email protected]';
export const SUPPORT_EMAIL_MAILTO = `mailto:${SUPPORT_EMAIL}`;
export const I18N_EMAIL = '[email protected]';
export const I18N_EMAIL_MAILTO = `mailto:${I18N_EMAIL}`;
export const I18N_WEBLATE_DOMAIN = 'weblate.fluxer.tools';
export const I18N_WEBLATE_URL = `https://${I18N_WEBLATE_DOMAIN}`;
export const EXAMPLE_DOMAIN = 'example.com';
export const EXAMPLE_URL = `https://${EXAMPLE_DOMAIN}`;
export const EXAMPLE_CALLBACK_URL = `${EXAMPLE_URL}/callback`;
@@ -16,7 +16,6 @@ class Initialization {
state: InitializationState = InitializationState.LOADING;
hasCompletedInitialLoad = false;
error: string | null = null;
readyPayload: unknown = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
@@ -46,29 +45,25 @@ class Initialization {
setLoading(): void {
this.state = InitializationState.LOADING;
this.error = null;
this.readyPayload = null;
}
@action
setConnecting(): void {
this.state = InitializationState.CONNECTING;
this.error = null;
this.readyPayload = null;
}
@action
setReady(payload: unknown): void {
setReady(): void {
this.state = InitializationState.READY;
this.hasCompletedInitialLoad = true;
this.error = null;
this.readyPayload = payload;
}
@action
setError(error: string): void {
this.state = InitializationState.ERROR;
this.error = error;
this.readyPayload = null;
}
@action
@@ -76,7 +71,6 @@ class Initialization {
this.state = InitializationState.LOADING;
this.hasCompletedInitialLoad = false;
this.error = null;
this.readyPayload = null;
}
}
@@ -28,6 +28,10 @@ import {
SearchIndexingState,
} from '@app/features/channel/components/channel_search_results/SearchResultsStateViews';
import type {MessageGroupRenderWrapperProps} from '@app/features/channel/components/MessageGroup';
import {
buildSearchResultGroups,
buildSearchResultGroupsByMessageId,
} from '@app/features/channel/components/SearchResultGrouping';
import {SearchResultMessageList} from '@app/features/channel/components/SearchResultMessageList';
import {areSegmentsEqual} from '@app/features/channel/components/SearchResultsUtils';
import {DEFAULT_SCOPE_VALUE, getScopeOptionsForChannel} from '@app/features/channel/components/SearchScopeOptions';
@@ -134,35 +138,27 @@ export const ChannelSearchResults = observer(
() => new Map(successChannels.map((searchChannel) => [searchChannel.id, searchChannel])),
[successChannels],
);
const messagesByChannel = useMemo(() => {
const grouped = new Map<string, Array<Message>>();
for (const message of successResults) {
if (!grouped.has(message.channelId)) {
grouped.set(message.channelId, []);
}
grouped.get(message.channelId)!.push(message);
}
return grouped;
}, [successResults]);
const resultGroups = useMemo(() => buildSearchResultGroups(successResults), [successResults]);
const resultGroupsByMessageId = useMemo(() => buildSearchResultGroupsByMessageId(resultGroups), [resultGroups]);
const onCopySelectedMessages = useMessageSelectionCopyForMessages<HTMLDivElement>(successResults);
const spammerOverrideVersion = LocalUserSpamOverride.version;
const collapsedMessageVisibility = useMemo(
() => ({
isMessageRevealed: (message: Message) => {
const channelMessages = messagesByChannel.get(message.channelId);
if (!channelMessages) return false;
const resultGroup = resultGroupsByMessageId.get(message.id);
if (!resultGroup) return false;
const messageChannel = searchChannelsById.get(message.channelId) ?? Channels.getChannel(message.channelId);
if (!messageChannel) return false;
const groupKey = getCollapsedMessageGroupKey({
channel: messageChannel,
messages: channelMessages,
messages: resultGroup.messages,
messageId: message.id,
treatSpam: true,
});
return groupKey != null && revealedGroupKeys.has(groupKey);
},
}),
[messagesByChannel, revealedGroupKeys, searchChannelsById, spammerOverrideVersion],
[resultGroupsByMessageId, revealedGroupKeys, searchChannelsById, spammerOverrideVersion],
);
const handleCollapsedGroupRevealChange = useCallback((groupKey: string, revealed: boolean) => {
setRevealedGroupKeys((current) => {
@@ -606,9 +602,9 @@ export const ChannelSearchResults = observer(
data-message-selection-root="true"
data-flx="channel.channel-search-results.render-content.results-scroller"
>
{Array.from(messagesByChannel.entries()).map(([resultChannelId, messages]) => {
{resultGroups.map((resultGroup) => {
const renderData = getSearchResultChannelRenderData(
resultChannelId,
resultGroup.channelId,
searchChannelsById,
(activeScope ?? DEFAULT_SCOPE_VALUE) as MessageSearchScope,
);
@@ -649,7 +645,7 @@ export const ChannelSearchResults = observer(
</div>
);
return (
<React.Fragment key={resultChannelId}>
<React.Fragment key={resultGroup.key}>
<MessageContextPrefix
channel={messageChannel}
showGuildMeta={showGuildMeta}
@@ -660,7 +656,7 @@ export const ChannelSearchResults = observer(
/>
<SearchResultMessageList
channel={messageChannel}
messages={messages}
messages={resultGroup.messages}
revealedGroupKeys={revealedGroupKeys}
onGroupRevealChange={handleCollapsedGroupRevealChange}
collapsedGroupClassName={styles.collapsedMessageGroup}
@@ -725,7 +721,7 @@ export const ChannelSearchResults = observer(
handlePaginationJump,
onCopySelectedMessages,
collapsedMessageVisibility,
messagesByChannel,
resultGroups,
searchChannelsById,
revealedGroupKeys,
handleCollapsedGroupRevealChange,
@@ -1375,7 +1375,10 @@ export const LexicalChannelTextareaContent = observer(
showStickersButton={showStickersButton}
showEmojiButton={showEmojiButton}
showMessageSendButton={showMessageSendButton}
showVoiceMessageButton={false}
canRecordVoice={canAttachFilesInChannel(channel)}
isEditingMessage={isEditingMessageInComposer || editingMessageId != null}
hasPendingSticker={hasPendingSticker}
voiceTooltipAnchorRef={contentAreaRef}
expressionPickerOpen={expressionPickerOpen}
selectedTab={selectedTab}
isMobile={mobileLayout.enabled}
@@ -2,6 +2,8 @@
import type {MessageAttachment} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
export const ATTACHMENT_CARD_WIDTH = 400;
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp', 'image/avif'];
const VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime'];
@@ -48,7 +48,7 @@
margin-top: var(--message-container-gap, 0.25rem);
}
.urlEmbedSpoiler {
div.urlEmbedSpoiler {
width: 100%;
max-width: 22.5rem;
}
@@ -109,7 +109,6 @@ const SpoileredUrlEmbed = observer(function SpoileredUrlEmbed({
hidden={hidden}
onReveal={reveal}
className={styles.urlEmbedSpoiler}
style={{width: '100%', maxWidth: 360}}
data-flx="channel.message-attachments.spoilered-url-embed"
>
{children}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ATTACHMENT_CARD_WIDTH} from '@app/features/channel/components/MessageAttachmentUtils';
import styles from '@app/features/channel/components/MessageUploadProgress.module.css';
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import {useMessageUpload} from '@app/features/messaging/hooks/useCloudUpload';
@@ -77,8 +78,8 @@ export const MessageUploadProgress = observer(({attachment, message}: MessageUpl
}
: {
display: 'grid',
width: '400px',
maxWidth: '400px',
width: remFromPx(ATTACHMENT_CARD_WIDTH),
maxWidth: remFromPx(ATTACHMENT_CARD_WIDTH),
};
return (
<div style={containerStyles} data-flx="channel.message-upload-progress.div">
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import {describe, expect, it} from 'vitest';
import {
buildSearchResultGroups,
buildSearchResultGroupsByMessageId,
countSearchResultChannels,
} from './SearchResultGrouping';
const message = (id: string, channelId: string): Message => ({id, channelId}) as Message;
describe('SearchResultGrouping', () => {
it('keeps the order the server returned instead of bucketing per channel', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('400', 'random'),
message('300', 'general'),
]);
expect(groups.map((group) => group.messages.map((result) => result.id))).toEqual([['500'], ['400'], ['300']]);
expect(groups.map((group) => group.channelId)).toEqual(['general', 'random', 'general']);
});
it('merges consecutive results from the same channel into one group', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('490', 'general'),
message('400', 'random'),
]);
expect(groups).toHaveLength(2);
expect(groups[0].messages.map((result) => result.id)).toEqual(['500', '490']);
expect(groups[1].messages.map((result) => result.id)).toEqual(['400']);
});
it('gives every group a unique key even when a channel appears more than once', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('400', 'random'),
message('300', 'general'),
]);
expect(new Set(groups.map((group) => group.key)).size).toBe(3);
});
it('indexes each message to the group it renders in', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('400', 'random'),
message('300', 'general'),
]);
const groupsByMessageId = buildSearchResultGroupsByMessageId(groups);
expect(groupsByMessageId.get('500')).toBe(groups[0]);
expect(groupsByMessageId.get('400')).toBe(groups[1]);
expect(groupsByMessageId.get('300')).toBe(groups[2]);
});
it('counts distinct channels rather than groups', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('400', 'random'),
message('300', 'general'),
]);
expect(countSearchResultChannels(groups)).toBe(2);
expect(countSearchResultChannels(buildSearchResultGroups([]))).toBe(0);
});
});
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Message} from '@app/features/messaging/models/MessagingMessage';
export interface SearchResultGroup {
key: string;
channelId: string;
messages: Array<Message>;
}
export const buildSearchResultGroups = (messages: Array<Message>): Array<SearchResultGroup> => {
const groups: Array<SearchResultGroup> = [];
let currentGroup: SearchResultGroup | null = null;
for (const message of messages) {
if (!currentGroup || currentGroup.channelId !== message.channelId) {
currentGroup = {key: `${message.channelId}-${message.id}`, channelId: message.channelId, messages: []};
groups.push(currentGroup);
}
currentGroup.messages.push(message);
}
return groups;
};
export const buildSearchResultGroupsByMessageId = (
groups: Array<SearchResultGroup>,
): Map<string, SearchResultGroup> => {
const groupsByMessageId = new Map<string, SearchResultGroup>();
for (const group of groups) {
for (const message of group.messages) {
groupsByMessageId.set(message.id, group);
}
}
return groupsByMessageId;
};
export const countSearchResultChannels = (groups: Array<SearchResultGroup>): number => {
return new Set(groups.map((group) => group.channelId)).size;
};
@@ -74,7 +74,11 @@ export function formatSlowmodeDuration(ms: number, locale: string): string {
return formatDurationPart(totalSeconds, 'second', locale);
}
if (totalSeconds < SECONDS_PER_HOUR) {
return formatDurationPart(Math.round(totalSeconds / SECONDS_PER_MINUTE), 'minute', locale);
const minutes = Math.floor(totalSeconds / SECONDS_PER_MINUTE);
const remainingSeconds = totalSeconds % SECONDS_PER_MINUTE;
const minutePart = formatDurationPart(minutes, 'minute', locale);
if (remainingSeconds === 0) return minutePart;
return `${minutePart} ${formatDurationPart(remainingSeconds, 'second', locale)}`;
}
if (totalSeconds < SECONDS_PER_DAY) {
const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR);
@@ -770,8 +770,46 @@ export const UserMessage = observer(() => {
/>
</Tooltip>
)}
{(message.editedTimestamp || message.isEditing) &&
(message.isEditing ? (
<span className={styles.editedLabel} data-flx="channel.user-message.edited-label--3">
{' '}
{i18n._(EDITED_DESCRIPTOR)}
</span>
) : (
<TimestampWithTooltip
date={message.editedTimestamp!}
className={styles.editedTimestamp}
data-flx="channel.user-message.edited-timestamp--2"
>
<span className={styles.editedLabel} data-flx="channel.user-message.edited-label--4">
{' '}
{i18n._(EDITED_DESCRIPTOR)}
</span>
</TimestampWithTooltip>
))}
</AuthorHeading>
)}
{((!message.content && !isEditing) || (shouldHideContent && !isEditing)) &&
shouldGroup &&
(message.editedTimestamp || message.isEditing) &&
(message.isEditing ? (
<span className={styles.editedLabel} data-flx="channel.user-message.edited-label--5">
{' '}
{i18n._(EDITED_DESCRIPTOR)}
</span>
) : (
<TimestampWithTooltip
date={message.editedTimestamp!}
className={styles.editedTimestamp}
data-flx="channel.user-message.edited-timestamp--3"
>
<span className={styles.editedLabel} data-flx="channel.user-message.edited-label--6">
{' '}
{i18n._(EDITED_DESCRIPTOR)}
</span>
</TimestampWithTooltip>
))}
<MessageAttachments data-flx="channel.user-message.message-attachments--3" />
{renderFailedFooter()}
</div>
@@ -32,14 +32,18 @@ export const VerificationBarrier = observer(({channel}: Props) => {
case VerificationFailureReason.ACCOUNT_TOO_NEW:
return (
<AccountTooNewBarrier
initialTimeRemaining={verificationStatus.timeRemaining || 0}
initialTimeRemaining={
verificationStatus.verificationEndsAt ? Math.max(0, verificationStatus.verificationEndsAt - Date.now()) : 0
}
data-flx="channel.verification-barrier.account-too-new-barrier"
/>
);
case VerificationFailureReason.NOT_MEMBER_LONG_ENOUGH:
return (
<NotMemberLongEnoughBarrier
initialTimeRemaining={verificationStatus.timeRemaining || 0}
initialTimeRemaining={
verificationStatus.verificationEndsAt ? Math.max(0, verificationStatus.verificationEndsAt - Date.now()) : 0
}
data-flx="channel.verification-barrier.not-member-long-enough-barrier"
/>
);
@@ -50,7 +54,9 @@ export const VerificationBarrier = observer(({channel}: Props) => {
case VerificationFailureReason.TIMED_OUT:
return (
<TimeoutBarrier
initialTimeRemaining={verificationStatus.timeRemaining || 0}
initialTimeRemaining={
verificationStatus.verificationEndsAt ? Math.max(0, verificationStatus.verificationEndsAt - Date.now()) : 0
}
data-flx="channel.verification-barrier.timeout-barrier"
/>
);
@@ -7,6 +7,34 @@
justify-content: center;
}
.voiceButton {
display: flex;
align-items: center;
justify-content: center;
min-width: var(--textarea-button-height);
height: var(--textarea-button-height);
padding: 0;
color: var(--text-primary-muted);
border-radius: var(--radius-md);
background: transparent;
border: none;
outline: none;
cursor: pointer;
flex-shrink: 0;
touch-action: none;
}
.voiceButton:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.voiceButtonIcon {
width: var(--textarea-button-icon-size);
height: var(--textarea-button-icon-size);
flex-shrink: 0;
}
.holdTooltip {
background-color: var(--brand-primary);
color: var(--text-on-brand-primary);
@@ -33,6 +61,10 @@
}
.overlayInner {
display: flex;
align-items: flex-end;
gap: 0.375rem;
padding: 0.5rem;
width: 100%;
min-width: 0;
box-sizing: border-box;
@@ -133,6 +165,7 @@
flex-shrink: 0;
display: flex;
align-items: center;
width: 2.25rem;
height: 2.25rem;
}
@@ -2,7 +2,6 @@
import Accessibility from '@app/features/accessibility/state/Accessibility';
import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter';
import layoutStyles from '@app/features/channel/components/textarea/MobileTextareaLayout.module.css';
import styles from '@app/features/channel/components/VoiceMessageRecorder.module.css';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {usePortalHost} from '@app/features/ui/overlay/PortalHostContext';
@@ -529,7 +528,7 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
>
<motion.button
type="button"
className={layoutStyles.mobileVoiceButton}
className={styles.voiceButton}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerMove={handlePointerMove}
@@ -541,7 +540,7 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
data-flx="channel.voice-message-recorder.button.pointer-down"
>
<MicrophoneIcon
className={layoutStyles.mobileRightButtonIcon}
className={styles.voiceButtonIcon}
data-flx="channel.voice-message-recorder.microphone-icon"
/>
</motion.button>
@@ -578,10 +577,7 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
transition={resolvedOverlayMotion.transition}
data-flx="channel.voice-message-recorder.recording-overlay"
>
<div
className={clsx(styles.overlayInner, layoutStyles.mobileTextareaWrapper)}
data-flx="channel.voice-message-recorder.overlay-inner"
>
<div className={styles.overlayInner} data-flx="channel.voice-message-recorder.overlay-inner">
<button
type="button"
className={clsx(styles.iconButton, styles.trashButton)}
@@ -610,7 +606,7 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
</div>
</div>
<div
className={clsx(styles.lockIndicatorWrapper, layoutStyles.mobileRightButtonContainer)}
className={styles.lockIndicatorWrapper}
data-flx="channel.voice-message-recorder.lock-indicator-wrapper"
>
<button
@@ -8,6 +8,11 @@ import {Message as MessageComponent} from '@app/features/channel/components/Chan
import {CollapsedMessageVisibilityProvider} from '@app/features/channel/components/CollapsedMessageVisibilityContext';
import {MessageActionBottomSheet} from '@app/features/channel/components/MessageActionBottomSheet';
import type {MessageGroupRenderWrapperProps} from '@app/features/channel/components/MessageGroup';
import {
buildSearchResultGroups,
buildSearchResultGroupsByMessageId,
countSearchResultChannels,
} from '@app/features/channel/components/SearchResultGrouping';
import {SearchResultMessageList} from '@app/features/channel/components/SearchResultMessageList';
import {type ChannelSearchFilters, useChannelSearch} from '@app/features/channel/hooks/useChannelSearch';
import type {Channel} from '@app/features/channel/models/Channel';
@@ -216,22 +221,17 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
() => new Map(successChannels.map((searchChannel) => [searchChannel.id, searchChannel])),
[successChannels],
);
const messagesByChannel = React.useMemo(() => {
const grouped = new Map<string, Array<Message>>();
for (const message of successResults) {
if (!grouped.has(message.channelId)) {
grouped.set(message.channelId, []);
}
grouped.get(message.channelId)!.push(message);
}
return grouped;
}, [successResults]);
const resultGroups = React.useMemo(() => buildSearchResultGroups(successResults), [successResults]);
const resultGroupsByMessageId = React.useMemo(
() => buildSearchResultGroupsByMessageId(resultGroups),
[resultGroups],
);
const spammerOverrideVersion = LocalUserSpamOverride.version;
const collapsedMessageVisibility = React.useMemo(
() => ({
isMessageRevealed: (message: Message) => {
const channelMessages = messagesByChannel.get(message.channelId);
if (!channelMessages) {
const resultGroup = resultGroupsByMessageId.get(message.id);
if (!resultGroup) {
return false;
}
const messageChannel = searchChannelsById.get(message.channelId) ?? Channels.getChannel(message.channelId);
@@ -240,14 +240,14 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
}
const groupKey = getCollapsedMessageGroupKey({
channel: messageChannel,
messages: channelMessages,
messages: resultGroup.messages,
messageId: message.id,
treatSpam: true,
});
return groupKey != null && revealedGroupKeys.has(groupKey);
},
}),
[messagesByChannel, revealedGroupKeys, searchChannelsById, spammerOverrideVersion],
[resultGroupsByMessageId, revealedGroupKeys, searchChannelsById, spammerOverrideVersion],
);
const handleCollapsedGroupRevealChange = useCallback((groupKey: string, revealed: boolean) => {
setRevealedGroupKeys((current) => {
@@ -598,7 +598,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
);
}
const totalPages = Math.max(1, Math.ceil(total / hitsPerPage));
const hasMultipleChannels = messagesByChannel.size > 1;
const hasMultipleChannels = countSearchResultChannels(resultGroups) > 1;
return (
<>
<CollapsedMessageVisibilityProvider
@@ -613,8 +613,9 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
data-message-selection-root="true"
data-flx="channel.channel-search-bottom-sheet.render-content.results-scroller"
>
{Array.from(messagesByChannel.entries()).map(([channelId, messages]) => {
const messageChannel = searchChannelsById.get(channelId) ?? Channels.getChannel(channelId);
{resultGroups.map((resultGroup) => {
const messageChannel =
searchChannelsById.get(resultGroup.channelId) ?? Channels.getChannel(resultGroup.channelId);
if (!messageChannel) {
return null;
}
@@ -648,7 +649,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
</LongPressable>
);
return (
<React.Fragment key={channelId}>
<React.Fragment key={resultGroup.key}>
{hasMultipleChannels && (
<div
className={styles.channelSection}
@@ -667,7 +668,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
)}
<SearchResultMessageList
channel={messageChannel}
messages={messages}
messages={resultGroup.messages}
revealedGroupKeys={revealedGroupKeys}
onGroupRevealChange={handleCollapsedGroupRevealChange}
collapsedGroupClassName={styles.collapsedMessageGroup}
@@ -44,7 +44,6 @@ import {useVoiceCallFullscreenViewState} from '@app/features/voice/components/us
import {VoiceCallView} from '@app/features/voice/components/VoiceCallView';
import {VoiceE2EEIndicator} from '@app/features/voice/components/VoiceE2EEIndicator';
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
import {isNativeVoiceEngineSelected} from '@app/features/voice/engine/native_voice_engine/getVoiceEngine';
import {useCompactCallExpansionState} from '@app/features/voice/hooks/useCompactCallExpansionState';
import {usePendingVoiceConnection} from '@app/features/voice/hooks/usePendingVoiceConnection';
import {getGuildVoiceCallExpansionKey} from '@app/features/voice/state/CompactVoiceCallHeight';
@@ -182,7 +181,7 @@ export const GuildChannelView = observer(({channelId, guildId}: GuildChannelView
isVoiceChannel &&
connectedChannelId === channelId &&
(connectedGuildId ?? null) === (channel?.guildId ?? null) &&
(room || (isNativeVoiceEngineSelected() && MediaEngine.connected)),
room,
);
const matureContentGateReason = GuildMatureContentAgree.getGateReason({channelId, guildId});
const matureContentResolved = GuildMatureContentAgree.getResolvedContext({channelId, guildId});
@@ -3,7 +3,6 @@
import Authentication from '@app/features/auth/state/Authentication';
import type {Channel} from '@app/features/channel/models/Channel';
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
import {isNativeVoiceEngineSelected} from '@app/features/voice/engine/native_voice_engine/getVoiceEngine';
import CallInitiator from '@app/features/voice/state/CallInitiator';
import CallState, {type Call} from '@app/features/voice/state/CallState';
@@ -40,9 +39,7 @@ export function useCallHeaderState(channel?: Channel | null): CallHeaderState {
const matchesConnectionContext = Boolean(
channelId && MediaEngine.channelId === channelId && (MediaEngine.guildId ?? null) === normalizedGuildId,
);
const isDeviceInRoomForChannelCall = Boolean(
matchesConnectionContext && (MediaEngine.room || (isNativeVoiceEngineSelected() && MediaEngine.connected)),
);
const isDeviceInRoomForChannelCall = Boolean(matchesConnectionContext && MediaEngine.room);
const isDeviceConnectingToChannelCall =
matchesConnectionContext && (MediaEngine.connecting || (MediaEngine.connected && !isDeviceInRoomForChannelCall));
const controlsVariant: CallHeaderControlsVariant = !callExistsAndOngoing
@@ -113,6 +113,10 @@
border-radius: 50%;
}
.embedIconPending {
opacity: 0;
}
.embedTitle {
font-size: 1em;
font-weight: 600;
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
BLUESKY_EMBED_MEDIA_FALLBACK_OUTER_WIDTH,
BLUESKY_EMBED_MIN_CONTENT_WIDTH,
calculateBlueskyMediaContainerWidth,
calculateBlueskyOuterMaxWidth,
EMBED_TEXT_OUTER_WIDTH,
formatResponsiveEmbedWidth,
} from '@app/features/channel/components/embeds/EmbedRenderUtils';
import {describe, expect, it} from 'vitest';
describe('formatResponsiveEmbedWidth', () => {
it('caps in rem so the width tracks the root font size at any zoom', () => {
expect(formatResponsiveEmbedWidth(EMBED_TEXT_OUTER_WIDTH)).toBe('min(100%, 32.25rem)');
expect(formatResponsiveEmbedWidth(BLUESKY_EMBED_MEDIA_FALLBACK_OUTER_WIDTH)).toBe('min(100%, 27rem)');
});
it('rounds to whole pixels before converting', () => {
expect(formatResponsiveEmbedWidth(400.4)).toBe('min(100%, 25rem)');
});
it('clamps non-positive and non-finite widths', () => {
expect(formatResponsiveEmbedWidth(0)).toBe('min(100%, 0rem)');
expect(formatResponsiveEmbedWidth(Number.NaN)).toBe('min(100%, 0rem)');
});
});
describe('calculateBlueskyOuterMaxWidth', () => {
it('falls back to the shared text width when there is no media', () => {
expect(calculateBlueskyOuterMaxWidth({hasMedia: false, chromeWidth: 29})).toBe(EMBED_TEXT_OUTER_WIDTH);
});
it('adds chrome around the media container width', () => {
expect(calculateBlueskyOuterMaxWidth({mediaWidth: 480, hasMedia: true, chromeWidth: 29})).toBe(509);
});
it('never goes below the minimum content width', () => {
expect(calculateBlueskyMediaContainerWidth(120)).toBe(BLUESKY_EMBED_MIN_CONTENT_WIDTH);
});
});
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {MessageEmbedTypes} from '@fluxer/constants/src/ChannelConstants';
import type {MessageEmbed} from '@fluxer/schema/src/domains/message/EmbedSchemas';
@@ -28,7 +29,7 @@ export function isMediaOnlyEmbed(embed: MediaOnlyEmbedFields): boolean {
export const BLUESKY_EMBED_MIN_CONTENT_WIDTH = 320;
export const BLUESKY_EMBED_MEDIA_FALLBACK_OUTER_WIDTH = 432;
export const BLUESKY_EMBED_TEXT_OUTER_WIDTH = 516;
export const EMBED_TEXT_OUTER_WIDTH = 516;
const normalizePositiveWidth = (width: number): number => {
if (!Number.isFinite(width) || width <= 0) {
return 0;
@@ -55,9 +56,9 @@ export function calculateBlueskyOuterMaxWidth({
if (mediaContainerWidth !== undefined) {
return mediaContainerWidth + normalizePositiveWidth(chromeWidth);
}
return hasMedia ? BLUESKY_EMBED_MEDIA_FALLBACK_OUTER_WIDTH : BLUESKY_EMBED_TEXT_OUTER_WIDTH;
return hasMedia ? BLUESKY_EMBED_MEDIA_FALLBACK_OUTER_WIDTH : EMBED_TEXT_OUTER_WIDTH;
}
export function formatResponsiveEmbedWidth(width: number): string {
return `min(100%, ${normalizePositiveWidth(width)}px)`;
return `min(100%, ${remFromPx(normalizePositiveWidth(width))})`;
}
@@ -3,6 +3,7 @@
import {TextualAttachmentPreview} from '@app/features/channel/components/embeds/attachments/TextualAttachmentPreview';
import {splitFilename} from '@app/features/channel/components/embeds/EmbedUtils';
import {canDeleteAttachmentUtil} from '@app/features/channel/components/MessageActionUtils';
import {ATTACHMENT_CARD_WIDTH} from '@app/features/channel/components/MessageAttachmentUtils';
import {useMaybeMessageViewContext} from '@app/features/channel/components/MessageViewContext';
import {DELETE_ATTACHMENT_DESCRIPTOR, DOWNLOAD_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {useDeleteAttachment} from '@app/features/messaging/hooks/useDeleteAttachment';
@@ -181,8 +182,8 @@ export const AttachmentFile = observer(({attachment, message, isPreview}: Attach
containerStyles = {
display: 'grid',
width: '100%',
maxWidth: '400px',
minWidth: 'min(400px, 100%)',
maxWidth: remFromPx(ATTACHMENT_CARD_WIDTH),
minWidth: `min(${remFromPx(ATTACHMENT_CARD_WIDTH)}, 100%)`,
};
}
const handleDownload = async (e: React.MouseEvent) => {
@@ -5,6 +5,7 @@ import styles from '@app/features/channel/components/embeds/ChannelEmbed.module.
import {EmbedLink} from '@app/features/channel/components/embeds/channel_embed/EmbedLink';
import {SafeMarkdown} from '@app/features/messaging/components/markdown';
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
import {useCachedImageLoaded} from '@app/features/messaging/hooks/useCachedImageLoaded';
import {buildMediaProxyURL} from '@app/features/messaging/utils/MediaProxyUtils';
import * as DateUtils from '@app/features/user/utils/DateFormatting';
import type {EmbedAuthor, EmbedField, EmbedFooter} from '@fluxer/schema/src/domains/message/EmbedSchemas';
@@ -32,13 +33,14 @@ export const EmbedAuthorComponent: FC<{author?: EmbedAuthor}> = observer(({autho
const iconSrc = author?.proxy_icon_url
? buildMediaProxyURL(author.proxy_icon_url, animatedMediaPlaybackAllowed ? {} : {format: 'webp', animated: false})
: undefined;
const iconLoaded = useCachedImageLoaded(iconSrc);
if (!author) return null;
return (
<div className={styles.embedAuthor} data-flx="channel.embeds.embed.embed-author-component.embed-author">
{iconSrc && (
<img
alt=""
className={styles.embedAuthorIcon}
className={clsx(styles.embedAuthorIcon, !iconLoaded && styles.embedIconPending)}
src={iconSrc}
width={24}
height={24}
@@ -181,6 +183,7 @@ export const EmbedFooterComponent: FC<{
const iconSrc = footer?.proxy_icon_url
? buildMediaProxyURL(footer.proxy_icon_url, animatedMediaPlaybackAllowed ? {} : {format: 'webp', animated: false})
: undefined;
const iconLoaded = useCachedImageLoaded(iconSrc);
if (!(footer || formattedTimestamp)) return null;
return (
<div
@@ -190,7 +193,7 @@ export const EmbedFooterComponent: FC<{
{iconSrc && (
<img
alt=""
className={styles.embedFooterIcon}
className={clsx(styles.embedFooterIcon, !iconLoaded && styles.embedIconPending)}
src={iconSrc}
width={20}
height={20}
@@ -23,6 +23,10 @@ import {
EmbedProvider,
EmbedTitle,
} from '@app/features/channel/components/embeds/channel_embed/EmbedParts';
import {
EMBED_TEXT_OUTER_WIDTH,
formatResponsiveEmbedWidth,
} from '@app/features/channel/components/embeds/EmbedRenderUtils';
import markupStyles from '@app/features/theme/styles/Markup.module.css';
import {MessageEmbedTypes} from '@fluxer/constants/src/ChannelConstants';
import type {EmbedMedia} from '@fluxer/schema/src/domains/message/EmbedSchemas';
@@ -58,7 +62,7 @@ export const RichEmbed: FC<EmbedProps> = observer(
data-search-highlight-scope="message"
style={{
borderLeft: `4px solid ${getBorderColor(embed.color)}`,
maxWidth: useNarrowWidth ? `min(100%, ${EMBED_MEDIA_MAX_WIDTH}px)` : 'min(100%, 516px)',
maxWidth: formatResponsiveEmbedWidth(useNarrowWidth ? EMBED_MEDIA_MAX_WIDTH : EMBED_TEXT_OUTER_WIDTH),
}}
data-flx="channel.embeds.embed.rich-embed.embed"
>
@@ -4,6 +4,7 @@ import {deriveDefaultNameFromMessage} from '@app/features/channel/components/emb
import styles from '@app/features/channel/components/embeds/media/EmbedAudio.module.css';
import {getMediaButtonVisibility} from '@app/features/channel/components/embeds/media/MediaButtonUtils';
import type {BaseMediaProps} from '@app/features/channel/components/embeds/media/MediaTypes';
import {ATTACHMENT_CARD_WIDTH} from '@app/features/channel/components/MessageAttachmentUtils';
import {useMaybeMessageViewContext} from '@app/features/channel/components/MessageViewContext';
import {DELETE_ATTACHMENT_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {useDeleteAttachment} from '@app/features/messaging/hooks/useDeleteAttachment';
@@ -130,8 +131,8 @@ const EmbedAudio: FC<EmbedAudioProps> = observer(
: {
display: 'grid',
width: '100%',
maxWidth: 'min(100%, 400px)',
minWidth: 'min(400px, 100%)',
maxWidth: `min(100%, ${remFromPx(ATTACHMENT_CARD_WIDTH)})`,
minWidth: `min(${remFromPx(ATTACHMENT_CARD_WIDTH)}, 100%)`,
};
const {showDeleteButton, showDownloadButton} = getMediaButtonVisibility(
canFavorite,
@@ -233,7 +233,7 @@ export const EmbedYouTube: FC<EmbedYouTubeProps> = observer(({embed, width = YOU
className={styles.videoContainer}
style={{
...containerStyle,
width: `${dimensions.width}px`,
width: remFromPx(dimensions.width),
aspectRatio,
maxWidth: '100%',
}}
@@ -1,260 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Accessibility from '@app/features/accessibility/state/Accessibility';
import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter';
import {TextareaButton} from '@app/features/channel/components/textarea/TextareaButton';
import styles from '@app/features/channel/components/textarea/TextareaButtons.module.css';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {prepareVoiceMessageWav} from '@app/features/voice/utils/VoiceMessageRecordingUtils';
import {sendVoiceMessage} from '@app/features/voice/utils/VoiceMessageSendUtils';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {MicrophoneIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {motion, type Variants} from 'framer-motion';
import type React from 'react';
import {useCallback, useMemo, useRef, useState} from 'react';
const RECORDING_FAILED_PLEASE_TRY_AGAIN_DESCRIPTOR = msg({
message: 'Recording failed. Try again.',
comment: 'Error toast shown when the hold-to-record voice message recording fails.',
});
const VOICE_MESSAGE_DESCRIPTOR = msg({
message: 'Voice message',
comment: 'Filename label used for voice messages sent via the hold-to-record button.',
});
const UNABLE_TO_SEND_VOICE_MESSAGE_PLEASE_TRY_AGAIN_DESCRIPTOR = msg({
message: 'Unable to send voice message. Try again.',
comment: 'Error toast shown when sending a hold-to-record voice message fails.',
});
const VOICE_RECORDING_IS_NOT_SUPPORTED_IN_THIS_BROWSER_DESCRIPTOR = msg({
message: 'Voice recording is not supported in this browser.',
comment: 'Inline error when MediaRecorder is unsupported for the hold-to-record voice message flow.',
});
const UNABLE_TO_START_RECORDING_PLEASE_ALLOW_MICROPHONE_ACCESS_DESCRIPTOR = msg({
message: 'Unable to start recording. Allow microphone access.',
comment: 'Inline error when microphone permission is denied for the hold-to-record voice message flow.',
});
const RELEASE_TO_SEND_DESCRIPTOR = msg({
message: 'Release to send',
comment: 'Tooltip on the hold-to-record button while the user is actively holding to record.',
});
const HOLD_TO_SEND_VOICE_MESSAGE_DESCRIPTOR = msg({
message: 'Hold to send voice message',
comment: 'Tooltip on the hold-to-record button in its idle state.',
});
const logger = new Logger('HoldToRecordButton');
const recordingVariants: Variants = {
idle: {
scale: 1,
boxShadow: '0 0 0 0px rgba(252, 95, 105, 0)',
},
recording: {
scale: [1, 1.05, 1],
boxShadow: [
'0 0 0 0px rgba(252, 95, 105, 0.35)',
'0 0 0 8px rgba(252, 95, 105, 0.15)',
'0 0 0 0px rgba(252, 95, 105, 0.35)',
],
transition: {
duration: 1.2,
repeat: Infinity,
ease: 'easeInOut',
},
},
};
interface HoldToRecordButtonProps {
channelId: string;
disabled?: boolean;
onFallback?: () => void;
}
const HoldToRecordButton: React.FC<HoldToRecordButtonProps> = ({channelId, disabled, onFallback}) => {
const {i18n} = useLingui();
const [status, setStatus] = useState<'idle' | 'recording' | 'sending'>('idle');
const [error, setError] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Array<Blob>>([]);
const pointerIdRef = useRef<number | null>(null);
const maxTimerRef = useRef<number | null>(null);
const maxRecordingSeconds = useMemo(
() => LimitResolver.resolve({key: 'max_voice_message_duration', fallback: 1200}),
[],
);
const clearMaxTimer = useCallback(() => {
if (maxTimerRef.current) {
clearTimeout(maxTimerRef.current);
maxTimerRef.current = null;
}
}, []);
const releaseStream = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
}, []);
const cleanupRecording = useCallback(() => {
clearMaxTimer();
releaseStream();
mediaRecorderRef.current = null;
}, [clearMaxTimer, releaseStream]);
const handleRecordingStop = useCallback(
async (blob: Blob) => {
if (blob.size === 0) {
setStatus('idle');
setError(i18n._(RECORDING_FAILED_PLEASE_TRY_AGAIN_DESCRIPTOR));
return;
}
setStatus('sending');
try {
const {file, duration, waveform} = await prepareVoiceMessageWav(blob, `voice-message-${Date.now()}.wav`);
await sendVoiceMessage({
channelId,
file,
waveform,
duration,
title: i18n._(VOICE_MESSAGE_DESCRIPTOR),
});
setStatus('idle');
setError(null);
} catch (err) {
logger.error('Failed to send voice message', err);
setError(i18n._(UNABLE_TO_SEND_VOICE_MESSAGE_PLEASE_TRY_AGAIN_DESCRIPTOR));
setStatus('idle');
}
},
[channelId, i18n],
);
const handleStopEvent = useCallback(() => {
const recorder = mediaRecorderRef.current;
if (!recorder) {
setStatus('idle');
return;
}
const recordedChunks = chunksRef.current;
const blob = new Blob(recordedChunks, {type: recorder.mimeType || 'audio/ogg'});
chunksRef.current = [];
cleanupRecording();
if (recordedChunks.length === 0) {
setError(i18n._(RECORDING_FAILED_PLEASE_TRY_AGAIN_DESCRIPTOR));
setStatus('idle');
return;
}
void handleRecordingStop(blob);
}, [cleanupRecording, handleRecordingStop, i18n]);
const handleDataAvailable = useCallback((event: BlobEvent) => {
if (event.data && event.data.size > 0) {
chunksRef.current = [...chunksRef.current, event.data];
}
}, []);
const stopRecording = useCallback(() => {
const recorder = mediaRecorderRef.current;
if (recorder && recorder.state !== 'inactive') {
recorder.stop();
}
}, []);
const startRecording = useCallback(async () => {
if (status === 'recording' || status === 'sending') return;
if (typeof navigator.mediaDevices === 'undefined' || typeof MediaRecorder === 'undefined') {
setError(i18n._(VOICE_RECORDING_IS_NOT_SUPPORTED_IN_THIS_BROWSER_DESCRIPTOR));
onFallback?.();
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({audio: true});
const preferredType = MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')
? 'audio/ogg;codecs=opus'
: 'audio/webm;codecs=opus';
const recorder = new MediaRecorder(stream, {mimeType: preferredType});
chunksRef.current = [];
recorder.addEventListener('dataavailable', handleDataAvailable);
recorder.addEventListener('stop', handleStopEvent, {once: true});
recorder.start();
mediaRecorderRef.current = recorder;
streamRef.current = stream;
setStatus('recording');
setError(null);
clearMaxTimer();
maxTimerRef.current = window.setTimeout(() => {
stopRecording();
}, maxRecordingSeconds * 1000);
} catch (err) {
logger.error('Failed to start voice recording', err);
setError(i18n._(UNABLE_TO_START_RECORDING_PLEASE_ALLOW_MICROPHONE_ACCESS_DESCRIPTOR));
}
}, [clearMaxTimer, handleDataAvailable, handleStopEvent, maxRecordingSeconds, status, i18n]);
const handlePointerDown = useCallback(
(event: React.PointerEvent<HTMLButtonElement>) => {
if (disabled || status === 'sending') return;
event.preventDefault();
pointerIdRef.current = event.pointerId;
event.currentTarget.setPointerCapture(event.pointerId);
void startRecording();
},
[disabled, startRecording, status],
);
const handlePointerUp = useCallback(
(event: React.PointerEvent<HTMLButtonElement>) => {
if (pointerIdRef.current !== event.pointerId) return;
event.preventDefault();
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {}
pointerIdRef.current = null;
stopRecording();
},
[stopRecording],
);
const handlePointerCancel = useCallback(
(event: React.PointerEvent<HTMLButtonElement>) => {
if (pointerIdRef.current !== event.pointerId) return;
event.preventDefault();
pointerIdRef.current = null;
stopRecording();
},
[stopRecording],
);
const indicatorText = useMemo(() => {
if (error) return error;
if (status === 'recording') return i18n._(RELEASE_TO_SEND_DESCRIPTOR);
return i18n._(HOLD_TO_SEND_VOICE_MESSAGE_DESCRIPTOR);
}, [error, status, i18n.locale]);
return (
<motion.div
className={styles.holdButtonWrapper}
variants={recordingVariants}
animate={status === 'recording' ? 'recording' : 'idle'}
data-flx="channel.textarea.hold-to-record-button.hold-button-wrapper"
>
<TextareaButton
icon={MicrophoneIcon}
label={i18n._(VOICE_MESSAGE_DESCRIPTOR)}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
onPointerLeave={handlePointerCancel}
disabled={disabled || status === 'sending'}
className={clsx(styles.holdButton, status === 'recording' && styles.holdButtonRecording)}
data-flx="channel.textarea.hold-to-record-button.hold-button.pointer-down"
/>
<motion.span
key={indicatorText}
className={clsx(
styles.holdIndicator,
status === 'recording' && styles.holdIndicatorRecording,
error && styles.holdIndicatorError,
)}
initial={Accessibility.useReducedMotion ? {opacity: 1, y: 0} : {opacity: 0, y: 5}}
animate={{opacity: 1, y: 0}}
transition={{duration: Accessibility.useReducedMotion ? 0 : 0.15}}
data-flx="channel.textarea.hold-to-record-button.hold-indicator"
>
{indicatorText}
</motion.span>
</motion.div>
);
};
export default HoldToRecordButton;
@@ -1,158 +0,0 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.mobileTextareaWrapper {
display: flex;
align-items: flex-end;
gap: 0.375rem;
padding: 0.5rem;
width: 100%;
box-sizing: border-box;
}
.mobilePlusButtonContainer {
display: flex;
align-items: flex-end;
flex-shrink: 0;
height: 2.25rem;
}
.mobilePlusButton {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
background-color: var(--background-tertiary);
color: var(--text-primary);
border: none;
cursor: pointer;
flex-shrink: 0;
padding: 0;
}
.mobilePlusButtonIcon {
width: 1.25rem;
height: 1.25rem;
}
.mobileContentWrapper {
flex: 1;
min-width: 0;
display: flex;
align-items: flex-end;
gap: 0.375rem;
}
.mobileInputContainer {
flex: 1;
min-width: 0;
display: flex;
align-items: flex-end;
background-color: var(--background-tertiary);
border-radius: 1.125rem;
padding: 0;
position: relative;
min-height: 2.25rem;
}
.mobileInputContent {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding: 0;
font-size: 0.9375rem;
}
.mobileEmojiButtonContainer {
position: absolute;
right: 0.25rem;
bottom: 0.125rem;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.mobileEmojiButton {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background-color: transparent;
color: var(--text-primary-muted);
border: none;
cursor: pointer;
flex-shrink: 0;
padding: 0;
}
.mobileEmojiButtonIcon {
width: 1.375rem;
height: 1.375rem;
}
.mobileRightButtonContainer {
flex-shrink: 0;
display: flex;
align-items: flex-end;
position: relative;
width: 2.25rem;
height: 2.25rem;
}
.mobileRightButtonSlot {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.mobileSendButton {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
background-color: var(--brand-primary);
color: white;
border: none;
cursor: pointer;
flex-shrink: 0;
}
.mobileSendButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mobileVoiceButton {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
background-color: var(--background-tertiary);
color: var(--text-primary);
border: none;
cursor: pointer;
flex-shrink: 0;
touch-action: none;
}
.mobileVoiceButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mobileRightButtonIcon {
width: 1.25rem;
height: 1.25rem;
}
@@ -1,297 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Accessibility from '@app/features/accessibility/state/Accessibility';
import type {AutocompleteOption} from '@app/features/channel/components/Autocomplete';
import wrapperStyles from '@app/features/channel/components/textarea/InputWrapper.module.css';
import {transitionMobileTextareaButtonState} from '@app/features/channel/components/textarea/MobileTextareaButtonStateMachine';
import styles from '@app/features/channel/components/textarea/MobileTextareaLayout.module.css';
import textareaStyles from '@app/features/channel/components/textarea/TextareaInput.module.css';
import {TextareaInputField} from '@app/features/channel/components/textarea/TextareaInputField';
import VoiceMessageRecorder from '@app/features/channel/components/VoiceMessageRecorder';
import {EMOJIS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import type {TextareaTextChangeHint} from '@app/features/messaging/utils/TextareaSegmentManager';
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
import {getReducedMotionProps, type MotionAnimation} from '@app/features/ui/utils/ReducedMotionAnimation';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {ArrowUpIcon, PlusIcon, SmileyIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {AnimatePresence, motion} from 'framer-motion';
import type React from 'react';
import {type Dispatch, type SetStateAction, useMemo, useRef} from 'react';
const OPEN_MENU_DESCRIPTOR = msg({
message: 'Open menu',
comment: 'Button or menu action label in the channel and chat mobile textarea layout. Keep it concise.',
});
const SEND_MESSAGE_DESCRIPTOR = msg({
message: 'Send message',
comment: 'Button or menu action label in the channel and chat mobile textarea layout. Keep it concise.',
});
const MOBILE_BUTTON_SWAP_MOTION: MotionAnimation = {
initial: {opacity: 0, scale: 0.86, y: 3},
animate: {opacity: 1, scale: 1, y: 0},
exit: {opacity: 0, scale: 0.9, y: -2},
transition: {
opacity: {duration: 0.08},
y: {duration: 0.08, ease: 'easeOut'},
scale: {type: 'spring', stiffness: 560, damping: 28, mass: 0.55},
},
};
interface MobileTextareaLayoutProps {
disabled: boolean;
canAttachFiles: boolean;
value: string;
placeholderText: string;
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
scrollerRef: React.RefObject<ScrollerHandle | null>;
isFocused: boolean;
isAutocompleteAttached: boolean;
autocompleteListId?: string;
autocompleteOptions: Array<AutocompleteOption>;
selectedIndex: number;
channelId: string;
isSlowmodeActive: boolean;
isOverCharacterLimit: boolean;
isEditingMessage: boolean;
hasContent: boolean;
hasAttachments: boolean;
hasPendingSticker?: boolean;
isEditingScheduledMessage: boolean;
onFocus: () => void;
onBlur: () => void;
onChange: (value: string, inputType?: string, hint?: TextareaTextChangeHint) => void;
onHeightChange: (height: number) => void;
onCursorMove: () => void;
onArrowUp: (event: React.KeyboardEvent) => void;
onSubmit: () => void;
onAutocompleteSelect: (option: AutocompleteOption) => void;
setSelectedIndex: Dispatch<SetStateAction<number>>;
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onPlusClick: () => void;
onEmojiClick: () => void;
}
export function MobileTextareaLayout({
disabled,
canAttachFiles,
value,
placeholderText,
textareaRef,
scrollerRef,
isFocused,
isAutocompleteAttached,
autocompleteListId,
autocompleteOptions,
selectedIndex,
channelId,
isSlowmodeActive,
isOverCharacterLimit,
isEditingMessage,
hasContent,
hasAttachments,
hasPendingSticker,
isEditingScheduledMessage,
onFocus,
onBlur,
onChange,
onHeightChange,
onCursorMove,
onArrowUp,
onSubmit,
onAutocompleteSelect,
setSelectedIndex,
onKeyDown,
onPlusClick,
onEmojiClick,
}: MobileTextareaLayoutProps) {
const {i18n} = useLingui();
const buttonSwapMotion = getReducedMotionProps(MOBILE_BUTTON_SWAP_MOTION, Accessibility.useReducedMotion);
const rightButtonState = useMemo(
() =>
transitionMobileTextareaButtonState({
disabled,
canRecordVoice: canAttachFiles,
value,
isSlowmodeActive,
isOverCharacterLimit,
isEditingMessage,
hasContent,
hasAttachments,
hasPendingSticker: Boolean(hasPendingSticker),
isEditingScheduledMessage,
}),
[
canAttachFiles,
disabled,
hasAttachments,
hasContent,
hasPendingSticker,
isEditingMessage,
isEditingScheduledMessage,
isOverCharacterLimit,
isSlowmodeActive,
value,
],
);
const sendButtonDisabled = rightButtonState.sendButton.disabled;
const sendLockRef = useRef(false);
const handleSendClick = () => {
if (sendLockRef.current) {
return;
}
sendLockRef.current = true;
setTimeout(() => {
sendLockRef.current = false;
}, 500);
onSubmit();
};
const shouldShowVoiceButton = rightButtonState.visibleButton === 'voice';
const voiceButtonDisabled = rightButtonState.voiceButton.disabled;
const voiceTooltipAnchorRef = useRef<HTMLDivElement | null>(null);
return (
<div
className={clsx(styles.mobileTextareaWrapper, disabled && wrapperStyles.disabled)}
data-flx="channel.textarea.mobile-textarea-layout.mobile-textarea-wrapper"
>
{!disabled && canAttachFiles && (
<div
className={styles.mobilePlusButtonContainer}
data-flx="channel.textarea.mobile-textarea-layout.mobile-plus-button-container"
>
<button
type="button"
className={styles.mobilePlusButton}
onClick={onPlusClick}
aria-label={i18n._(OPEN_MENU_DESCRIPTOR)}
data-flx="channel.textarea.mobile-textarea-layout.mobile-plus-button.plus-click"
>
<PlusIcon
className={styles.mobilePlusButtonIcon}
weight="bold"
data-flx="channel.textarea.mobile-textarea-layout.mobile-plus-button-icon"
/>
</button>
</div>
)}
<div
className={styles.mobileContentWrapper}
data-flx="channel.textarea.mobile-textarea-layout.mobile-content-wrapper"
>
<div
className={styles.mobileInputContainer}
ref={voiceTooltipAnchorRef}
data-flx="channel.textarea.mobile-textarea-layout.mobile-input-container"
>
<div
className={styles.mobileInputContent}
data-flx="channel.textarea.mobile-textarea-layout.mobile-input-content"
>
<Scroller
ref={scrollerRef}
fade={true}
className={textareaStyles.scroller}
key="mobile-textarea-scroller"
data-flx="channel.textarea.mobile-textarea-layout.scroller"
>
<div className={textareaStyles.flexColumn} data-flx="channel.textarea.mobile-textarea-layout.div">
<TextareaInputField
channelId={channelId}
disabled={disabled}
isMobile={true}
value={value}
placeholder={placeholderText}
textareaRef={textareaRef}
isFocused={isFocused}
isAutocompleteAttached={isAutocompleteAttached}
autocompleteListId={autocompleteListId}
autocompleteOptions={autocompleteOptions}
selectedIndex={selectedIndex}
className={textareaStyles.textareaMobile}
onFocus={onFocus}
onBlur={onBlur}
onChange={onChange}
onHeightChange={onHeightChange}
onCursorMove={onCursorMove}
onArrowUp={onArrowUp}
onEnter={onSubmit}
onAutocompleteSelect={onAutocompleteSelect}
setSelectedIndex={setSelectedIndex}
onKeyDown={onKeyDown}
data-flx="channel.textarea.mobile-textarea-layout.textarea-input-field.change"
/>
</div>
</Scroller>
</div>
{!disabled && (
<div
className={styles.mobileEmojiButtonContainer}
data-flx="channel.textarea.mobile-textarea-layout.mobile-emoji-button-container"
>
<button
type="button"
className={styles.mobileEmojiButton}
onClick={onEmojiClick}
aria-label={i18n._(EMOJIS_DESCRIPTOR)}
data-flx="channel.textarea.mobile-textarea-layout.mobile-emoji-button.emoji-click"
>
<SmileyIcon
className={styles.mobileEmojiButtonIcon}
weight="fill"
data-flx="channel.textarea.mobile-textarea-layout.mobile-emoji-button-icon"
/>
</button>
</div>
)}
</div>
<div
className={styles.mobileRightButtonContainer}
data-flx="channel.textarea.mobile-textarea-layout.mobile-right-button-container"
>
<AnimatePresence initial={false} data-flx="channel.textarea.mobile-textarea-layout.animate-presence">
{shouldShowVoiceButton ? (
<motion.div
key="voice-button"
className={styles.mobileRightButtonSlot}
data-flx="channel.textarea.mobile-textarea-layout.div--2"
{...buttonSwapMotion}
>
<VoiceMessageRecorder
channelId={channelId}
disabled={voiceButtonDisabled}
tooltipAnchorRef={voiceTooltipAnchorRef}
data-flx="channel.textarea.mobile-textarea-layout.voice-message-recorder"
/>
</motion.div>
) : (
<motion.div
key="send-button"
className={styles.mobileRightButtonSlot}
data-flx="channel.textarea.mobile-textarea-layout.mobile-send-button.send-click"
{...buttonSwapMotion}
>
<button
type="button"
className={styles.mobileSendButton}
onClick={handleSendClick}
aria-label={i18n._(SEND_MESSAGE_DESCRIPTOR)}
disabled={sendButtonDisabled}
data-flx="channel.textarea.mobile-textarea-layout.mobile-send-button.button"
>
<ArrowUpIcon
className={styles.mobileRightButtonIcon}
weight="bold"
data-flx="channel.textarea.mobile-textarea-layout.mobile-right-button-icon"
/>
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
);
}
MobileTextareaLayout.displayName = 'MobileTextareaLayout';
@@ -14,33 +14,17 @@
align-items: stretch;
}
.holdButtonWrapper {
.mobileRightButtonContainer {
position: relative;
flex-shrink: 0;
width: var(--textarea-button-height);
height: var(--textarea-button-height);
}
.mobileRightButtonSlot {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
touch-action: none;
}
.holdButton {
touch-action: none;
}
.holdButtonRecording {
box-shadow: 0 0 0 0.125rem rgba(252, 95, 105, 0.35);
}
.holdIndicator {
font-size: 0.625rem;
text-transform: uppercase;
color: var(--text-primary-muted);
}
.holdIndicatorRecording {
color: var(--accent-danger);
}
.holdIndicatorError {
color: var(--accent-danger);
font-weight: 600;
justify-content: center;
}
@@ -1,10 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Accessibility from '@app/features/accessibility/state/Accessibility';
import HoldToRecordButton from '@app/features/channel/components/textarea/HoldToRecordButton';
import {transitionMobileTextareaButtonState} from '@app/features/channel/components/textarea/MobileTextareaButtonStateMachine';
import {TextareaButton} from '@app/features/channel/components/textarea/TextareaButton';
import textareaButtonsStyles from '@app/features/channel/components/textarea/TextareaButtons.module.css';
import styles from '@app/features/channel/components/textarea/TextareaInput.module.css';
import VoiceMessageRecorder from '@app/features/channel/components/VoiceMessageRecorder';
import type {ExpressionPickerTabType} from '@app/features/expressions/components/popouts/ExpressionPickerPopout';
import {
EMOJIS_DESCRIPTOR,
@@ -12,29 +13,28 @@ import {
MEDIA_DESCRIPTOR,
STICKERS_DESCRIPTOR,
} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {getReducedMotionProps} from '@app/features/ui/utils/ReducedMotionAnimation';
import {getReducedMotionProps, type MotionAnimation} from '@app/features/ui/utils/ReducedMotionAnimation';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {
GifIcon,
ImageSquareIcon,
MicrophoneIcon,
PaperPlaneRightIcon,
SmileyIcon,
StickerIcon,
} from '@phosphor-icons/react';
import {GifIcon, ImageSquareIcon, PaperPlaneRightIcon, SmileyIcon, StickerIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {AnimatePresence, motion} from 'framer-motion';
import React from 'react';
import React, {useMemo} from 'react';
const VOICE_MESSAGE_DESCRIPTOR = msg({
message: 'Voice message',
comment: 'Short label in the channel and chat textarea buttons. Keep it concise.',
});
const SEND_MESSAGE_DESCRIPTOR = msg({
message: 'Send message',
comment: 'Button or menu action label in the channel and chat textarea buttons. Keep it concise.',
});
const MOBILE_BUTTON_SWAP_MOTION: MotionAnimation = {
initial: {opacity: 0, scale: 0.86, y: 3},
animate: {opacity: 1, scale: 1, y: 0},
exit: {opacity: 0, scale: 0.9, y: -2},
transition: {
opacity: {duration: 0.08},
y: {duration: 0.08, ease: 'easeOut'},
scale: {type: 'spring', stiffness: 560, damping: 28, mass: 0.55},
},
};
interface TextareaButtonsProps {
disabled: boolean;
@@ -44,8 +44,10 @@ interface TextareaButtonsProps {
showStickersButton: boolean;
showEmojiButton: boolean;
showMessageSendButton: boolean;
showVoiceMessageButton?: boolean;
onVoiceMessageClick?: () => void;
canRecordVoice: boolean;
isEditingMessage: boolean;
hasPendingSticker: boolean;
voiceTooltipAnchorRef?: React.RefObject<HTMLElement | null>;
channelId: string;
expressionPickerOpen: boolean;
selectedTab: ExpressionPickerTabType;
@@ -71,8 +73,10 @@ export const TextareaButtons = React.forwardRef<HTMLDivElement, TextareaButtonsP
showStickersButton,
showEmojiButton,
showMessageSendButton,
showVoiceMessageButton,
onVoiceMessageClick,
canRecordVoice,
isEditingMessage,
hasPendingSticker,
voiceTooltipAnchorRef,
channelId,
expressionPickerOpen,
selectedTab,
@@ -90,22 +94,40 @@ export const TextareaButtons = React.forwardRef<HTMLDivElement, TextareaButtonsP
ref,
) => {
const {i18n} = useLingui();
const buttonModel = useMemo(
() =>
transitionMobileTextareaButtonState({
disabled,
canRecordVoice,
value: '',
isSlowmodeActive,
isOverCharacterLimit: isOverLimit,
isEditingMessage,
hasContent,
hasAttachments,
hasPendingSticker,
isEditingScheduledMessage: false,
}),
[
canRecordVoice,
disabled,
hasAttachments,
hasContent,
hasPendingSticker,
isEditingMessage,
isOverLimit,
isSlowmodeActive,
],
);
if (disabled) {
return null;
}
const buttonSwapMotion = getReducedMotionProps(
{
initial: {scale: 0.8, opacity: 0},
animate: {scale: 1, opacity: 1},
exit: {scale: 0.8, opacity: 0},
transition: {duration: 0.15, ease: 'easeOut'},
},
Accessibility.useReducedMotion,
);
const buttonSwapMotion = getReducedMotionProps(MOBILE_BUTTON_SWAP_MOTION, Accessibility.useReducedMotion);
const shouldShowDesktopSendButton = showMessageSendButton;
const baseSendDisabled = isSlowmodeActive || isOverLimit || disableSendButton;
const sendButtonDisabled = baseSendDisabled || (!hasContent && !hasAttachments);
const shouldShowHoldToRecord = isMobile && showVoiceMessageButton && !hasContent && !hasAttachments;
const desktopSendDisabled =
isSlowmodeActive || isOverLimit || (!hasContent && !hasAttachments) || disableSendButton;
const shouldShowVoiceButton = buttonModel.visibleButton === 'voice';
const mobileSendDisabled = buttonModel.sendButton.disabled || Boolean(disableSendButton);
return (
<div
className={clsx(styles.buttonContainerDense, styles.sideButtonPadding)}
@@ -167,48 +189,51 @@ export const TextareaButtons = React.forwardRef<HTMLDivElement, TextareaButtonsP
className={textareaButtonsStyles.invisibleTrigger}
data-flx="channel.textarea.textarea-buttons.div"
/>
{isMobile && showVoiceMessageButton && onVoiceMessageClick && !shouldShowHoldToRecord && (
<TextareaButton
icon={MicrophoneIcon}
label={i18n._(VOICE_MESSAGE_DESCRIPTOR)}
onClick={onVoiceMessageClick}
data-flx="channel.textarea.textarea-buttons.textarea-button.voice-message-click"
/>
)}
{isMobile && (
<AnimatePresence mode="wait" initial={false} data-flx="channel.textarea.textarea-buttons.animate-presence">
{shouldShowHoldToRecord ? (
<motion.div
key="hold-to-record"
data-flx="channel.textarea.textarea-buttons.div--2"
{...buttonSwapMotion}
>
<HoldToRecordButton
channelId={channelId}
disabled={baseSendDisabled}
onFallback={onVoiceMessageClick}
data-flx="channel.textarea.textarea-buttons.hold-to-record-button"
/>
</motion.div>
) : (
<motion.div key="send-button" data-flx="channel.textarea.textarea-buttons.div--3" {...buttonSwapMotion}>
<TextareaButton
disabled={sendButtonDisabled}
icon={PaperPlaneRightIcon}
label={i18n._(SEND_MESSAGE_DESCRIPTOR)}
onClick={onSubmit}
keybindCombo={{key: 'Enter'}}
data-flx="channel.textarea.textarea-buttons.textarea-button.submit"
/>
</motion.div>
)}
</AnimatePresence>
<div
className={textareaButtonsStyles.mobileRightButtonContainer}
data-flx="channel.textarea.textarea-buttons.mobile-right-button-container"
>
<AnimatePresence initial={false} data-flx="channel.textarea.textarea-buttons.animate-presence">
{shouldShowVoiceButton ? (
<motion.div
key="voice-button"
className={textareaButtonsStyles.mobileRightButtonSlot}
data-flx="channel.textarea.textarea-buttons.div--2"
{...buttonSwapMotion}
>
<VoiceMessageRecorder
channelId={channelId}
disabled={buttonModel.voiceButton.disabled}
tooltipAnchorRef={voiceTooltipAnchorRef}
data-flx="channel.textarea.textarea-buttons.voice-message-recorder"
/>
</motion.div>
) : (
<motion.div
key="send-button"
className={textareaButtonsStyles.mobileRightButtonSlot}
data-flx="channel.textarea.textarea-buttons.div--3"
{...buttonSwapMotion}
>
<TextareaButton
disabled={mobileSendDisabled}
icon={PaperPlaneRightIcon}
label={i18n._(SEND_MESSAGE_DESCRIPTOR)}
onClick={onSubmit}
keybindCombo={{key: 'Enter'}}
data-flx="channel.textarea.textarea-buttons.textarea-button.submit"
/>
</motion.div>
)}
</AnimatePresence>
</div>
)}
{!isMobile && shouldShowDesktopSendButton && (
<>
<div className={styles.divider} data-flx="channel.textarea.textarea-buttons.divider" />
<TextareaButton
disabled={isSlowmodeActive || isOverLimit || (!hasContent && !hasAttachments) || disableSendButton}
disabled={desktopSendDisabled}
icon={PaperPlaneRightIcon}
label={i18n._(SEND_MESSAGE_DESCRIPTOR)}
onClick={onSubmit}
@@ -125,7 +125,8 @@ export async function toggleSelfMute(_guildId: string | null = null): Promise<vo
}
const room = MediaEngine.room;
const connectedChannelId = MediaEngine.channelId;
const currentMute = LocalVoiceState.getSelfMute();
const microphoneFailureLatched = MediaEngine.isMicrophoneFailureLatched();
const currentMute = LocalVoiceState.getSelfMute() || microphoneFailureLatched;
const currentDeaf = LocalVoiceState.getSelfDeaf();
const willUndeafen = currentDeaf;
const willUnmute = currentMute;
@@ -139,6 +140,7 @@ export async function toggleSelfMute(_guildId: string | null = null): Promise<vo
logger.info('toggleSelfMute', {
currentMute,
currentDeaf,
microphoneFailureLatched,
willUnmute,
willUndeafen,
willMute,
@@ -158,6 +160,7 @@ export async function toggleSelfMute(_guildId: string | null = null): Promise<vo
handleMicrophonePermissionBlocked();
return;
}
MediaEngine.clearMicrophoneFailureLatch();
if (!MediaPermission.isMicrophoneGranted()) {
logger.info('Microphone permission not granted, requesting permission');
const permissionGranted = room?.localParticipant
@@ -175,6 +178,11 @@ export async function toggleSelfMute(_guildId: string | null = null): Promise<vo
return;
}
}
if (!LocalVoiceState.getSelfMute() && !LocalVoiceState.getSelfDeaf()) {
logger.debug('Unmute intent only had to clear the microphone failure latch');
SoundCommands.playSound(SoundType.Unmute);
return;
}
}
LocalVoiceState.toggleSelfMute();
const newMute = LocalVoiceState.getSelfMute();
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ChannelHeaderIcon} from '@app/features/channel/components/channel_header_components/ChannelHeaderIcon';
import {DiscoveryLanguageDropdown} from '@app/features/discovery/discovery/DiscoveryLanguageDropdown';
import Discovery from '@app/features/discovery/state/Discovery';
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
import {useContextMenuTrigger} from '@app/features/ui/hooks/useContextMenuTrigger';
import {getDiscoveryLanguageLabel} from '@app/features/user/utils/LocaleUtils';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {TranslateIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback} from 'react';
const FILTER_BY_LANGUAGE_DESCRIPTOR = msg({
message: 'Filter by language',
comment: 'Label for the discovery language filter button. Keep it concise.',
});
const FILTERING_BY_LANGUAGE_DESCRIPTOR = msg({
message: 'Filtering by {language}',
comment:
'Label for the discovery language filter button once a language is chosen. Preserve {language}; it is inserted by code.',
});
export const DiscoveryLanguageButton = observer(() => {
const {i18n} = useLingui();
const {isOpen, withTracking} = useContextMenuTrigger();
const activeLanguage = Discovery.language;
const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
ContextMenuCommands.openFromElementBottomRight(
event,
({onClose}) => (
<DiscoveryLanguageDropdown
onClose={onClose}
data-flx="discovery.discovery.discovery-language-button.handle-click.discovery-language-dropdown"
/>
),
withTracking(),
);
},
[withTracking],
);
return (
<ChannelHeaderIcon
icon={TranslateIcon}
iconWeight="bold"
label={
activeLanguage == null
? i18n._(FILTER_BY_LANGUAGE_DESCRIPTOR)
: i18n._(FILTERING_BY_LANGUAGE_DESCRIPTOR, {language: getDiscoveryLanguageLabel(activeLanguage)})
}
isSelected={isOpen || activeLanguage != null}
aria-haspopup="menu"
aria-expanded={isOpen}
onClick={handleClick}
data-flx="discovery.discovery.discovery-language-button.channel-header-icon.click"
/>
);
});
@@ -0,0 +1,59 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Discovery from '@app/features/discovery/state/Discovery';
import {ContextMenuCloseProvider} from '@app/features/ui/action_menu/ContextMenu';
import {MenuGroup} from '@app/features/ui/action_menu/MenuGroup';
import {MenuItemRadio} from '@app/features/ui/action_menu/MenuItemRadio';
import {getSortedDiscoveryLanguages} from '@app/features/user/utils/LocaleUtils';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useMemo} from 'react';
const ALL_LANGUAGES_DESCRIPTOR = msg({
message: 'All languages',
comment: 'Option in the discovery language filter that clears the filter. Keep it concise.',
});
interface Props {
onClose: () => void;
}
export const DiscoveryLanguageDropdown: React.FC<Props> = observer(({onClose}) => {
const {i18n} = useLingui();
const languages = useMemo(() => getSortedDiscoveryLanguages(), [i18n.locale]);
const selectLanguage = useCallback(
(language: string | null) => {
if (Discovery.language !== language) {
void Discovery.search({language, offset: 0});
}
onClose();
},
[onClose],
);
return (
<ContextMenuCloseProvider
value={onClose}
data-flx="discovery.discovery.discovery-language-dropdown.context-menu-close-provider"
>
<MenuGroup data-flx="discovery.discovery.discovery-language-dropdown.menu-group">
<MenuItemRadio
label={i18n._(ALL_LANGUAGES_DESCRIPTOR)}
selected={Discovery.language == null}
onSelect={() => selectLanguage(null)}
data-flx="discovery.discovery.discovery-language-dropdown.menu-item-radio.all"
/>
{languages.map((language) => (
<MenuItemRadio
key={language.code}
label={language.label}
selected={Discovery.language === language.code}
onSelect={() => selectLanguage(language.code)}
data-flx="discovery.discovery.discovery-language-dropdown.menu-item-radio.language"
/>
))}
</MenuGroup>
</ContextMenuCloseProvider>
);
});
@@ -114,6 +114,14 @@
}
}
.searchArea {
display: flex;
align-items: center;
gap: var(--spacing-2);
flex: 0 0 auto;
min-width: 0;
}
.searchForm {
display: flex;
align-items: center;
@@ -4,6 +4,7 @@ import {reportSkeletonDiscoveryCategoryTabs} from '@app/features/app/components/
import {measureSkeletonWidthPx, useSkeletonLayoutReport} from '@app/features/app/hooks/useSkeletonLayoutMemoryCapture';
import {ChannelHeaderIcon} from '@app/features/channel/components/channel_header_components/ChannelHeaderIcon';
import searchBarStyles from '@app/features/channel/components/message_search_bar/MessageSearchBar.module.css';
import {DiscoveryLanguageButton} from '@app/features/discovery/discovery/DiscoveryLanguageButton';
import styles from '@app/features/discovery/discovery/DiscoveryNavbar.module.css';
import {DiscoveryMotionKind, DiscoveryTransition} from '@app/features/discovery/discovery/DiscoveryTransition';
import Discovery from '@app/features/discovery/state/Discovery';
@@ -251,51 +252,54 @@ export const DiscoveryNavbar = observer(function DiscoveryNavbar({
)}
</DiscoveryTransition>
</div>
<form
className={styles.searchForm}
role="search"
aria-label={i18n._(DISCOVERY_SEARCH_DESCRIPTOR)}
onSubmit={handleSubmit}
data-flx="discovery.discovery.discovery-navbar.search-form.submit"
>
<div className={searchBarStyles.inputContainer} data-flx="discovery.discovery.discovery-navbar.div">
<MagnifyingGlassIcon
className={clsx(searchBarStyles.searchIcon, styles.searchIcon)}
weight="bold"
aria-hidden
data-flx="discovery.discovery.discovery-navbar.search-icon"
/>
<input
ref={searchInputRef}
className={styles.searchInput}
type="search"
aria-label={i18n._(SEARCH_COMMUNITIES_DESCRIPTOR)}
aria-controls={resultsRegionId}
placeholder={i18n._(SEARCH_COMMUNITIES_DESCRIPTOR)}
value={draftQuery}
onChange={(event) => setDraftQuery(event.target.value)}
data-flx="discovery.discovery.discovery-navbar.search-input.set-draft-query"
/>
{draftQuery.length > 0 && (
<FocusRing offset={-2} data-flx="discovery.discovery.discovery-navbar.focus-ring">
<button
type="button"
className={searchBarStyles.clearButton}
aria-label={i18n._(CLEAR_SEARCH_DESCRIPTOR)}
onClick={handleClear}
data-flx="discovery.discovery.discovery-navbar.button.clear"
>
<XIcon
weight="bold"
className={searchBarStyles.optionMetaIcon}
aria-hidden
data-flx="discovery.discovery.discovery-navbar.x-icon"
/>
</button>
</FocusRing>
)}
</div>
</form>
<div className={styles.searchArea} data-flx="discovery.discovery.discovery-navbar.search-area">
<form
className={styles.searchForm}
role="search"
aria-label={i18n._(DISCOVERY_SEARCH_DESCRIPTOR)}
onSubmit={handleSubmit}
data-flx="discovery.discovery.discovery-navbar.search-form.submit"
>
<div className={searchBarStyles.inputContainer} data-flx="discovery.discovery.discovery-navbar.div">
<MagnifyingGlassIcon
className={clsx(searchBarStyles.searchIcon, styles.searchIcon)}
weight="bold"
aria-hidden
data-flx="discovery.discovery.discovery-navbar.search-icon"
/>
<input
ref={searchInputRef}
className={styles.searchInput}
type="search"
aria-label={i18n._(SEARCH_COMMUNITIES_DESCRIPTOR)}
aria-controls={resultsRegionId}
placeholder={i18n._(SEARCH_COMMUNITIES_DESCRIPTOR)}
value={draftQuery}
onChange={(event) => setDraftQuery(event.target.value)}
data-flx="discovery.discovery.discovery-navbar.search-input.set-draft-query"
/>
{draftQuery.length > 0 && (
<FocusRing offset={-2} data-flx="discovery.discovery.discovery-navbar.focus-ring">
<button
type="button"
className={searchBarStyles.clearButton}
aria-label={i18n._(CLEAR_SEARCH_DESCRIPTOR)}
onClick={handleClear}
data-flx="discovery.discovery.discovery-navbar.button.clear"
>
<XIcon
weight="bold"
className={searchBarStyles.optionMetaIcon}
aria-hidden
data-flx="discovery.discovery.discovery-navbar.x-icon"
/>
</button>
</FocusRing>
)}
</div>
</form>
<DiscoveryLanguageButton data-flx="discovery.discovery.discovery-navbar.discovery-language-button" />
</div>
</div>
);
});
@@ -183,7 +183,7 @@ function handleReadyInternal(data: ReadyPayload, context: GatewayHandlerContext)
GuildReadState.handleConnectionOpen();
Presence.handleConnectionOpen(data.user, guilds, data.presences);
MediaEngine.handleConnectionOpen(guilds);
Initialization.setReady(data);
Initialization.setReady();
context.setReady();
Messages.handleConnectionOpen((channelId) => ReadStates.getUnreadJumpAnchor(channelId));
}
@@ -30,7 +30,7 @@ export type VerificationFailureReason = ValueOf<typeof VerificationFailureReason
interface VerificationStatus {
canAccess: boolean;
reason?: VerificationFailureReason;
timeRemaining?: number;
verificationEndsAt?: number;
}
class GuildVerification {
@@ -81,10 +81,11 @@ class GuildVerification {
for (const guild of guilds) {
const status = this.computeVerificationStatus(guild);
newVerificationStatus[guild.id] = status;
if (!status.canAccess && status.timeRemaining && status.timeRemaining > 0) {
const delay = status.verificationEndsAt ? status.verificationEndsAt - Date.now() : undefined;
if (!status.canAccess && delay && delay > 0) {
newTimers[guild.id] = setTimeout(() => {
this.recomputeGuild(guild.id);
}, status.timeRemaining);
}, delay);
}
}
this.verificationStatus = Object.freeze(newVerificationStatus);
@@ -106,10 +107,11 @@ class GuildVerification {
}
const newTimers = {...this.timers};
delete newTimers[guildId];
if (!status.canAccess && status.timeRemaining && status.timeRemaining > 0) {
const delay = status.verificationEndsAt ? status.verificationEndsAt - Date.now() : undefined;
if (!status.canAccess && delay && delay > 0) {
newTimers[guildId] = setTimeout(() => {
this.recomputeGuild(guildId);
}, status.timeRemaining);
}, delay);
}
this.verificationStatus = Object.freeze(newVerificationStatus);
this.timers = newTimers;
@@ -124,12 +126,11 @@ class GuildVerification {
const now = Date.now();
if (member?.communicationDisabledUntil) {
const timeoutUntil = member.communicationDisabledUntil;
const timeRemaining = timeoutUntil.getTime() - now;
if (timeRemaining > 0) {
if (timeoutUntil.getTime() > now) {
return {
canAccess: false,
reason: VerificationFailureReason.TIMED_OUT,
timeRemaining,
verificationEndsAt: timeoutUntil.getTime(),
};
}
}
@@ -164,18 +165,16 @@ class GuildVerification {
}
}
if (verificationLevel >= GuildVerificationLevel.MEDIUM) {
const accountAge = Date.now() - user.createdAt.getTime();
if (accountAge < FIVE_MINUTES_MS) {
const timeRemaining = FIVE_MINUTES_MS - accountAge;
return {canAccess: false, reason: VerificationFailureReason.ACCOUNT_TOO_NEW, timeRemaining};
const verificationEndsAt = user.createdAt.getTime() + FIVE_MINUTES_MS;
if (verificationEndsAt > now) {
return {canAccess: false, reason: VerificationFailureReason.ACCOUNT_TOO_NEW, verificationEndsAt};
}
}
if (verificationLevel >= GuildVerificationLevel.HIGH) {
if (member?.joinedAt) {
const membershipDuration = Date.now() - member.joinedAt.getTime();
if (membershipDuration < TEN_MINUTES_MS) {
const timeRemaining = TEN_MINUTES_MS - membershipDuration;
return {canAccess: false, reason: VerificationFailureReason.NOT_MEMBER_LONG_ENOUGH, timeRemaining};
const verificationEndsAt = member.joinedAt.getTime() + TEN_MINUTES_MS;
if (verificationEndsAt > now) {
return {canAccess: false, reason: VerificationFailureReason.NOT_MEMBER_LONG_ENOUGH, verificationEndsAt};
}
}
}
@@ -196,7 +195,8 @@ class GuildVerification {
}
getTimeRemaining(guildId: string): number | null {
return this.verificationStatus[guildId]?.timeRemaining ?? null;
const verificationEndsAt = this.verificationStatus[guildId]?.verificationEndsAt;
return verificationEndsAt ? Math.max(0, verificationEndsAt - Date.now()) : null;
}
}
File diff suppressed because it is too large Load Diff
@@ -1380,6 +1380,9 @@
{
"msgid": "File ordering"
},
{
"msgid": "Filtering by {language}"
},
{
"msgid": "Finish"
},
@@ -1407,6 +1410,9 @@
{
"msgid": "Granted"
},
{
"msgid": "Help translate {PRODUCT_NAME} into your language on <0>{I18N_WEBLATE_DOMAIN}</0>."
},
{
"msgid": "How much of the member's recent message history to delete."
},
@@ -1743,6 +1749,9 @@
{
"msgid": "online"
},
{
"msgid": "{PRODUCT_NAME} needs to restart for the title bar change to take effect."
},
{
"msgid": "{channelHeading}, {mentionCount, plural, one {# mention} other {# mentions}}"
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More