Compare commits

..
Author SHA1 Message Date
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
HampusandGitHub 9b0b703c9d fix(api): identify session clients correctly and attribute handoff sessions (#1727) 2026-08-19 00:41:29 +02:00
HampusandGitHub 5660972c71 perf(app): cut idle renderer CPU and unbounded memory growth (#1724) 2026-08-18 23:13:42 +02:00
HampusandGitHub b4a5eb77a8 docs: fix community health document links broken by the .github move (#1723) 2026-08-18 20:23:40 +02:00
HampusandGitHub 4bbfee4cec fix(app): make click-through the default and move animation pausing to Accessibility (#1722) 2026-08-18 19:40:35 +02:00
HampusandGitHub 9e89539f0a perf(app): stop unbounded WASM heap growth and idle-CPU churn (#1721) 2026-08-18 18:31:28 +02:00
fa71eb8682 chore(marketing): advance pointer 9720a17 → a31164c (#1717)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-18 16:50:27 +02:00
HampusandGitHub 49b7127bc7 chore(static): regenerate marketing screenshots and README showcase (#1718) 2026-08-18 16:50:23 +02:00
HampusandGitHub 9cb8812be0 fix(app-proxy): collapse the discovery cold-start retry condition (#1716) 2026-08-18 16:12:33 +02:00
HampusandGitHub 7d82d188a0 fix(gateway): register the session process with presence on resume (#1715) 2026-08-18 16:06:26 +02:00
HampusandGitHub 5ce5669f17 fix(app): allow the inbox shortcut while the composer is empty (#1714) 2026-08-18 14:31:23 +02:00
441 changed files with 18074 additions and 36604 deletions
+4 -4
View File
@@ -25,7 +25,7 @@ Closes #456
You must understand every line you submit and be able to explain why the change is correct.
The [LLM usage policy](LLM_USAGE_POLICY.md) defines the authorship requirements for contributors who do not have write access.
The [LLM usage policy](https://github.com/fluxerapp/fluxer/blob/main/.github/LLM_USAGE_POLICY.md) defines the authorship requirements for contributors who do not have write access.
Each contribution must contain one coherent change. Do not include unrelated fixes, refactoring or formatting changes.
@@ -80,15 +80,15 @@ Complete every section of the pull request template. Clearly describe:
Use the [bug report form](https://github.com/fluxerapp/fluxer/issues/new?template=bug-report.yaml) to report reproducible defects.
Report security vulnerabilities privately through the channels specified in the [security policy](SECURITY.md). Do not report vulnerabilities in public issues or discussions.
Report security vulnerabilities privately through the channels specified in the [security policy](https://github.com/fluxerapp/fluxer/blob/main/.github/SECURITY.md). Do not report vulnerabilities in public issues or discussions.
Use [discussions](https://github.com/orgs/fluxerapp/discussions) for feature proposals and self-hosting questions.
Submit translations through [Weblate](https://weblate.fluxer.tools), not through pull requests.
All repository activity is governed by the [Code of Conduct](CODE_OF_CONDUCT.md).
All repository activity is governed by the [Code of Conduct](https://github.com/fluxerapp/fluxer/blob/main/.github/CODE_OF_CONDUCT.md).
Fluxer is distributed under the [GNU Affero General Public License, version 3.0 or later](../LICENSE). By adding a DCO sign-off, you certify that you have the right to submit the contribution under that licence.
Fluxer is distributed under the [GNU Affero General Public License, version 3.0 or later](https://github.com/fluxerapp/fluxer/blob/main/LICENSE). By adding a DCO sign-off, you certify that you have the right to submit the contribution under that licence.
## Private marketing project
+2 -2
View File
@@ -8,11 +8,11 @@ External contributions do not grant voting rights, commit access, employment or
## Licence and contributor rights
Source code owned by Fluxer Platform AB in this repository is distributed under the [GNU Affero General Public License, version 3.0 or later](../LICENSE). The licence permits its use, modification and redistribution subject to its terms.
Source code owned by Fluxer Platform AB in this repository is distributed under the [GNU Affero General Public License, version 3.0 or later](https://github.com/fluxerapp/fluxer/blob/main/LICENSE). The licence permits its use, modification and redistribution subject to its terms.
Fluxer Platform AB does not require contributors to sign a contributor licence agreement or assign their copyright. Contributors retain the copyright in their work.
Every commit made by a contributor must include the [Developer Certificate of Origin](https://developercertificate.org) sign-off required by the [contributing guidelines](CONTRIBUTING.md). Pull requests opened by Fluxer repository automation are exempt from this requirement.
Every commit made by a contributor must include the [Developer Certificate of Origin](https://developercertificate.org) sign-off required by the [contributing guidelines](https://github.com/fluxerapp/fluxer/blob/main/.github/CONTRIBUTING.md). Pull requests opened by Fluxer repository automation are exempt from this requirement.
## Name and marks
+1 -1
View File
@@ -129,7 +129,7 @@ Deliberately submitting a fabricated security report MAY result in an immediate
Maintainers are not required to investigate possible LLM use proactively. Writing style alone is not evidence of a violation.
A person MUST NOT publicly accuse or harass a contributor because of suspected LLM use. All discussion, review and enforcement under this policy MUST comply with the [Code of Conduct](CODE_OF_CONDUCT.md).
A person MUST NOT publicly accuse or harass a contributor because of suspected LLM use. All discussion, review and enforcement under this policy MUST comply with the [Code of Conduct](https://github.com/fluxerapp/fluxer/blob/main/.github/CODE_OF_CONDUCT.md).
## 11. Normative References
+1 -1
View File
@@ -31,5 +31,5 @@
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
<p align="center">
<img src="./fluxer_static/marketing/screenshots/desktop-1920w.png" alt="Fluxer app showcase" width="900">
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer app showcase" width="900">
</p>
-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/'],
},
},
});
@@ -41,10 +41,11 @@ import * as AuthUtility from '../../auth/AuthUtility';
import {createPasswordResetToken, createUserID, type UserID} from '../../BrandedTypes';
import type {UserRow} from '../../database/types/UserTypes';
import {Logger} from '../../Logger';
import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons';
import type {IRiskHistoryRepository} from '../../risk/HistoricalOutcomeRepository';
import type {HistoricalOutcomeCode} from '../../risk/RiskHistoryTypes';
import {getIpAddressReverse, getLocationLabelFromIp} from '../../utils/IpUtils';
import {resolveSessionClientInfo} from '../../utils/UserAgentUtils';
import {resolveSessionClientInfo} from '../../utils/SessionClientIdentity';
import {mapUserToAdminResponse} from '../models/UserTypes';
import type {AdminAuditService} from './AdminAuditService';
import type {AdminUserUpdatePropagator} from './AdminUserUpdatePropagator';
@@ -749,7 +750,7 @@ export class AdminUserSecurityService {
approximateLastUsedAt: Date;
clientIp: string;
clientUserAgent: string | null;
clientIsDesktop: boolean | null;
clientOs: string | null;
deletedAt: Date | null;
}> = [
...activeSessions.map((s) => ({
@@ -758,7 +759,7 @@ export class AdminUserSecurityService {
approximateLastUsedAt: s.approximateLastUsedAt,
clientIp: s.clientIp,
clientUserAgent: s.clientUserAgent,
clientIsDesktop: s.clientIsDesktop,
clientOs: s.clientOs ?? null,
deletedAt: null as Date | null,
})),
...tombstones.map((t) => ({
@@ -767,7 +768,7 @@ export class AdminUserSecurityService {
approximateLastUsedAt: t.approximateLastUsedAt,
clientIp: t.clientIp,
clientUserAgent: t.clientUserAgent,
clientIsDesktop: t.clientIsDesktop,
clientOs: t.clientOs ?? null,
deletedAt: t.deletedAt,
})),
];
@@ -776,6 +777,8 @@ export class AdminUserSecurityService {
if (a.deletedAt !== null && b.deletedAt === null) return 1;
return b.createdAt.getTime() - a.createdAt.getTime();
});
const {branding} = await getInstanceConfigRepository().getAppPublicConfig();
const productName = branding.product_name;
const canViewIp = acls.has(AdminACLs.USER_VIEW_IP) || acls.has(AdminACLs.WILDCARD);
if (!canViewIp) {
await auditService.createAuditLog({
@@ -788,9 +791,10 @@ export class AdminUserSecurityService {
});
return {
sessions: entries.map((entry) => {
const {clientOs, clientPlatform} = resolveSessionClientInfo({
const clientInfo = resolveSessionClientInfo({
userAgent: entry.clientUserAgent,
isDesktopClient: entry.clientIsDesktop,
reportedOs: entry.clientOs,
productName,
});
return {
session_id_hash: entry.sessionIdHash.toString('base64url'),
@@ -798,8 +802,8 @@ export class AdminUserSecurityService {
approx_last_used_at: entry.approximateLastUsedAt.toISOString(),
client_ip: '[redacted]',
client_ip_reverse: null,
client_os: clientOs,
client_platform: clientPlatform,
client_os: clientInfo.os,
client_platform: clientInfo.platform,
client_location: null,
deleted_at: entry.deletedAt?.toISOString() ?? null,
};
@@ -836,9 +840,10 @@ export class AdminUserSecurityService {
const clientLocation = locationResult.status === 'fulfilled' ? locationResult.value : null;
const reverseDnsResult = reverseDnsResults[index];
const clientIpReverse = reverseDnsResult?.status === 'fulfilled' ? reverseDnsResult.value : null;
const {clientOs, clientPlatform} = resolveSessionClientInfo({
const clientInfo = resolveSessionClientInfo({
userAgent: entry.clientUserAgent,
isDesktopClient: entry.clientIsDesktop,
reportedOs: entry.clientOs,
productName,
});
return {
session_id_hash: entry.sessionIdHash.toString('base64url'),
@@ -846,8 +851,8 @@ export class AdminUserSecurityService {
approx_last_used_at: entry.approximateLastUsedAt.toISOString(),
client_ip: entry.clientIp,
client_ip_reverse: clientIpReverse,
client_os: clientOs,
client_platform: clientPlatform,
client_os: clientInfo.os,
client_platform: clientInfo.platform,
client_location: clientLocation,
deleted_at: entry.deletedAt?.toISOString() ?? null,
};
+1 -13
View File
@@ -551,18 +551,7 @@ export function AuthController(app: HonoApp) {
'Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking.',
}),
async (ctx) => {
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
const clientPlatform = ctx.req.header('x-fluxer-platform')?.trim().toLowerCase() ?? undefined;
return ctx.json(
await ctx.get('authRequestService').initiateHandoff({
userAgent: ctx.req.header('User-Agent'),
clientIp,
clientPlatform,
}),
);
return ctx.json(await ctx.get('authRequestService').initiateHandoff({request: ctx.req.raw}));
},
);
app.get(
@@ -611,7 +600,6 @@ export function AuthController(app: HonoApp) {
});
await ctx.get('authRequestService').completeHandoff({
data: ctx.req.valid('json'),
request: ctx.req.raw,
clientIp,
authToken: ctx.get('authToken') ?? undefined,
});
+4 -1
View File
@@ -100,7 +100,10 @@ export async function revertEmailChange(
event: 'USER_UPDATE',
data: mapUserToPrivateResponse(updatedUser),
});
const [authToken] = await AuthSession.createAuthSession(ctx, {user: updatedUser, request});
const [authToken] = await AuthSession.createAuthSession(ctx, {
user: updatedUser,
origin: AuthSession.resolveSessionOrigin(ctx, request),
});
await contactChangeLog.recordDiff({
oldUser: user,
newUser: updatedUser,
+21 -29
View File
@@ -105,9 +105,7 @@ export interface IpAuthorizationTicketCache {
userId: string;
email: string;
username: string;
clientIp: string;
userAgent: string;
platform: string | null;
origin: AuthSession.SessionOrigin;
authToken: string;
clientLocation: string;
inviteCode?: string | null;
@@ -115,8 +113,8 @@ export interface IpAuthorizationTicketCache {
createdAt: number;
}
function getTicketCacheKey(ticket: string): string {
return `ip-auth-ticket:${ticket}`;
export function getTicketCacheKey(ticket: string): string {
return `ip-auth-ticket-v2:${ticket}`;
}
function getTokenCacheKey(token: string): string {
@@ -148,7 +146,7 @@ export async function resendIpAuthorization(
payload.email,
payload.username,
payload.authToken,
payload.clientIp,
payload.origin.ip,
payload.clientLocation,
null,
);
@@ -172,7 +170,7 @@ export async function completeIpAuthorization(
user_id: string;
ticket: string;
}> {
const {users, cache, config} = ctx.services;
const {users, cache} = ctx.services;
const tokenMapping = await cache.get<{
ticket: string;
}>(getTokenCacheKey(token));
@@ -193,19 +191,8 @@ export async function completeIpAuthorization(
throw new UnknownUserError();
}
AuthUtility.assertNonBotUser(ctx, user);
await users.createAuthorizedIp(user.id, payload.clientIp);
const headers: Record<string, string> = {
[config.proxy.client_ip_header]: payload.clientIp,
'user-agent': payload.userAgent,
};
if (payload.platform) {
headers['x-fluxer-platform'] = payload.platform;
}
const syntheticRequest = new Request('https://api.fluxer.app/auth/ip-authorization', {
headers,
method: 'POST',
});
const [sessionToken] = await AuthSession.createAuthSession(ctx, {user, request: syntheticRequest});
await users.createAuthorizedIp(user.id, payload.origin.ip);
const [sessionToken] = await AuthSession.createAuthSession(ctx, {user, origin: payload.origin});
await cache.delete(cacheKey);
await cache.delete(getTokenCacheKey(token));
return {token: sessionToken, user_id: user.id.toString(), ticket: tokenMapping.ticket};
@@ -313,15 +300,11 @@ export async function login(
const authToken = createIpAuthorizationToken(await AuthUtility.generateSecureToken(ctx));
const geoipResult = await lookupGeoip(clientIp);
const clientLocation = formatGeoipLocation(geoipResult) ?? UNKNOWN_LOCATION;
const userAgent = request.headers.get('user-agent') || '';
const platform = request.headers.get('x-fluxer-platform');
const cachePayload: IpAuthorizationTicketCache = {
userId: currentUser.id.toString(),
email: currentUser.email!,
username: currentUser.username,
clientIp,
userAgent,
platform: platform ?? null,
origin: AuthSession.resolveSessionOrigin(ctx, request),
authToken,
clientLocation,
inviteCode: data.invite_code ?? null,
@@ -329,7 +312,7 @@ export async function login(
createdAt: Date.now(),
};
const ttlSeconds = seconds('15 minutes');
await cache.set<IpAuthorizationTicketCache>(`ip-auth-ticket:${ticket}`, cachePayload, ttlSeconds);
await cache.set<IpAuthorizationTicketCache>(getTicketCacheKey(ticket), cachePayload, ttlSeconds);
await cache.set<{
ticket: string;
}>(`ip-auth-token:${authToken}`, {ticket}, ttlSeconds);
@@ -364,7 +347,10 @@ export async function login(
Logger.warn({inviteCode: data.invite_code, error}, 'Failed to auto-join invite on login');
}
}
const [token] = await AuthSession.createAuthSession(ctx, {user: currentUser, request});
const [token] = await AuthSession.createAuthSession(ctx, {
user: currentUser,
origin: AuthSession.resolveSessionOrigin(ctx, request),
});
return {
user_id: currentUser.id.toString(),
token,
@@ -418,7 +404,10 @@ export async function loginMfaTotp(
await cache.delete(`mfa-ticket:${ticket}`);
await cache.delete(attemptsKey);
await cache.delete(userAttemptsKey);
const [token] = await AuthSession.createAuthSession(ctx, {user, request});
const [token] = await AuthSession.createAuthSession(ctx, {
user,
origin: AuthSession.resolveSessionOrigin(ctx, request),
});
return {user_id: user.id.toString(), token};
}
@@ -438,7 +427,10 @@ export async function loginMfaWebAuthn(
AuthUtility.assertNonBotUser(ctx, user);
await AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket);
await cache.delete(`mfa-ticket:${ticket}`);
const [token] = await AuthSession.createAuthSession(ctx, {user, request});
const [token] = await AuthSession.createAuthSession(ctx, {
user,
origin: AuthSession.resolveSessionOrigin(ctx, request),
});
return {user_id: user.id.toString(), token};
}
+12 -17
View File
@@ -5,9 +5,10 @@ import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSche
import {uint8ArrayToBase64} from 'uint8array-extras';
import {Config} from '../Config';
import {Logger} from '../Logger';
import {getInstanceConfigRepository} from '../middleware/ServiceSingletons';
import type {AuthSession} from '../models/AuthSession';
import {getLocationLabelFromIp} from '../utils/IpUtils';
import {resolveSessionClientInfo} from '../utils/UserAgentUtils';
import {resolveSessionClientInfo} from '../utils/SessionClientIdentity';
const DEV_FALLBACK_AUTH_SESSION_LOCATION = 'Stockholm, Stockholm County, Sweden';
@@ -40,30 +41,24 @@ export async function mapAuthSessionsToResponse({
const locationResults = await Promise.allSettled(
sortedSessions.map((session) => resolveAuthSessionLocation(session)),
);
const {branding} = await getInstanceConfigRepository().getAppPublicConfig();
return sortedSessions.map((authSession, index): AuthSessionResponse => {
const locationResult = locationResults[index];
const clientLocation = locationResult?.status === 'fulfilled' ? locationResult.value : null;
let clientOs: string;
let clientPlatform: string;
if (authSession.clientUserAgent) {
const parsed = resolveSessionClientInfo({
userAgent: authSession.clientUserAgent,
isDesktopClient: authSession.clientIsDesktop,
});
clientOs = parsed.clientOs;
clientPlatform = parsed.clientPlatform;
} else {
clientOs = authSession.clientOs || 'Unknown';
clientPlatform = authSession.clientPlatform || 'Unknown';
}
const clientInfo = resolveSessionClientInfo({
userAgent: authSession.clientUserAgent,
reportedOs: authSession.clientOs ?? null,
productName: branding.product_name,
});
const idHash = uint8ArrayToBase64(authSession.sessionIdHash, {urlSafe: true});
const isCurrent = currentSessionId ? Buffer.compare(authSession.sessionIdHash, currentSessionId) === 0 : false;
return {
id_hash: idHash,
client_info: {
platform: clientPlatform,
os: clientOs,
browser: undefined,
platform: clientInfo.platform,
os: clientInfo.os,
browser: clientInfo.browser,
device: clientInfo.device,
location: clientLocation
? {
city: clientLocation.split(',').at(0)?.trim() || null,
+4 -1
View File
@@ -275,7 +275,10 @@ export async function resetPassword(
if (hasMfa) {
return await createMfaTicketResponse(ctx, updatedUser);
}
const [token] = await AuthSession.createAuthSession(ctx, {user: updatedUser, request});
const [token] = await AuthSession.createAuthSession(ctx, {
user: updatedUser,
origin: AuthSession.resolveSessionOrigin(ctx, request),
});
return {user_id: updatedUser.id.toString(), token};
}
+4 -1
View File
@@ -444,7 +444,10 @@ export async function register(
);
}
await singleCommunityService.joinStockCommunity(userId, requestCache);
const [token] = await AuthSession.createAuthSession(ctx, {user, request});
const [token] = await AuthSession.createAuthSession(ctx, {
user,
origin: AuthSession.resolveSessionOrigin(ctx, request),
});
if (grantBootstrapAdmin) {
await instanceConfigRepository.markAdminBootstrapped();
}
+24 -23
View File
@@ -33,11 +33,12 @@ import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResp
import type {ApiContext} from '../ApiContext';
import {createUserID, type UserID} from '../BrandedTypes';
import type {RequestCache} from '../middleware/RequestCacheMiddleware';
import {getInstanceConfigRepository} from '../middleware/ServiceSingletons';
import type {User} from '../models/User';
import {mapUserToPartialResponse} from '../user/UserMappers';
import {lookupGeoip} from '../utils/IpUtils';
import {parseJsonRecord} from '../utils/JsonBoundaryUtils';
import {resolveSessionClientInfo} from '../utils/UserAgentUtils';
import {resolveSessionClientInfo} from '../utils/SessionClientIdentity';
import {generateUsernameSuggestions} from '../utils/UsernameSuggestionUtils';
import * as AuthEmail from './AuthEmail';
import * as AuthEmailRevert from './AuthEmailRevert';
@@ -89,7 +90,6 @@ interface AuthLogoutRequest {
interface AuthHandoffCompleteRequest {
data: HandoffCompleteRequest;
request: Request;
clientIp: string;
authToken?: string;
}
@@ -122,9 +122,7 @@ interface AuthLogoutAuthSessionsRequest {
}
interface AuthHandoffInitiateRequest {
userAgent?: string;
clientIp: string;
clientPlatform?: string;
request: Request;
}
interface AuthHandoffInfoRequest {
@@ -263,7 +261,7 @@ export class AuthRequestService {
user: await this.getUserPartial(parsed.user_id),
};
}
const ticketPayload = await cache.get(`ip-auth-ticket:${ticket}`);
const ticketPayload = await cache.get(AuthLogin.getTicketCacheKey(ticket));
if (!ticketPayload) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.INVALID_OR_EXPIRED_AUTHORIZATION_TICKET);
}
@@ -276,7 +274,10 @@ export class AuthRequestService {
async authenticateWebAuthnDiscoverable({data, request}: AuthWebAuthnAuthenticateRequest) {
const user = await AuthMfa.verifyWebAuthnAuthenticationDiscoverable(this.apiContext, data.response, data.challenge);
const [token] = await AuthSession.createAuthSession(this.apiContext, {user, request});
const [token] = await AuthSession.createAuthSession(this.apiContext, {
user,
origin: AuthSession.resolveSessionOrigin(this.apiContext, request),
});
return {token, user_id: user.id.toString(), user: mapUserToPartialResponse(user)};
}
@@ -298,12 +299,9 @@ export class AuthRequestService {
return {suggestions: generateUsernameSuggestions(globalName)};
}
async initiateHandoff({
userAgent,
clientIp,
clientPlatform,
}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
const result = await this.desktopHandoffService.initiateHandoff({userAgent, clientIp, clientPlatform});
async initiateHandoff({request}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
const origin = AuthSession.resolveSessionOrigin(this.apiContext, request);
const result = await this.desktopHandoffService.initiateHandoff({origin});
return {
code: result.code,
expires_at: result.expiresAt.toISOString(),
@@ -312,19 +310,22 @@ export class AuthRequestService {
async getHandoffInfo({code, clientIp}: AuthHandoffInfoRequest): Promise<HandoffInfoResponse> {
const info = await this.desktopHandoffService.getHandoffInfo(code, clientIp);
if (info.status === 'expired' || !info.clientIp) {
if (info.status === 'expired' || !info.origin) {
return {status: info.status, client_info: null};
}
const geo = await lookupGeoip(info.clientIp);
const {clientOs, clientPlatform} = resolveSessionClientInfo({
userAgent: info.userAgent ?? null,
isDesktopClient: info.clientPlatform === 'desktop',
const geo = await lookupGeoip(info.origin.ip);
const {branding} = await getInstanceConfigRepository().getAppPublicConfig();
const resolved = resolveSessionClientInfo({
userAgent: info.origin.userAgent,
reportedOs: info.origin.clientOs,
productName: branding.product_name,
});
return {
status: 'pending',
client_info: {
platform: clientPlatform,
os: clientOs,
platform: resolved.platform,
os: resolved.os,
device: resolved.device,
location: {
city: geo.city,
region: geo.region,
@@ -334,18 +335,18 @@ export class AuthRequestService {
};
}
async completeHandoff({data, request, clientIp, authToken}: AuthHandoffCompleteRequest): Promise<void> {
async completeHandoff({data, clientIp, authToken}: AuthHandoffCompleteRequest): Promise<void> {
const sessionToken = data.token ?? authToken;
if (!sessionToken) {
throw new UnauthorizedError();
}
await this.desktopHandoffService.completeHandoff(
data.code,
() =>
(origin) =>
AuthSession.createAdditionalAuthSessionFromToken(this.apiContext, {
token: sessionToken,
expectedUserId: data.user_id,
request,
origin,
}),
clientIp,
);
+31 -20
View File
@@ -15,12 +15,19 @@ import {Logger} from '../Logger';
import type {AuthSession} from '../models/AuthSession';
import type {User} from '../models/User';
import {lookupGeoip} from '../utils/IpUtils';
import {isFluxerNativeUserAgent, parseReportedClientOs} from '../utils/SessionClientIdentity';
import {mapAuthSessionsToResponse} from './AuthModel';
import * as AuthUtility from './AuthUtility';
export interface SessionOrigin {
ip: string;
userAgent: string | null;
clientOs: string | null;
}
interface CreateAuthSessionParams {
user: User;
request: Request;
origin: SessionOrigin;
}
interface LogoutAuthSessionsParams {
@@ -60,29 +67,35 @@ interface ReplaceCurrentAuthSessionResult {
interface CreateAdditionalAuthSessionFromTokenParams {
token: string;
expectedUserId?: string;
request: Request;
origin: SessionOrigin;
}
export function resolveSessionOrigin(ctx: ApiContext, request: Request): SessionOrigin {
const {config} = ctx.services;
const ip = requireClientIp(request, {
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
});
const userAgent = request.headers.get('user-agent')?.trim() || null;
const clientOs = isFluxerNativeUserAgent(userAgent)
? parseReportedClientOs(request.headers.get('x-fluxer-client-properties'))
: null;
return {ip, userAgent, clientOs};
}
export async function createAuthSession(
ctx: ApiContext,
{user, request}: CreateAuthSessionParams,
{user, origin}: CreateAuthSessionParams,
): Promise<[token: string, AuthSession]> {
const {users, config} = ctx.services;
const {users} = ctx.services;
if (user.isBot) throw new BotUserAuthSessionCreationDeniedError();
if (user.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)) throw new RegistrationPendingApprovalError();
if (user.traits.has(REGISTRATION_REJECTED_TRAIT)) throw new RegistrationRejectedError();
const now = new Date();
const token = await AuthUtility.generateAuthToken(ctx);
const ip = requireClientIp(request, {
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
});
const platformHeader = request.headers.get('x-fluxer-platform')?.trim().toLowerCase() ?? null;
const uaRaw = request.headers.get('user-agent') ?? '';
const isDesktopClient = platformHeader === 'desktop';
let clientCountry: string | null = null;
try {
const geoip = await lookupGeoip(ip);
const geoip = await lookupGeoip(origin.ip);
clientCountry = geoip.countryCode ? geoip.countryCode.toUpperCase() : null;
} catch (error) {
Logger.warn({userId: user.id.toString(), error}, 'GeoIP lookup failed at session creation');
@@ -92,11 +105,9 @@ export async function createAuthSession(
session_id_hash: Buffer.from(AuthUtility.getTokenIdHash(ctx, token)),
created_at: now,
approx_last_used_at: now,
client_ip: ip,
client_user_agent: uaRaw || null,
client_is_desktop: isDesktopClient,
client_os: null,
client_platform: null,
client_ip: origin.ip,
client_user_agent: origin.userAgent,
client_os: origin.clientOs,
client_country: clientCountry,
version: 1,
});
@@ -105,7 +116,7 @@ export async function createAuthSession(
export async function createAdditionalAuthSessionFromToken(
ctx: ApiContext,
{token, expectedUserId, request}: CreateAdditionalAuthSessionFromTokenParams,
{token, expectedUserId, origin}: CreateAdditionalAuthSessionFromTokenParams,
): Promise<{
token: string;
userId: string;
@@ -122,7 +133,7 @@ export async function createAdditionalAuthSessionFromToken(
if (expectedUserId && user.id.toString() !== expectedUserId) {
throw new SessionTokenMismatchError();
}
const [newToken] = await createAuthSession(ctx, {user, request});
const [newToken] = await createAuthSession(ctx, {user, origin});
return {token: newToken, userId: user.id.toString()};
}
@@ -205,7 +216,7 @@ export async function replaceCurrentAuthSession(
(authSession) => !authSession.sessionIdHash.equals(currentAuthSession.sessionIdHash),
);
await deleteAndTerminateAuthSessions(ctx, user.id, otherAuthSessions);
const [newToken, newAuthSession] = await createAuthSession(ctx, {user, request});
const [newToken, newAuthSession] = await createAuthSession(ctx, {user, origin: resolveSessionOrigin(ctx, request)});
const newAuthSessionIdHash = encodeSessionIdHash(newAuthSession.sessionIdHash);
await dispatchAuthSessionChange(ctx, {
userId: user.id,
@@ -5,8 +5,9 @@ import {HandoffCodeExpiredError} from '@fluxer/errors/src/domains/auth/HandoffCo
import {InvalidHandoffCodeError} from '@fluxer/errors/src/domains/auth/InvalidHandoffCodeError';
import {ms, seconds} from 'itty-time';
import type {ApiContext} from '../../ApiContext';
import type {SessionOrigin} from '../AuthSession';
const HANDOFF_CODE_PREFIX = 'desktop-handoff:';
const HANDOFF_CODE_PREFIX = 'desktop-handoff-v2:';
const HANDOFF_TOKEN_PREFIX = 'desktop-handoff-token:';
const CODE_CHARACTERS = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
const CODE_LENGTH = 12;
@@ -19,9 +20,7 @@ const MAX_INFO_LOOKUPS = 3;
interface HandoffData {
createdAt: number;
userAgent?: string;
clientIp: string;
clientPlatform?: string;
origin: SessionOrigin;
infoLookupCount: number;
}
@@ -61,7 +60,7 @@ function assertValidHandoffCode(code: string): void {
export class DesktopHandoffService {
constructor(private readonly apiContext: ApiContext) {}
async initiateHandoff(args: {userAgent?: string; clientIp: string; clientPlatform?: string}): Promise<{
async initiateHandoff(args: {origin: SessionOrigin}): Promise<{
code: string;
expiresAt: Date;
}> {
@@ -70,9 +69,7 @@ export class DesktopHandoffService {
const normalizedCode = normalizeHandoffCode(code);
const handoffData: HandoffData = {
createdAt: Date.now(),
userAgent: args.userAgent,
clientIp: args.clientIp,
clientPlatform: args.clientPlatform,
origin: args.origin,
infoLookupCount: 0,
};
const expirySeconds = seconds('5 minutes');
@@ -83,7 +80,7 @@ export class DesktopHandoffService {
async completeHandoff(
code: string,
createTokenData: () => Promise<{token: string; userId: string}>,
createTokenData: (origin: SessionOrigin) => Promise<{token: string; userId: string}>,
approverIp: string,
): Promise<void> {
const {cache} = this.apiContext.services;
@@ -107,7 +104,7 @@ export class DesktopHandoffService {
if (remainingSeconds <= 0) {
throw new HandoffCodeExpiredError();
}
const {token, userId} = await createTokenData();
const {token, userId} = await createTokenData(handoffData.origin);
const tokenData: HandoffTokenData = {
token,
userId,
@@ -122,9 +119,7 @@ export class DesktopHandoffService {
approverIp: string,
): Promise<{
status: 'pending' | 'expired';
userAgent?: string;
clientIp?: string;
clientPlatform?: string;
origin?: SessionOrigin;
}> {
const {cache} = this.apiContext.services;
const normalizedCode = normalizeHandoffCode(code);
@@ -149,12 +144,7 @@ export class DesktopHandoffService {
{approvedAt: Date.now()},
remainingTtl > 0 ? remainingTtl : seconds('5 minutes'),
);
return {
status: 'pending',
userAgent: handoffData.userAgent,
clientIp: handoffData.clientIp,
clientPlatform: handoffData.clientPlatform,
};
return {status: 'pending', origin: handoffData.origin};
}
async getHandoffStatus(
@@ -332,7 +332,10 @@ export class SsoService {
});
const claims = await this.resolveClaims(tokenResponse, config, statePayload.nonce);
const user = await this.resolveUserFromClaims(claims, config);
const [token] = await AuthSession.createAuthSession(this.apiContext, {user, request});
const [token] = await AuthSession.createAuthSession(this.apiContext, {
user,
origin: AuthSession.resolveSessionOrigin(this.apiContext, request),
});
return {token, user_id: user.id.toString(), redirect_to: statePayload.redirectTo ?? ''};
}
@@ -1,10 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {maskIpForDisplay} from '@fluxer/ip_utils/src/IpAddress';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import type {ApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
import {createAuthHarness, createTestAccount, fetchMe, loginAccount} from './AuthTestUtils';
interface HandoffInitiateResponse {
@@ -17,6 +19,7 @@ interface HandoffInfoResponse {
client_info?: {
platform?: string | null;
os?: string | null;
device?: 'mobile' | 'desktop';
location?: {
city?: string | null;
region?: string | null;
@@ -25,6 +28,16 @@ interface HandoffInfoResponse {
} | null;
}
interface AuthSessionsResponseItem {
masked_ip?: string | null;
client_info?: {
platform?: string | null;
os?: string | null;
browser?: string | null;
device?: 'mobile' | 'desktop';
} | null;
}
interface HandoffStatusResponse {
status: 'pending' | 'completed' | 'expired';
token?: string;
@@ -53,6 +66,46 @@ describe('Auth desktop handoff flow', () => {
afterAll(async () => {
await harness?.shutdown();
});
it('attributes the handed-off session to the initiating desktop, not the approving browser', async () => {
const DESKTOP_IP = '203.0.113.77';
const desktopUserAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) FluxerStable/1.4.0 Chrome/128.0.0.0 Electron/32.0.0 Safari/537.36';
const browserUserAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36';
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await createBuilderWithoutAuth<HandoffInitiateResponse>(harness)
.post('/auth/handoff/initiate')
.header('User-Agent', desktopUserAgent)
.header('x-forwarded-for', DESKTOP_IP)
.body(null)
.execute();
const info = await createBuilderWithoutAuth<HandoffInfoResponse>(harness)
.get(`/auth/handoff/${initResp.code}/info`)
.header('User-Agent', browserUserAgent)
.execute();
expect(info.client_info?.platform).toBe('Fluxer macOS');
expect(info.client_info?.device).toBe('desktop');
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/complete')
.header('User-Agent', browserUserAgent)
.body({code: initResp.code, token: login.token, user_id: login.userId})
.expect(204)
.execute();
const completed = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.get(`/auth/handoff/${initResp.code}/status`)
.execute();
const sessions = await createBuilder<Array<AuthSessionsResponseItem>>(harness, completed.token!)
.get('/auth/sessions')
.execute();
const handedOff = sessions.filter((session) => session.client_info?.platform === 'Fluxer macOS');
expect(handedOff).toHaveLength(1);
expect(handedOff[0]?.client_info?.os).toBe('macOS');
expect(handedOff[0]?.client_info?.browser).toBeNull();
expect(handedOff[0]?.client_info?.device).toBe('desktop');
expect(sessions.some((session) => session.client_info?.browser === 'Chrome')).toBe(false);
expect(handedOff[0]?.masked_ip).toBe(maskIpForDisplay(DESKTOP_IP));
});
it('completes full handoff flow: initiate → info → complete → status', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
@@ -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);
@@ -18,9 +18,7 @@ export interface AuthSessionRow {
approx_last_used_at: Date;
client_ip: string;
client_user_agent: Nullish<string>;
client_is_desktop: Nullish<boolean>;
client_os?: Nullish<string>;
client_platform?: Nullish<string>;
client_os: Nullish<string>;
client_country: Nullish<string>;
version: number;
}
@@ -32,9 +30,7 @@ export interface AuthSessionTombstoneRow {
approx_last_used_at: Date;
client_ip: string;
client_user_agent: Nullish<string>;
client_is_desktop: Nullish<boolean>;
client_os: Nullish<string>;
client_platform: Nullish<string>;
client_country: Nullish<string>;
deleted_at: Date;
version: number;
@@ -140,9 +136,7 @@ export const AUTH_SESSION_COLUMNS = [
'approx_last_used_at',
'client_ip',
'client_user_agent',
'client_is_desktop',
'client_os',
'client_platform',
'client_country',
'version',
] as const satisfies ReadonlyArray<keyof AuthSessionRow>;
@@ -153,9 +147,7 @@ export const AUTH_SESSION_TOMBSTONE_COLUMNS = [
'approx_last_used_at',
'client_ip',
'client_user_agent',
'client_is_desktop',
'client_os',
'client_platform',
'client_country',
'deleted_at',
'version',
+2 -12
View File
@@ -10,9 +10,7 @@ export class AuthSession {
readonly approximateLastUsedAt: Date;
readonly clientIp: string;
readonly clientUserAgent: string | null;
readonly clientIsDesktop: boolean | null;
readonly clientOs?: string | null;
readonly clientPlatform?: string | null;
readonly clientOs: string | null;
readonly clientCountry: string | null;
readonly version: number;
@@ -23,9 +21,7 @@ export class AuthSession {
this.approximateLastUsedAt = row.approx_last_used_at;
this.clientIp = row.client_ip;
this.clientUserAgent = row.client_user_agent ?? null;
this.clientIsDesktop = row.client_is_desktop ?? null;
this.clientOs = row.client_os ?? null;
this.clientPlatform = row.client_platform ?? null;
this.clientCountry = row.client_country ?? null;
this.version = row.version;
}
@@ -38,9 +34,7 @@ export class AuthSession {
approx_last_used_at: this.approximateLastUsedAt,
client_ip: this.clientIp,
client_user_agent: this.clientUserAgent,
client_is_desktop: this.clientIsDesktop,
client_os: this.clientOs,
client_platform: this.clientPlatform,
client_country: this.clientCountry,
version: this.version,
};
@@ -54,9 +48,7 @@ export class AuthSessionTombstone {
readonly approximateLastUsedAt: Date;
readonly clientIp: string;
readonly clientUserAgent: string | null;
readonly clientIsDesktop: boolean | null;
readonly clientOs?: string | null;
readonly clientPlatform?: string | null;
readonly clientOs: string | null;
readonly clientCountry: string | null;
readonly deletedAt: Date;
readonly version: number;
@@ -68,9 +60,7 @@ export class AuthSessionTombstone {
this.approximateLastUsedAt = row.approx_last_used_at;
this.clientIp = row.client_ip;
this.clientUserAgent = row.client_user_agent ?? null;
this.clientIsDesktop = row.client_is_desktop ?? null;
this.clientOs = row.client_os ?? null;
this.clientPlatform = row.client_platform ?? null;
this.clientCountry = row.client_country ?? null;
this.deletedAt = row.deleted_at;
this.version = row.version;
+29 -6
View File
@@ -26040,11 +26040,18 @@
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "The operating system of the requesting device"
},
"device": {
"enum": ["mobile", "desktop"],
"type": "string",
"x-enumNames": ["mobile", "desktop"],
"description": "Device class of the requesting device, decided by the server"
},
"location": {
"anyOf": [{"$ref": "#/components/schemas/AuthSessionLocation"}, {"type": "null"}],
"description": "The approximate location of the requesting device"
}
}
},
"required": ["device"]
},
{"type": "null"}
],
@@ -26288,11 +26295,18 @@
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "The browser reported by the client"
},
"device": {
"enum": ["mobile", "desktop"],
"type": "string",
"x-enumNames": ["mobile", "desktop"],
"description": "Device class of the session, decided by the server"
},
"location": {
"anyOf": [{"$ref": "#/components/schemas/AuthSessionLocation"}, {"type": "null"}],
"description": "The geolocation data sent by the client"
}
}
},
"required": ["device"]
},
{"type": "null"}
],
@@ -27388,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"},
@@ -33239,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": [
@@ -33255,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();
});
});
});
@@ -35,6 +35,7 @@ import type {Context} from 'hono';
import {seconds} from 'itty-time';
import {AttachmentDecayRepository} from '../attachment/AttachmentDecayRepository';
import type {IpAuthorizationTicketCache} from '../auth/AuthLogin';
import {getTicketCacheKey} from '../auth/AuthLogin';
import {
type ChannelID,
createApplicationID,
@@ -86,6 +87,7 @@ import {UserRepository} from '../user/repositories/UserRepository';
import {processUserDeletion} from '../user/services/UserDeletionService';
import {UserHarvestRepository} from '../user/UserHarvestRepository';
import {getExpiryBucket} from '../utils/AttachmentDecay';
import {parseReportedClientOs} from '../utils/SessionClientIdentity';
import {ScheduledMessageExecutor} from '../worker/executors/ScheduledMessageExecutor';
import {processExpiredAttachments} from '../worker/tasks/ExpireAttachments';
import {processInactivityDeletionsCore} from '../worker/tasks/ProcessInactivityDeletions';
@@ -627,7 +629,7 @@ export function TestHarnessController(app: HonoApp) {
client_ip: clientIp,
user_agent: userAgent,
client_location: clientLocation,
platform,
client_properties: clientProperties,
resend_used: resendUsed,
invite_code: inviteCode,
created_at: createdAtInput,
@@ -649,9 +651,11 @@ export function TestHarnessController(app: HonoApp) {
userId: String(userId),
email: String(email),
username: String(username),
clientIp: String(clientIp),
userAgent: String(userAgent),
platform: platform ? String(platform) : null,
origin: {
ip: String(clientIp),
userAgent: userAgent ? String(userAgent) : null,
clientOs: parseReportedClientOs(clientProperties ? String(clientProperties) : null),
},
authToken: String(token),
clientLocation: String(clientLocation),
inviteCode: inviteCode ? String(inviteCode) : null,
@@ -659,7 +663,7 @@ export function TestHarnessController(app: HonoApp) {
createdAt,
};
const ttl = typeof ttlSeconds === 'number' && ttlSeconds > 0 ? ttlSeconds : seconds('15 minutes');
await cacheService.set(`ip-auth-ticket:${ticket}`, payload, ttl);
await cacheService.set(getTicketCacheKey(String(ticket)), payload, ttl);
await cacheService.set(`ip-auth-token:${token}`, {ticket: String(ticket)}, ttl);
return ctx.json(
{
@@ -721,7 +725,7 @@ export function TestHarnessController(app: HonoApp) {
return ctx.json({error: 'ticket or token is required'}, 400);
}
if (ticket) {
await cacheService.delete(`ip-auth-ticket:${ticket}`);
await cacheService.delete(getTicketCacheKey(String(ticket)));
await cacheService.delete(`ip-auth:${ticket}`);
}
if (token) {
@@ -211,9 +211,7 @@ function toTombstoneRow(row: AuthSessionRow, deletedAt: Date): AuthSessionTombst
approx_last_used_at: row.approx_last_used_at,
client_ip: row.client_ip,
client_user_agent: row.client_user_agent,
client_is_desktop: row.client_is_desktop,
client_os: row.client_os ?? null,
client_platform: row.client_platform ?? null,
client_country: row.client_country ?? null,
deleted_at: deletedAt,
version: row.version,
@@ -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,134 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Bowser from 'bowser';
import {Logger} from '../Logger';
import {parseJsonRecord} from './JsonBoundaryUtils';
export type SessionDeviceClass = 'mobile' | 'desktop';
interface SessionClientSignals {
userAgent: string | null;
reportedOs: string | null;
productName: string;
}
export interface SessionClientInfo {
platform: string | null;
os: string | null;
browser: string | null;
device: SessionDeviceClass;
}
type OsToken = 'android' | 'ios' | 'macos' | 'windows' | 'linux';
const OS_DISPLAY: Record<OsToken, string> = {
android: 'Android',
ios: 'iOS',
macos: 'macOS',
windows: 'Windows',
linux: 'Linux',
};
const BOWSER_OS_TO_TOKEN: Record<string, OsToken> = {
macOS: 'macos',
Windows: 'windows',
Linux: 'linux',
iOS: 'ios',
Android: 'android',
};
const NATIVE_UA_REGEX = /^Fluxer (Android|iOS|Linux|Desktop|Client)(?=[/ ]|$)/;
const ELECTRON_UA_REGEX = /\bElectron\/\d+(?:\.\d+)*/;
const PRODUCT_TOKEN_OS: Record<string, OsToken | null> = {
Android: 'android',
iOS: 'ios',
Linux: 'linux',
Desktop: null,
Client: null,
};
const CLIENT_PROPERTIES_HEADER_MAX_LENGTH = 4096;
const MOBILE_PLATFORM_TYPES = new Set(['mobile', 'tablet']);
function narrowOsToken(value: string | null): OsToken | null {
if (value === null) return null;
return Object.hasOwn(OS_DISPLAY, value) ? (value as OsToken) : null;
}
function parseUserAgent(userAgent: string): Bowser.Parser.Parser | null {
try {
return Bowser.getParser(userAgent);
} catch (error) {
Logger.warn({error}, 'Failed to parse user agent');
return null;
}
}
function bowserOsToken(userAgent: string): OsToken | null {
if (!userAgent) return null;
return narrowOsToken(BOWSER_OS_TO_TOKEN[parseUserAgent(userAgent)?.getOSName() ?? ''] ?? null);
}
export function isFluxerNativeUserAgent(userAgent: string | null): boolean {
return NATIVE_UA_REGEX.test(userAgent?.trim() ?? '');
}
export function parseReportedClientOs(headerValue: string | null): OsToken | null {
if (!headerValue) return null;
const trimmed = headerValue.trim();
if (!trimmed || trimmed.length > CLIENT_PROPERTIES_HEADER_MAX_LENGTH) return null;
let decoded: string;
try {
decoded = Buffer.from(trimmed, 'base64').toString('utf8');
} catch {
return null;
}
const record = parseJsonRecord(decoded);
if (!record) return null;
const os = record.os;
return typeof os === 'string' ? narrowOsToken(os) : null;
}
export function resolveSessionClientInfo({
userAgent,
reportedOs,
productName,
}: SessionClientSignals): SessionClientInfo {
const ua = userAgent?.trim() ?? '';
const nativeMatch = NATIVE_UA_REGEX.exec(ua);
if (nativeMatch) {
const productToken = nativeMatch[1] as keyof typeof PRODUCT_TOKEN_OS;
const osToken = narrowOsToken(reportedOs) ?? PRODUCT_TOKEN_OS[productToken] ?? bowserOsToken(ua);
const osDisplay = osToken ? OS_DISPLAY[osToken] : null;
const mobile = osToken === 'ios' || osToken === 'android';
const platform = osDisplay
? mobile
? `${productName} ${osDisplay}`
: `${productName} Lite ${osDisplay}`
: `${productName} Lite`;
return {platform, os: osDisplay, browser: null, device: mobile ? 'mobile' : 'desktop'};
}
if (ELECTRON_UA_REGEX.test(ua)) {
const osToken = bowserOsToken(ua);
const osDisplay = osToken ? OS_DISPLAY[osToken] : null;
const mobile = osToken === 'ios' || osToken === 'android';
return {
platform: osDisplay ? `${productName} ${osDisplay}` : productName,
os: osDisplay,
browser: null,
device: mobile ? 'mobile' : 'desktop',
};
}
const parser = ua ? parseUserAgent(ua) : null;
const browser = parser?.getBrowserName() || null;
const os = parser?.getOSName() || null;
const platformType = parser?.getPlatformType(true) ?? '';
return {
platform: browser ?? os,
os,
browser,
device: MOBILE_PLATFORM_TYPES.has(platformType) ? 'mobile' : 'desktop',
};
}
@@ -1,43 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Bowser from 'bowser';
import {Logger} from '../Logger';
interface UserAgentInfo {
clientOs: string;
detectedPlatform: string;
}
const UNKNOWN_LABEL = 'Unknown';
function formatName(name?: string | null): string {
const normalized = name?.trim();
return normalized || UNKNOWN_LABEL;
}
function parseUserAgentSafe(userAgentRaw: string): UserAgentInfo {
const ua = userAgentRaw.trim();
if (!ua) return {clientOs: UNKNOWN_LABEL, detectedPlatform: UNKNOWN_LABEL};
try {
const parser = Bowser.getParser(ua);
return {
clientOs: formatName(parser.getOSName()),
detectedPlatform: formatName(parser.getBrowserName()),
};
} catch (error) {
Logger.warn({error}, 'Failed to parse user agent');
return {clientOs: UNKNOWN_LABEL, detectedPlatform: UNKNOWN_LABEL};
}
}
export function resolveSessionClientInfo(args: {userAgent: string | null; isDesktopClient: boolean | null}): {
clientOs: string;
clientPlatform: string;
} {
const parsed = parseUserAgentSafe(args.userAgent ?? '');
const clientPlatform = args.isDesktopClient ? 'Fluxer Desktop' : parsed.detectedPlatform;
return {
clientOs: parsed.clientOs,
clientPlatform,
};
}
@@ -0,0 +1,190 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {
isFluxerNativeUserAgent,
parseReportedClientOs,
resolveSessionClientInfo,
type SessionClientInfo,
} from '../SessionClientIdentity';
const ELECTRON_MAC_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) FluxerStable/2026.614.83512 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36';
const ELECTRON_WINDOWS_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) FluxerCanary/2026.614.83512 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36';
const CHROME_MAC_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
const SAFARI_IPHONE_UA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
const resolve = (userAgent: string | null, reportedOs: string | null, productName = 'Fluxer'): SessionClientInfo =>
resolveSessionClientInfo({userAgent, reportedOs, productName});
describe('resolveSessionClientInfo', () => {
it('labels the Flutter mobile clients by their operating system', () => {
expect(resolve('Fluxer iOS/1.4.2 (stable)', 'ios')).toEqual({
platform: 'Fluxer iOS',
os: 'iOS',
browser: null,
device: 'mobile',
});
expect(resolve('Fluxer Android/1.4.2 (stable)', 'android')).toEqual({
platform: 'Fluxer Android',
os: 'Android',
browser: null,
device: 'mobile',
});
});
it('accepts the versionless user agent emitted before package info loads', () => {
expect(resolve('Fluxer iOS (stable)', 'ios')).toEqual({
platform: 'Fluxer iOS',
os: 'iOS',
browser: null,
device: 'mobile',
});
});
it('labels Flutter desktop builds as Lite and distinguishes them by reported operating system', () => {
expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'macos')).toEqual({
platform: 'Fluxer Lite macOS',
os: 'macOS',
browser: null,
device: 'desktop',
});
expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'windows')).toEqual({
platform: 'Fluxer Lite Windows',
os: 'Windows',
browser: null,
device: 'desktop',
});
expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'linux')).toEqual({
platform: 'Fluxer Lite Linux',
os: 'Linux',
browser: null,
device: 'desktop',
});
});
it('treats a narrow Linux window as a desktop because the product token cannot carry form factor', () => {
expect(resolve('Fluxer Linux/1.4.2 (stable)', 'linux')).toEqual({
platform: 'Fluxer Lite Linux',
os: 'Linux',
browser: null,
device: 'desktop',
});
});
it('resolves legacy rows that predate the reported operating system', () => {
expect(resolve('Fluxer iOS/1.4.2 (stable)', null)).toEqual({
platform: 'Fluxer iOS',
os: 'iOS',
browser: null,
device: 'mobile',
});
expect(resolve('Fluxer Desktop/1.4.2 (stable)', null)).toEqual({
platform: 'Fluxer Lite',
os: null,
browser: null,
device: 'desktop',
});
});
it('ignores a corrupt reported operating system instead of rendering it', () => {
expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'Windows 11')).toEqual({
platform: 'Fluxer Lite',
os: null,
browser: null,
device: 'desktop',
});
});
it('labels the desktop application by operating system without naming a browser', () => {
expect(resolve(ELECTRON_MAC_UA, null)).toEqual({
platform: 'Fluxer macOS',
os: 'macOS',
browser: null,
device: 'desktop',
});
expect(resolve(ELECTRON_WINDOWS_UA, null)).toEqual({
platform: 'Fluxer Windows',
os: 'Windows',
browser: null,
device: 'desktop',
});
});
it('keeps browser sessions reporting their browser', () => {
expect(resolve(CHROME_MAC_UA, null)).toEqual({
platform: 'Chrome',
os: 'macOS',
browser: 'Chrome',
device: 'desktop',
});
expect(resolve(SAFARI_IPHONE_UA, null)).toEqual({
platform: 'Safari',
os: 'iOS',
browser: 'Safari',
device: 'mobile',
});
});
it('keeps the operating system when the browser cannot be named', () => {
expect(resolve('SomeBot (Windows NT 10.0; Win64; x64)', null)).toEqual({
platform: 'Windows',
os: 'Windows',
browser: null,
device: 'desktop',
});
});
it('rejects a product token that only prefixes a real one', () => {
const resolved = resolve('Fluxer iOS-not-really/6.6.6', 'ios');
expect(resolved.platform).not.toBe('Fluxer iOS');
expect(resolved.browser).toBe(resolved.platform);
});
it('never emits an Unknown literal when the user agent is absent', () => {
expect(resolve(null, null)).toEqual({platform: null, os: null, browser: null, device: 'desktop'});
expect(resolve('', null)).toEqual({platform: null, os: null, browser: null, device: 'desktop'});
});
it('uses instance branding for the product word', () => {
expect(resolve('Fluxer iOS/1.4.2 (stable)', 'ios', 'Acme').platform).toBe('Acme iOS');
expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'windows', 'Acme').platform).toBe('Acme Lite Windows');
expect(resolve(ELECTRON_MAC_UA, null, 'Acme').platform).toBe('Acme macOS');
});
});
describe('isFluxerNativeUserAgent', () => {
it('matches only the Fluxer native product tokens', () => {
expect(isFluxerNativeUserAgent('Fluxer iOS/1.4.2 (stable)')).toBe(true);
expect(isFluxerNativeUserAgent('Fluxer Desktop (canary)')).toBe(true);
expect(isFluxerNativeUserAgent(ELECTRON_MAC_UA)).toBe(false);
expect(isFluxerNativeUserAgent(CHROME_MAC_UA)).toBe(false);
expect(isFluxerNativeUserAgent(null)).toBe(false);
});
});
describe('parseReportedClientOs', () => {
const encode = (value: unknown): string => Buffer.from(JSON.stringify(value), 'utf8').toString('base64');
it('reads the operating system from the client properties header', () => {
expect(parseReportedClientOs(encode({os: 'macos', device: 'mobile'}))).toBe('macos');
expect(parseReportedClientOs(encode({os: 'ios'}))).toBe('ios');
});
it('rejects anything outside the known operating systems', () => {
expect(parseReportedClientOs(null)).toBeNull();
expect(parseReportedClientOs('')).toBeNull();
expect(parseReportedClientOs('not base64 $$$')).toBeNull();
expect(parseReportedClientOs(encode([1, 2]))).toBeNull();
expect(parseReportedClientOs(encode({os: 'solaris'}))).toBeNull();
expect(parseReportedClientOs(encode({os: 42}))).toBeNull();
expect(parseReportedClientOs(encode({}))).toBeNull();
});
it('rejects an oversized header without decoding it', () => {
expect(parseReportedClientOs('a'.repeat(4097))).toBeNull();
});
});
@@ -50,7 +50,7 @@ import type {User} from '../../models/User';
import type {UserGuildSettings} from '../../models/UserGuildSettings';
import type {UserSettings} from '../../models/UserSettings';
import type {WebAuthnCredential} from '../../models/WebAuthnCredential';
import {resolveSessionClientInfo} from '../../utils/UserAgentUtils';
import {resolveSessionClientInfo} from '../../utils/SessionClientIdentity';
import {createArchiveJsonBuffer} from '../utils/ArchiveJson';
import {appendAssetToArchive, buildHashedAssetKey, getAnimatedAssetExtension} from '../utils/AssetArchiveHelpers';
import {ContentAddressedAttachmentCollector} from '../utils/ContentAddressedAttachmentCollector';
@@ -114,6 +114,7 @@ interface HarvestMessageResult {
interface UserDataJsonParams {
user: User;
userId: UserID;
productName: string;
authSessions: Array<AuthSession>;
relationships: Array<Relationship>;
userNotes: Map<UserID, string>;
@@ -354,6 +355,7 @@ function buildUserDataJson(params: UserDataJsonParams) {
const {
user,
userId,
productName,
authSessions,
relationships,
userNotes,
@@ -412,17 +414,18 @@ function buildUserDataJson(params: UserDataJsonParams) {
authenticator_types: Array.from(user.authenticatorTypes),
},
auth_sessions: authSessions.map((session) => {
const {clientOs, clientPlatform} = resolveSessionClientInfo({
const clientInfo = resolveSessionClientInfo({
userAgent: session.clientUserAgent,
isDesktopClient: session.clientIsDesktop,
reportedOs: session.clientOs ?? null,
productName,
});
return {
created_at: session.createdAt.toISOString(),
approx_last_used_at: session.approximateLastUsedAt?.toISOString() ?? null,
client_ip: session.clientIp,
client_os: clientOs,
client_os: clientInfo.os,
client_user_agent: session.clientUserAgent,
client_platform: clientPlatform,
client_platform: clientInfo.platform,
};
}),
relationships: relationships.map((rel) => ({
@@ -872,9 +875,11 @@ const harvestUserData: WorkerTaskHandler = async (payload, helpers) => {
const guildSettings = await Promise.all(
guildIds.map((guildId: GuildID) => userRepository.findGuildSettings(userId, guildId)),
);
const {branding} = await instanceConfigRepository.getAppPublicConfig();
const userData = buildUserDataJson({
user,
userId,
productName: branding.product_name,
authSessions,
relationships,
userNotes,
@@ -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 -50
View File
@@ -53,12 +53,6 @@ dependencies = [
"shlex",
]
[[package]]
name = "cfg-if"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -111,7 +105,7 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if 1.0.4",
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
@@ -123,7 +117,7 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if 1.0.4",
"cfg-if",
"libc",
"r-efi 6.0.0",
"wasip2",
@@ -203,7 +197,6 @@ version = "0.1.0"
dependencies = [
"proptest",
"wasm-bindgen",
"wee_alloc",
"zstd",
]
@@ -225,12 +218,6 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memory_units"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8452105ba047068f40ff7093dd1d9da90898e63dd61736462e9cdda6a90ad3c3"
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -530,7 +517,7 @@ version = "0.2.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
dependencies = [
"cfg-if 1.0.4",
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
@@ -603,40 +590,6 @@ dependencies = [
"semver",
]
[[package]]
name = "wee_alloc"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbb3b5a6b2bb17cb6ad44a2e68a43e8d2722c997da10e928665c72ec6c0a0b8e"
dependencies = [
"cfg-if 0.1.10",
"libc",
"memory_units",
"winapi",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
-1
View File
@@ -10,7 +10,6 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "=0.2.123"
wee_alloc = {version = "0.4.5", default-features = false}
zstd = {version = "0.13.3", default-features = false, features = ["no_asm", "thin", "wasm"]}
[dev-dependencies]
-4
View File
@@ -9,10 +9,6 @@ use formats::is_animated_image_bytes;
use rgba::{TransformRequest, crop_rotate_rgba_alloc};
use wasm_bindgen::prelude::*;
#[cfg(target_arch = "wasm32")]
#[global_allocator]
static WASM_ALLOCATOR: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
#[wasm_bindgen]
#[allow(clippy::too_many_arguments)]
pub fn crop_rotate_rgba_raw(
-11
View File
@@ -16,11 +16,6 @@
background: transparent;
}
:global(html.platform-native.window-focus-activation-guard:not(.first-click-passthrough-when-unfocused))
:local(.appContainer) {
pointer-events: none;
}
:global(html.is-standalone:not(.auth-page)) :local(.appContainer) {
padding-top: env(safe-area-inset-top);
}
@@ -86,12 +81,6 @@
--skip-link-titlebar-offset: var(--native-titlebar-height);
--skip-link-traffic-lights-offset: 4.5rem;
}
:global(html.platform-native.window-focus-activation-guard:not(.first-click-passthrough-when-unfocused))
:local(.overlayScope)
> :not([data-overlay-pass-through]) {
pointer-events: none;
}
.quickSwitcherPortal {
position: fixed;
inset: 0;
+2 -14
View File
@@ -45,15 +45,9 @@ import {getActivePortalHost, setActivePortalHost} from '@app/features/ui/overlay
import MobileLayout from '@app/features/ui/state/MobileLayout';
import Modal from '@app/features/ui/state/Modal';
import Popout from '@app/features/ui/state/Popout';
import {
getDesktopWindowBehaviorSettings,
setDesktopWindowBehaviorSettings,
} from '@app/features/ui/utils/DesktopWindowBehaviorUtils';
import {getDesktopWindowBehaviorSettings} from '@app/features/ui/utils/DesktopWindowBehaviorUtils';
import {attachExternalLinkInterceptor, isDesktop} from '@app/features/ui/utils/NativeUtils';
import {
FIRST_CLICK_PASSTHROUGH_WHEN_UNFOCUSED_CLASS,
UNFOCUSED_FULLY_INTERACTIVE_CLASS,
} from '@app/features/ui/utils/WindowFocusInteractionGuard';
import {UNFOCUSED_FULLY_INTERACTIVE_CLASS} from '@app/features/ui/utils/WindowFocusInteractionGuard';
import UserSettings from '@app/features/user/state/UserSettings';
import {IncomingCallManager} from '@app/features/voice/components/IncomingCallManager';
import {VoiceLiveKitRoot} from '@app/features/voice/components/VoiceLiveKitRoot';
@@ -86,7 +80,6 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
const {i18n} = useLingui();
const reducedMotion = Accessibility.useReducedMotion;
const stayInteractiveWhenUnfocused = Accessibility.stayInteractiveWhenUnfocused;
const firstClickPassThroughWhenUnfocused = Accessibility.firstClickPassThroughWhenUnfocused;
const {platform, isNative} = useNativePlatform();
const useSystemTitleBar = useNativeTitleBar();
const messageDisplayCompact = UserSettings.getMessageDisplayCompact();
@@ -163,11 +156,6 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
useDocumentClassToggle('reduced-motion', reducedMotion);
useDocumentClassToggle('mobile-layout', MobileLayout.platformMobileDetected || MobileLayout.enabled);
useDocumentClassToggle(UNFOCUSED_FULLY_INTERACTIVE_CLASS, stayInteractiveWhenUnfocused);
useDocumentClassToggle(FIRST_CLICK_PASSTHROUGH_WHEN_UNFOCUSED_CLASS, firstClickPassThroughWhenUnfocused);
useEffect(() => {
if (!isNative) return;
void setDesktopWindowBehaviorSettings({firstClickPassThroughWhenUnfocused});
}, [isNative, firstClickPassThroughWhenUnfocused]);
useDesktopAllowTransparency(isNative);
useWindowEventListeners({preventDocumentScroll: !isNative});
useRemScaleTracking();
@@ -600,7 +600,6 @@ export interface AccessibilitySettings {
hdrDisplayMode: HdrDisplayMode;
preserveEditDraft: boolean;
stayInteractiveWhenUnfocused: boolean;
firstClickPassThroughWhenUnfocused: boolean;
scrollToBottomOnMessageSend: boolean;
sequentialFileSend: boolean;
showNeko: boolean;
@@ -718,7 +717,6 @@ class Accessibility {
hdrDisplayMode = HdrDisplayMode.FULL;
preserveEditDraft = false;
stayInteractiveWhenUnfocused = false;
firstClickPassThroughWhenUnfocused = false;
scrollToBottomOnMessageSend = true;
sequentialFileSend = false;
showNeko = false;
@@ -829,7 +827,6 @@ class Accessibility {
'hdrDisplayMode',
'preserveEditDraft',
'stayInteractiveWhenUnfocused',
'firstClickPassThroughWhenUnfocused',
'scrollToBottomOnMessageSend',
'sequentialFileSend',
],
@@ -890,7 +887,6 @@ class Accessibility {
hdrDisplayMode: HDR_TO_PROTO[s.hdrDisplayMode],
preserveEditDraft: s.preserveEditDraft,
stayInteractiveWhenUnfocused: s.stayInteractiveWhenUnfocused,
firstClickPassThroughWhenUnfocused: s.firstClickPassThroughWhenUnfocused,
scrollToBottomOnMessageSend: s.scrollToBottomOnMessageSend,
sequentialFileSend: s.sequentialFileSend,
}),
@@ -980,8 +976,6 @@ class Accessibility {
if (m.preserveEditDraft !== undefined) s.preserveEditDraft = m.preserveEditDraft;
if (m.stayInteractiveWhenUnfocused !== undefined)
s.stayInteractiveWhenUnfocused = m.stayInteractiveWhenUnfocused;
if (m.firstClickPassThroughWhenUnfocused !== undefined)
s.firstClickPassThroughWhenUnfocused = m.firstClickPassThroughWhenUnfocused;
if (m.scrollToBottomOnMessageSend !== undefined) s.scrollToBottomOnMessageSend = m.scrollToBottomOnMessageSend;
if (m.sequentialFileSend !== undefined) s.sequentialFileSend = m.sequentialFileSend;
},
@@ -1316,8 +1310,6 @@ class Accessibility {
if (validated.preserveEditDraft !== undefined) this.preserveEditDraft = validated.preserveEditDraft;
if (validated.stayInteractiveWhenUnfocused !== undefined)
this.stayInteractiveWhenUnfocused = validated.stayInteractiveWhenUnfocused;
if (validated.firstClickPassThroughWhenUnfocused !== undefined)
this.firstClickPassThroughWhenUnfocused = validated.firstClickPassThroughWhenUnfocused;
if (validated.scrollToBottomOnMessageSend !== undefined)
this.scrollToBottomOnMessageSend = validated.scrollToBottomOnMessageSend;
if (validated.sequentialFileSend !== undefined) this.sequentialFileSend = validated.sequentialFileSend;
@@ -1418,8 +1410,6 @@ class Accessibility {
hdrDisplayMode: data.hdrDisplayMode ?? this.hdrDisplayMode,
preserveEditDraft: data.preserveEditDraft ?? this.preserveEditDraft,
stayInteractiveWhenUnfocused: data.stayInteractiveWhenUnfocused ?? this.stayInteractiveWhenUnfocused,
firstClickPassThroughWhenUnfocused:
data.firstClickPassThroughWhenUnfocused ?? this.firstClickPassThroughWhenUnfocused,
scrollToBottomOnMessageSend: data.scrollToBottomOnMessageSend ?? this.scrollToBottomOnMessageSend,
sequentialFileSend: data.sequentialFileSend ?? this.sequentialFileSend,
showNeko: data.showNeko ?? this.showNeko,
@@ -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
@@ -6,7 +6,6 @@ import {THE_OTHER_PLATFORM} from '@fluxer/constants/src/ExternalPlatformConstant
import {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME, PRODUCT_NAME} from './ProductConstants';
export {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME, PRODUCT_NAME};
export const DESKTOP_PRODUCT_NAME = `${PRODUCT_NAME} Desktop`;
export const PRODUCT_API_NAME = `${PRODUCT_NAME} API`;
export const PRODUCT_HQ_COMMUNITY_NAME = `${PRODUCT_NAME} HQ`;
export const CANARY_RELEASE_CHANNEL_NAME = `${PRODUCT_NAME} Canary`;
@@ -29,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`;
@@ -1,177 +0,0 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {useRovingFocusList} from '@app/features/app/hooks/useRovingFocusList';
import {act} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
function RovingFocusProbe() {
const listRef = useRovingFocusList<HTMLDivElement>({
autoFocusFirst: true,
focusableSelector: '[data-roving-focus="true"]',
manageTabIndex: true,
});
return (
<div
ref={listRef}
role="menu"
aria-orientation="vertical"
data-flx="app.use-roving-focus-list-test.roving-focus-probe.menu"
>
<button
type="button"
role="menuitem"
data-roving-focus="true"
data-flx="app.use-roving-focus-list-test.roving-focus-probe.menuitem.button"
>
First
</button>
<button
type="button"
role="menuitem"
data-roving-focus="true"
data-flx="app.use-roving-focus-list-test.roving-focus-probe.menuitem.button--2"
>
Second
</button>
<div
role="menuitemcheckbox"
aria-checked="false"
tabIndex={0}
data-roving-focus="true"
data-flx="app.use-roving-focus-list-test.roving-focus-probe.menuitemcheckbox"
>
Third
</div>
</div>
);
}
function ManualEntryRovingFocusProbe() {
const listRef = useRovingFocusList<HTMLDivElement>({
focusableSelector: '[data-roving-focus="true"]',
manageTabIndex: true,
});
return (
<div
ref={listRef}
role="menu"
aria-orientation="vertical"
tabIndex={-1}
data-autofocus
data-flx="app.use-roving-focus-list-test.manual-entry-roving-focus-probe.menu"
>
<button
type="button"
role="menuitem"
data-roving-focus="true"
data-flx="app.use-roving-focus-list-test.manual-entry-roving-focus-probe.menuitem.button"
>
First
</button>
<button
type="button"
role="menuitem"
data-roving-focus="true"
data-flx="app.use-roving-focus-list-test.manual-entry-roving-focus-probe.menuitem.button--2"
>
Second
</button>
</div>
);
}
const press = (target: Element, key: string): KeyboardEvent => {
const event = new KeyboardEvent('keydown', {key, bubbles: true, cancelable: true});
act(() => {
target.dispatchEvent(event);
});
return event;
};
const getItems = (container: HTMLElement): Array<HTMLElement> =>
Array.from(container.querySelectorAll<HTMLElement>('[data-roving-focus="true"]'));
describe('useRovingFocusList', () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;
beforeEach(() => {
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root?.unmount();
});
root = null;
container?.remove();
container = null;
});
it('moves focus with vertical arrow keys while keeping one tabbable item', () => {
act(() => {
root?.render(<RovingFocusProbe data-flx="app.use-roving-focus-list-test.roving-focus-probe" />);
});
const items = getItems(container!);
expect(document.activeElement).toBe(items[0]);
expect(items.map((item) => item.tabIndex)).toEqual([0, -1, -1]);
const down = press(items[0], 'ArrowDown');
expect(down.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(items[1]);
expect(items.map((item) => item.tabIndex)).toEqual([-1, 0, -1]);
press(items[1], 'ArrowDown');
expect(document.activeElement).toBe(items[2]);
expect(items.map((item) => item.tabIndex)).toEqual([-1, -1, 0]);
press(items[2], 'ArrowUp');
expect(document.activeElement).toBe(items[1]);
expect(items.map((item) => item.tabIndex)).toEqual([-1, 0, -1]);
});
it('supports Home and End in a managed roving list', () => {
act(() => {
root?.render(<RovingFocusProbe data-flx="app.use-roving-focus-list-test.roving-focus-probe--2" />);
});
const items = getItems(container!);
press(items[0], 'End');
expect(document.activeElement).toBe(items[2]);
expect(items.map((item) => item.tabIndex)).toEqual([-1, -1, 0]);
press(items[2], 'Home');
expect(document.activeElement).toBe(items[0]);
expect(items.map((item) => item.tabIndex)).toEqual([0, -1, -1]);
});
it('can start on the container with no focused item before ArrowDown enters the list', () => {
act(() => {
root?.render(
<ManualEntryRovingFocusProbe data-flx="app.use-roving-focus-list-test.manual-entry-roving-focus-probe" />,
);
});
const menu = container?.querySelector<HTMLElement>('[role="menu"]');
const items = getItems(container!);
expect(menu).toBeInstanceOf(HTMLElement);
expect(document.activeElement).not.toBe(items[0]);
act(() => {
menu?.focus();
});
expect(document.activeElement).toBe(menu);
expect(items.map((item) => item.tabIndex)).toEqual([0, -1]);
const down = press(menu!, 'ArrowDown');
expect(down.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(items[0]);
expect(items.map((item) => item.tabIndex)).toEqual([0, -1]);
});
});
@@ -72,7 +72,9 @@ const applyTabIndices = (focusable: Array<HTMLElement>, activeIndex: number): vo
for (let i = 0; i < focusable.length; i++) {
const element = focusable[i];
const desired = i === clamped ? '0' : '-1';
element.setAttribute(ROVING_MANAGED_ATTR, '');
if (!element.hasAttribute(ROVING_MANAGED_ATTR)) {
element.setAttribute(ROVING_MANAGED_ATTR, '');
}
if (element.getAttribute('tabindex') !== desired) {
element.setAttribute('tabindex', desired);
}
@@ -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;
}
}
@@ -9,7 +9,6 @@ import {http} from '@app/features/platform/transport/RestTransport';
import {HttpError} from '@app/features/platform/types/EndpointError';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {failureCode} from '@app/features/platform/utils/ResponseInspection';
import {isDesktop} from '@app/features/ui/utils/NativeUtils';
import UserSettings from '@app/features/user/state/UserSettings';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
@@ -24,9 +23,7 @@ import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSche
import type {AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/browser';
const logger = new Logger('AuthService');
const getPlatformHeaderValue = (): 'web' | 'desktop' | 'mobile' => (isDesktop() ? 'desktop' : 'web');
const withPlatformHeader = (headers?: Record<string, string>): Record<string, string> => ({
'X-Fluxer-Platform': getPlatformHeaderValue(),
const withAuthLocaleHeader = (headers?: Record<string, string>): Record<string, string> => ({
'Accept-Language': UserSettings.getLocale(),
...(headers ?? {}),
});
@@ -247,7 +244,7 @@ export async function login({
try {
const response = await http.post<LoginResponse>(Endpoints.AUTH_LOGIN, {
body: loginBody({email, password, inviteCode}),
headers: withPlatformHeader(captchaHeaders({captchaToken, captchaType})),
headers: withAuthLocaleHeader(captchaHeaders({captchaToken, captchaType})),
});
logger.debug('Login successful', {mfa: response.body?.mfa});
return response.body;
@@ -268,7 +265,7 @@ export async function loginMfaTotp(code: string, ticket: string, inviteCode?: st
try {
const response = await http.post<TokenResponse>(Endpoints.AUTH_LOGIN_MFA_TOTP, {
body: mfaTotpBody(code, ticket, inviteCode),
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
const responseBody = response.body;
logger.debug('MFA TOTP authentication successful');
@@ -288,7 +285,7 @@ export async function loginMfaWebAuthn(
try {
const httpResponse = await http.post<TokenResponse>(Endpoints.AUTH_LOGIN_MFA_WEBAUTHN, {
body: mfaWebAuthnBody(response, challenge, ticket, inviteCode),
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
const responseBody = httpResponse.body;
logger.debug('MFA WebAuthn authentication successful');
@@ -303,7 +300,7 @@ export async function getWebAuthnMfaOptions(ticket: string): Promise<PublicKeyCr
try {
const response = await http.post<PublicKeyCredentialRequestOptionsJSON>(Endpoints.AUTH_LOGIN_MFA_WEBAUTHN_OPTIONS, {
body: ticketBody(ticket),
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
const responseBody = response.body;
logger.debug('WebAuthn MFA options retrieved');
@@ -317,7 +314,7 @@ export async function getWebAuthnMfaOptions(ticket: string): Promise<PublicKeyCr
export async function getWebAuthnAuthenticationOptions(): Promise<PublicKeyCredentialRequestOptionsJSON> {
try {
const response = await http.post<PublicKeyCredentialRequestOptionsJSON>(Endpoints.AUTH_WEBAUTHN_OPTIONS, {
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
const responseBody = response.body;
logger.debug('WebAuthn authentication options retrieved');
@@ -336,7 +333,7 @@ export async function authenticateWithWebAuthn(
try {
const httpResponse = await http.post<TokenResponse>(Endpoints.AUTH_WEBAUTHN_AUTHENTICATE, {
body: webAuthnBody(response, challenge, inviteCode),
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
const responseBody = httpResponse.body;
logger.debug('WebAuthn authentication successful');
@@ -351,7 +348,7 @@ export async function register(data: RegisterData): Promise<RegisterResponse> {
try {
const response = await http.post<RegisterResponse>(Endpoints.AUTH_REGISTER, {
body: registerBody(data),
headers: withPlatformHeader(captchaHeaders(data)),
headers: withAuthLocaleHeader(captchaHeaders(data)),
});
const responseBody = response.body;
logger.info('Registration successful');
@@ -370,7 +367,7 @@ export async function getUsernameSuggestions(globalName: string): Promise<Array<
try {
const response = await http.post<UsernameSuggestionsResponse>(Endpoints.AUTH_USERNAME_SUGGESTIONS, {
body: {global_name: globalName},
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
const responseBody = response.body;
logger.debug('Username suggestions retrieved', {count: responseBody?.suggestions?.length || 0});
@@ -389,7 +386,7 @@ export async function forgotPassword(
try {
await http.post(Endpoints.AUTH_FORGOT_PASSWORD, {
body: {email},
headers: withPlatformHeader(captchaHeaders({captchaToken, captchaType})),
headers: withAuthLocaleHeader(captchaHeaders({captchaToken, captchaType})),
});
logger.debug('Password reset email sent');
} catch (error) {
@@ -402,7 +399,7 @@ export async function validateResetPasswordToken(token: string): Promise<boolean
const response = await http.get<{
valid: boolean;
}>(Endpoints.AUTH_VALIDATE_RESET_PASSWORD_TOKEN(token), {
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
return response.body.valid;
} catch (error) {
@@ -415,7 +412,7 @@ export async function resetPassword(token: string, password: string): Promise<Re
try {
const response = await http.post<ResetPasswordResponse>(Endpoints.AUTH_RESET_PASSWORD, {
body: {token, password},
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
const responseBody = response.body;
logger.info('Password reset successful');
@@ -430,7 +427,7 @@ export async function revertEmailChange(token: string, password: string): Promis
try {
const response = await http.post<TokenResponse>(Endpoints.AUTH_EMAIL_REVERT, {
body: {token, password},
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
const responseBody = response.body;
logger.info('Email revert successful');
@@ -445,7 +442,7 @@ export async function verifyEmail(token: string): Promise<VerificationResult> {
try {
await http.post(Endpoints.AUTH_VERIFY_EMAIL, {
body: tokenBody(token),
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
logger.info('Email verification successful');
return VerificationResult.SUCCESS;
@@ -463,7 +460,7 @@ export async function verifyEmail(token: string): Promise<VerificationResult> {
export async function resendVerificationEmail(): Promise<VerificationResult> {
try {
await http.post(Endpoints.AUTH_RESEND_VERIFICATION, {
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
logger.info('Verification email resent');
return VerificationResult.SUCCESS;
@@ -486,7 +483,7 @@ export async function authorizeIp(token: string): Promise<VerificationResult> {
try {
await http.post(Endpoints.AUTH_AUTHORIZE_IP, {
body: tokenBody(token),
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
logger.info('IP authorization successful');
return VerificationResult.SUCCESS;
@@ -504,7 +501,7 @@ export async function authorizeIp(token: string): Promise<VerificationResult> {
export async function resendIpAuthorization(ticket: string): Promise<void> {
await http.post(Endpoints.AUTH_IP_AUTHORIZATION_RESEND, {
body: ticketBody(ticket),
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
}
@@ -517,7 +514,7 @@ export interface IpAuthorizationPollResult {
export async function pollIpAuthorization(ticket: string): Promise<IpAuthorizationPollResult> {
const response = await http.get<IpAuthorizationPollResult>(Endpoints.AUTH_IP_AUTHORIZATION_POLL(ticket), {
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
return response.body;
}
@@ -547,7 +544,7 @@ export async function completeDesktopHandoff({
}): Promise<void> {
await http.post(Endpoints.AUTH_HANDOFF_COMPLETE, {
body: {code, user_id: userId},
headers: withPlatformHeader({Authorization: token}),
headers: withAuthLocaleHeader({Authorization: token}),
auth: 'none',
});
}
@@ -643,7 +640,7 @@ export async function startSso({
};
const response = await http.post<SsoStartResponse>(Endpoints.AUTH_SSO_START, {
body,
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
return response.body;
}
@@ -651,7 +648,7 @@ export async function startSso({
export async function completeSso({code, state}: {code: string; state: string}): Promise<SsoCompleteResponse> {
const response = await http.post<SsoCompleteResponse>(Endpoints.AUTH_SSO_COMPLETE, {
body: {code, state},
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
return response.body;
}
@@ -1,6 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import type {DesktopHandoffInfoResponse} from '@app/features/auth/commands/AuthenticationCommands';
import type {DesktopHandoffMode} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
import styles from '@app/features/auth/flow/HandoffApprovalFlow.module.css';
@@ -18,11 +17,6 @@ const SIGN_IN_CODE_DESCRIPTOR = msg({
message: 'Sign-in code',
comment: 'Short label in the authentication handoff approval flow. Keep the tone plain and specific.',
});
const PRODUCT_DESKTOP_DESCRIPTOR = msg({
message: '{productName} Desktop',
comment:
'Display name for the product desktop client in the authentication handoff approval flow when the raw client name is Electron. Preserve {productName}; it is inserted by code.',
});
const CODE_LENGTH = 12;
const VALID_CODE_PATTERN = /^[A-Za-z0-9]{12}$/;
@@ -53,10 +47,6 @@ function formatLocation(location: {
return parts.length > 0 ? parts.join(', ') : null;
}
function isElectronClientLabel(label: string): boolean {
return label.trim().toLowerCase() === 'electron';
}
interface HandoffApprovalFlowProps {
mode: DesktopHandoffMode;
error: string | null;
@@ -164,11 +154,6 @@ export function HandoffApprovalFlow({
const os = clientInfo?.os ?? null;
const location = clientInfo?.location ? formatLocation(clientInfo.location) : null;
const hasAnyDeviceInfo = Boolean(platform || os || location);
const platformLabel = platform
? isElectronClientLabel(platform)
? i18n._(PRODUCT_DESKTOP_DESCRIPTOR, {productName: PRODUCT_NAME})
: platform
: null;
return (
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--4">
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title--3">
@@ -183,13 +168,13 @@ export function HandoffApprovalFlow({
</p>
{hasAnyDeviceInfo ? (
<div className={styles.deviceCard} data-flx="auth.flow.handoff-approval-flow.device-card">
{platformLabel ? (
{platform ? (
<div className={styles.deviceRow} data-flx="auth.flow.handoff-approval-flow.device-row">
<span className={styles.deviceLabel} data-flx="auth.flow.handoff-approval-flow.device-label">
<Trans>Platform</Trans>
</span>
<span className={styles.deviceValue} data-flx="auth.flow.handoff-approval-flow.device-value">
{platformLabel}
{platform}
</span>
</div>
) : null}
@@ -7,6 +7,8 @@ export class AuthSession {
readonly approxLastUsedAt: Date | null;
readonly clientOs: string | null;
readonly clientPlatform: string | null;
readonly clientBrowser: string | null;
readonly clientDevice: 'mobile' | 'desktop';
readonly clientLocation: string | null;
readonly maskedIp: string | null;
readonly isCurrent: boolean;
@@ -18,6 +20,8 @@ export class AuthSession {
this.clientInfo = data.client_info ?? null;
this.clientOs = this.clientInfo?.os ?? null;
this.clientPlatform = this.clientInfo?.platform ?? null;
this.clientBrowser = this.clientInfo?.browser ?? null;
this.clientDevice = this.clientInfo?.device ?? 'desktop';
this.clientLocation = getLocationLabel(this.clientInfo?.location ?? null);
this.maskedIp = data.masked_ip ?? null;
this.isCurrent = data.current;
@@ -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,
@@ -16,9 +16,8 @@ import {
EDIT_MESSAGE_DESCRIPTOR,
EMOJIS_DESCRIPTOR,
} from '@app/features/i18n/utils/CommonMessageDescriptors';
import Keybind from '@app/features/input/state/InputKeybind';
import {LexicalRichInput, type LexicalRichInputHandle} from '@app/features/lexical/composer/LexicalRichInput';
import {doesEventMatchShortcut, useMarkdownKeybinds} from '@app/features/messaging/hooks/useMarkdownKeybinds';
import {useMarkdownKeybinds} from '@app/features/messaging/hooks/useMarkdownKeybinds';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import MessageEdit from '@app/features/messaging/state/MessageEdit';
import {applyMarkdownSegments} from '@app/features/messaging/utils/MarkdownToSegmentUtils';
@@ -155,16 +154,6 @@ export const EditingMessageInput = observer(
if (event.defaultPrevented) {
return;
}
const composer = composerRef.current;
const selection = composer == null ? null : composer.getSelection();
const hasSelectionRange = selection != null && selection.start !== selection.end;
const inboxCombo = Keybind.getByAction('chat_toggle_inbox').combo;
if (doesEventMatchShortcut(event, inboxCombo) && !hasSelectionRange && actualContent.trim().length === 0) {
event.preventDefault();
event.stopPropagation();
ComponentDispatch.dispatch('INBOX_OPEN');
return;
}
if (event.key === 'Escape' && !event.shiftKey && !event.defaultPrevented && !event.nativeEvent.isComposing) {
event.preventDefault();
event.stopPropagation();
@@ -52,8 +52,7 @@ import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet';
import GuildGuilds from '@app/features/guild/state/Guilds';
import {CANCEL_DESCRIPTOR, CONTINUE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import Keybind from '@app/features/input/state/InputKeybind';
import type {ComposerHandle, ComposerSelectionRange} from '@app/features/lexical/composer/ComposerHandle';
import type {ComposerHandle} from '@app/features/lexical/composer/ComposerHandle';
import {insertComposerEmoji} from '@app/features/lexical/composer/ComposerInsertion';
import {LexicalComposerInput} from '@app/features/lexical/composer/LexicalComposerInput';
import {
@@ -69,7 +68,7 @@ import {showAttachmentPermissionDeniedModal} from '@app/features/messaging/compo
import {FileSizeTooLargeModal} from '@app/features/messaging/components/alerts/FileSizeTooLargeModal';
import {TooManyAttachmentsModal} from '@app/features/messaging/components/alerts/TooManyAttachmentsModal';
import {useTextareaAttachments} from '@app/features/messaging/hooks/useCloudUpload';
import {doesEventMatchShortcut, useMarkdownKeybinds} from '@app/features/messaging/hooks/useMarkdownKeybinds';
import {useMarkdownKeybinds} from '@app/features/messaging/hooks/useMarkdownKeybinds';
import {type SendMessageFunction, useMessageSubmission} from '@app/features/messaging/hooks/useMessageSubmission';
import {useTextareaDraftAndTyping} from '@app/features/messaging/hooks/useTextareaDraftAndTyping';
import {useTextareaEditing} from '@app/features/messaging/hooks/useTextareaEditing';
@@ -848,22 +847,9 @@ export const LexicalChannelTextareaContent = observer(
}, [channel.guildId]);
const handleEditorKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
const handle = handleRef.current;
let selection: ComposerSelectionRange | null = null;
if (handle !== null) {
selection = handle.getSelection();
}
const hasSelectionRange = selection ? selection.start !== selection.end : false;
const inboxCombo = Keybind.getByAction('chat_toggle_inbox').combo;
if (doesEventMatchShortcut(event, inboxCombo) && !hasSelectionRange && value.trim().length === 0) {
event.preventDefault();
event.stopPropagation();
ComponentDispatch.dispatch('INBOX_OPEN');
return;
}
handleEscapeKey(event);
},
[handleEscapeKey, value],
[handleEscapeKey],
);
const handleSubmit = useCallback(() => {
if (!canSubmit) {
@@ -1389,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}
@@ -67,7 +67,6 @@ import {Popout} from '@app/features/ui/popover/PopoverPopout';
import ContextMenu from '@app/features/ui/state/ContextMenu';
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import {canUseWindowFocusedActivationClick} from '@app/features/ui/utils/WindowFocusInteractionGuard';
import UserSettings from '@app/features/user/state/UserSettings';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import {MessageStates} from '@fluxer/constants/src/ChannelConstants';
@@ -187,14 +186,6 @@ const useShiftKey = (enabled: boolean) => {
}, [enabled]);
return useSyncExternalStore(subscribe, getSnapshot, shiftKeyManager.getServerSnapshot);
};
const suppressBlockedActivationClick = (event: React.SyntheticEvent): boolean => {
if (canUseWindowFocusedActivationClick()) {
return false;
}
event.preventDefault();
event.stopPropagation();
return true;
};
interface MessageActionBarButtonProps {
label: string;
@@ -211,9 +202,6 @@ const MessageActionBarButton = React.forwardRef<HTMLButtonElement, MessageAction
({label, icon, onClick, onPointerDownCapture, danger, isActive, hidden, dataAction}, ref) => {
const handleClick = useCallback(
(event: React.MouseEvent | React.KeyboardEvent) => {
if (suppressBlockedActivationClick(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
onClick?.(event);
@@ -222,9 +210,6 @@ const MessageActionBarButton = React.forwardRef<HTMLButtonElement, MessageAction
);
const handlePointerDownCapture = useCallback(
(event: React.PointerEvent) => {
if (suppressBlockedActivationClick(event)) {
return;
}
onPointerDownCapture?.(event);
},
[onPointerDownCapture],
@@ -275,9 +260,6 @@ const QuickReactionButton = observer(
const {url: displayUrl} = getEmojiDisplayData(emoji);
const handleClick = useCallback(
(event: React.MouseEvent | React.KeyboardEvent) => {
if (suppressBlockedActivationClick(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
EmojiPickerCommands.trackEmojiUsage(emoji);
@@ -700,7 +682,6 @@ export const MessageActionBarCore: React.FC<MessageActionBarCoreProps> = observe
uniqueId={`emoji_picker-actionbar-${message.id}`}
shouldAutoUpdate={false}
animationType="none"
shouldOpenOnClick={() => canUseWindowFocusedActivationClick()}
onOpen={handleEmojiPickerOpen}
onClose={handleEmojiPickerClose}
data-flx="channel.message-action-bar.message-action-bar-core.popout"
@@ -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,
@@ -21,6 +21,7 @@ import {
reduceGIFVRenderState,
} from '@app/features/channel/components/embeds/media/GIFVRenderStateMachine';
import {GifIndicator} from '@app/features/channel/components/embeds/media/GifIndicator';
import {useGifViewportGate} from '@app/features/channel/components/embeds/media/GifViewportGate';
import {getMediaButtonVisibility} from '@app/features/channel/components/embeds/media/MediaButtonUtils';
import {MediaContainer, shouldShowOverlays} from '@app/features/channel/components/embeds/media/MediaContainer';
import type {BaseMediaProps} from '@app/features/channel/components/embeds/media/MediaTypes';
@@ -985,9 +986,15 @@ export const EmbedGif: FC<
const imgRef = useRef<HTMLImageElement>(null);
const animatedImageRef = useRef<HTMLImageElement>(null);
const freezeCanvasRef = useRef<HTMLCanvasElement>(null);
const {ref: visibilityRef, isNearViewport} = useNearViewport<HTMLDivElement>({
disabled: !isMobile,
const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, channelId);
const {
ref: visibilityRef,
loadMedia: shouldLoadMedia,
animate: shouldAnimate,
} = useGifViewportGate<HTMLDivElement>({
element: containerElement,
rememberKey: proxyURL,
shouldBlur,
});
const setContainerRef = useCallback(
(node: HTMLDivElement | null) => {
@@ -1018,8 +1025,6 @@ export const EmbedGif: FC<
Math.round(displayHeight * 2),
);
const animatedImageSourceRef = useRef(optimizedAnimatedURL);
const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, channelId);
const shouldLoadMedia = isNearViewport && !shouldBlur;
const {
loaded,
error,
@@ -1049,7 +1054,7 @@ export const EmbedGif: FC<
status: ImageCacheUtils.hasImage(optimizedAnimatedURL) ? 'ready' : 'idle',
});
const [decoderRequested, setDecoderRequested] = useState(() => getAnimatedMediaPlaybackAllowed());
const shouldUseDecoder = shouldLoadMedia && hasStartedAnimating;
const shouldUseDecoder = shouldAnimate && hasStartedAnimating;
const decoderPlaying =
shouldUseDecoder && decoderRequested && animatedMediaPlaybackAllowed && (gifAutoPlay || isHoveredState);
const decoderState = useAnimatedImageDecoder({
@@ -1061,7 +1066,7 @@ export const EmbedGif: FC<
const useDecoder =
shouldUseDecoder && decoderRequested && decoderState.supported && hasStartedAnimating && !decoderState.error;
const decoderCanvasVisible = useDecoder && decoderState.loaded;
const shouldRenderFreezeFrame = !decoderCanvasVisible && (gifAutoPlay || hasStartedAnimating);
const shouldRenderFreezeFrame = shouldAnimate && !decoderCanvasVisible && (gifAutoPlay || hasStartedAnimating);
const setAnimatedImageRef = useCallback(
(node: HTMLImageElement | null) => {
animatedImageRef.current = node;
@@ -1406,7 +1411,7 @@ export const EmbedGif: FC<
...(aspectRatio ? {aspectRatio} : {}),
} as React.CSSProperties;
const shouldUseAnimatedImage =
shouldLoadMedia &&
shouldAnimate &&
animatedMediaPlaybackAllowed &&
!decoderCanvasVisible &&
(gifAutoPlay || (hasStartedAnimating && isHoveredState));
@@ -356,9 +356,9 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
() => {
if (!active) return;
setLoadedPosterSrc(posterSrc);
const image = ImageCacheUtils.getImage(posterSrc);
if (active && image && image.naturalWidth > 0 && image.naturalHeight > 0) {
setPosterNaturalDimensions({src: posterSrc, width: image.naturalWidth, height: image.naturalHeight});
const posterSize = ImageCacheUtils.getImageSize(posterSrc);
if (active && posterSize) {
setPosterNaturalDimensions({src: posterSrc, width: posterSize.width, height: posterSize.height});
}
},
() => {
@@ -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%',
}}
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {useNearViewport} from '@app/features/messaging/hooks/useNearViewport';
import {observeIntersection} from '@app/features/platform/utils/SharedIntersectionObserver';
import {useEffect, useState} from 'react';
const ANIMATION_ROOT_MARGIN = '600px 0px';
export interface GifViewportGate {
loadMedia: boolean;
animate: boolean;
}
export function resolveGifViewportGate({
isNearViewport,
isInViewport,
shouldBlur,
}: {
isNearViewport: boolean;
isInViewport: boolean;
shouldBlur: boolean;
}): GifViewportGate {
if (shouldBlur) return {loadMedia: false, animate: false};
return {loadMedia: isNearViewport, animate: isNearViewport && isInViewport};
}
export function useGifViewportGate<T extends Element>({
element,
rememberKey,
shouldBlur,
}: {
element: Element | null;
rememberKey: string;
shouldBlur: boolean;
}): GifViewportGate & {ref: (node: T | null) => void} {
const {ref, isNearViewport} = useNearViewport<T>({rememberKey});
const [isInViewport, setIsInViewport] = useState(() => typeof IntersectionObserver === 'undefined');
useEffect(() => {
if (typeof IntersectionObserver === 'undefined') {
setIsInViewport(true);
return;
}
if (element == null) {
setIsInViewport(false);
return;
}
return observeIntersection(
element,
(entry) => {
setIsInViewport(entry.isIntersecting || entry.intersectionRatio > 0);
},
{rootMargin: ANIMATION_ROOT_MARGIN},
);
}, [element]);
return {ref, ...resolveGifViewportGate({isNearViewport, isInViewport, shouldBlur})};
}
@@ -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}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {trackedRecipientNameKey} from '@app/features/channel/state/ChannelDisplayNameTracking';
import {onLocaleChange} from '@app/features/i18n/utils/LocaleChangeListener';
import {deferUntilModulesLoaded} from '@app/features/platform/utils/DeferUntilModulesLoaded';
import {noteText} from '@app/features/theme/fonts/ScriptFontLoader';
@@ -48,12 +49,8 @@ class ChannelDisplayName {
deferUntilModulesLoaded(() => {
reaction(
() => {
if (!Users) return [];
return Users.usersList.map((user) => ({
id: user.id,
username: user.username,
globalName: user.globalName,
}));
if (!Users) return '';
return trackedRecipientNameKey(this.channelSnapshots, Users.users);
},
() => this.recomputeAll(),
);
@@ -0,0 +1,129 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type TrackedRecipientSnapshot,
type TrackedRecipientUser,
trackedRecipientNameKey,
} from '@app/features/channel/state/ChannelDisplayNameTracking';
import {autorun, observable, runInAction} from 'mobx';
import {describe, expect, it} from 'vitest';
const snapshots = (entries: Record<string, ReadonlyArray<string>>): ReadonlyMap<string, TrackedRecipientSnapshot> =>
new Map(Object.entries(entries).map(([id, recipientIds]) => [id, {recipientIds}]));
const userTable = (count: number): Record<string, TrackedRecipientUser> => {
const users: Record<string, TrackedRecipientUser> = {};
for (let i = 0; i < count; i++) {
users[`u${i}`] = {username: `user${i}`, globalName: null};
}
return users;
};
/** Wraps a user table so we can count exactly which keys get read. */
const countingUsers = (
users: Record<string, TrackedRecipientUser | undefined>,
): {proxy: Record<string, TrackedRecipientUser | undefined>; reads: Array<string>} => {
const reads: Array<string> = [];
const proxy = new Proxy(users, {
get(target, key, receiver) {
if (typeof key === 'string') reads.push(key);
return Reflect.get(target, key, receiver);
},
});
return {proxy, reads};
};
describe('trackedRecipientNameKey', () => {
it('reads only the recipients of tracked group DMs, not the whole user cache', () => {
const {proxy, reads} = countingUsers(userTable(10_000));
trackedRecipientNameKey(snapshots({c1: ['u1', 'u2'], c2: ['u3']}), proxy);
// The cost must be a function of tracked recipients, never of cache size.
expect(reads).toEqual(['u1', 'u2', 'u3']);
});
it('cost is independent of how large the user cache grows', () => {
const small = countingUsers(userTable(10));
const huge = countingUsers(userTable(100_000));
const tracked = snapshots({c1: ['u1', 'u2']});
trackedRecipientNameKey(tracked, small.proxy);
trackedRecipientNameKey(tracked, huge.proxy);
expect(small.reads.length).toBe(2);
expect(huge.reads.length).toBe(2);
});
it('changes when a tracked recipient is renamed', () => {
const users = userTable(3);
const tracked = snapshots({c1: ['u1']});
const before = trackedRecipientNameKey(tracked, users);
users.u1 = {username: 'renamed', globalName: null};
expect(trackedRecipientNameKey(tracked, users)).not.toBe(before);
});
it('does not change when an untracked user is renamed', () => {
const users = userTable(3);
const tracked = snapshots({c1: ['u1']});
const before = trackedRecipientNameKey(tracked, users);
users.u2 = {username: 'unrelated-change', globalName: 'also changed'};
expect(trackedRecipientNameKey(tracked, users)).toBe(before);
});
it('distinguishes a missing recipient from one that arrives later', () => {
const users: Record<string, TrackedRecipientUser | undefined> = {};
const tracked = snapshots({c1: ['u1']});
const before = trackedRecipientNameKey(tracked, users);
users.u1 = {username: 'arrived', globalName: null};
expect(trackedRecipientNameKey(tracked, users)).not.toBe(before);
});
it('is not confused by usernames containing the separator characters', () => {
const tracked = snapshots({c1: ['a', 'b']});
const collidingLeft: Record<string, TrackedRecipientUser> = {
a: {username: 'x", "y', globalName: null},
b: {username: '', globalName: null},
};
const collidingRight: Record<string, TrackedRecipientUser> = {
a: {username: '', globalName: null},
b: {username: 'x", "y', globalName: null},
};
expect(trackedRecipientNameKey(tracked, collidingLeft)).not.toBe(trackedRecipientNameKey(tracked, collidingRight));
});
it('drives a MobX reaction on tracked renames but stays quiet on untracked ones', () => {
const users = observable.object<Record<string, TrackedRecipientUser | undefined>>({
u1: {username: 'one', globalName: null},
u2: {username: 'two', globalName: null},
});
const tracked = snapshots({c1: ['u1']});
let runs = 0;
const dispose = autorun(() => {
trackedRecipientNameKey(tracked, users);
runs++;
});
expect(runs).toBe(1);
// An untracked user changing must not wake the reaction.
runInAction(() => {
users.u2 = {username: 'two-renamed', globalName: null};
});
expect(runs).toBe(1);
// A tracked user changing must.
runInAction(() => {
users.u1 = {username: 'one-renamed', globalName: null};
});
expect(runs).toBe(2);
dispose();
});
});
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface TrackedRecipientSnapshot {
readonly recipientIds: ReadonlyArray<string>;
}
export interface TrackedRecipientUser {
readonly username: string;
readonly globalName?: string | null;
}
export const trackedRecipientNameKey = (
snapshots: ReadonlyMap<string, TrackedRecipientSnapshot>,
users: Record<string, TrackedRecipientUser | undefined>,
): string => {
const parts: Array<[string, string, string]> = [];
for (const snapshot of snapshots.values()) {
for (const recipientId of snapshot.recipientIds) {
const user = users[recipientId];
parts.push([recipientId, user?.username ?? '', user?.globalName ?? '']);
}
}
return JSON.stringify(parts);
};

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