mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 12:12:25 +09:00
Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c920f966e | ||
|
|
2b1de38949 | ||
|
|
d5daf61dbd | ||
|
|
06c42ce02f | ||
|
|
4f21430880 | ||
|
|
9731bac40f | ||
|
|
b144e650f2 | ||
|
|
ef6536644c | ||
|
|
17eab43245 | ||
|
|
fbd7f1e3b8 | ||
|
|
25af7516a4 | ||
|
|
c020eded31 | ||
|
|
5331c0216a | ||
|
|
528777926c | ||
|
|
47b5c3d4f0 | ||
|
|
d9bfca66d6 | ||
|
|
ded51b65d3 | ||
|
|
ddc8837d4f | ||
|
|
df16957c95 | ||
|
|
f38b19d4bd | ||
|
|
f7cd4f2c74 | ||
|
|
a078392888 | ||
|
|
3060f23f8c | ||
|
|
6a5f0d4d29 | ||
|
|
91d989dc7c | ||
|
|
7c82804df6 | ||
|
|
b7de83f7fc | ||
|
|
97bd022bee | ||
|
|
62324df039 | ||
|
|
9f78b3d9a6 | ||
|
|
362a89f2b2 | ||
|
|
ce41960fcd | ||
|
|
2083eaddd6 | ||
|
|
d398ebc44b | ||
|
|
59887ad404 | ||
|
|
5aa283e8e0 | ||
|
|
d9ed256a4b | ||
|
|
a297f89b83 | ||
|
|
4a16921242 | ||
|
|
a6f83c4fb1 | ||
|
|
7b5c82c6cf | ||
|
|
30a61ce90f | ||
|
|
22bc2cab74 | ||
|
|
53df9a0d6d | ||
|
|
f9b7ec4d0b | ||
|
|
569abf57b7 | ||
|
|
ae8e32d809 | ||
|
|
a81b1abec7 | ||
|
|
8836565c32 | ||
|
|
a1b595d52f | ||
|
|
abb71ed558 | ||
|
|
c006d413ac | ||
|
|
fa11acae15 | ||
|
|
300f467ad0 | ||
|
|
698469fa96 | ||
|
|
591e9fe2ba | ||
|
|
d148b4e5b7 | ||
|
|
324f333bb5 | ||
|
|
9b0b703c9d | ||
|
|
5660972c71 | ||
|
|
b4a5eb77a8 | ||
|
|
4bbfee4cec | ||
|
|
9e89539f0a | ||
|
|
fa71eb8682 | ||
|
|
49b7127bc7 | ||
|
|
9cb8812be0 | ||
|
|
7d82d188a0 | ||
|
|
5ce5669f17 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Generated
+3
-50
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+1
@@ -332,6 +332,7 @@ export const GuildListItemPresentation = forwardRef<
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-guild-list-focus-item="true"
|
||||
data-guild-id={guild.id}
|
||||
onLongPress={onLongPress}
|
||||
disabled={false}
|
||||
data-flx="app.sidebar-nav.guild-list-item-presentation.guild-list-item.click"
|
||||
|
||||
@@ -5,7 +5,7 @@ import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {clsx} from 'clsx';
|
||||
import type {CSSProperties, FC, ReactNode} from 'react';
|
||||
import type {FC, ReactNode} from 'react';
|
||||
|
||||
const REVEAL_SPOILER_DESCRIPTOR = msg({
|
||||
message: 'Reveal spoiler',
|
||||
@@ -23,24 +23,14 @@ interface SpoilerOverlayProps {
|
||||
label?: string;
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const SpoilerOverlay: FC<SpoilerOverlayProps> = ({
|
||||
hidden,
|
||||
onReveal,
|
||||
children,
|
||||
label,
|
||||
inline,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
export const SpoilerOverlay: FC<SpoilerOverlayProps> = ({hidden, onReveal, children, label, inline, className}) => {
|
||||
const {i18n} = useLingui();
|
||||
const ariaLabel = label ?? i18n._(REVEAL_SPOILER_DESCRIPTOR);
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.container, inline && styles.inline, hidden && styles.hidden, className)}
|
||||
style={style}
|
||||
data-flx="app.spoiler-overlay.container"
|
||||
>
|
||||
<div className={styles.content} aria-hidden={hidden} data-flx="app.spoiler-overlay.content">
|
||||
|
||||
@@ -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
|
||||
|
||||
+20
-19
@@ -8,6 +8,11 @@ import {Message as MessageComponent} from '@app/features/channel/components/Chan
|
||||
import {CollapsedMessageVisibilityProvider} from '@app/features/channel/components/CollapsedMessageVisibilityContext';
|
||||
import {MessageActionBottomSheet} from '@app/features/channel/components/MessageActionBottomSheet';
|
||||
import type {MessageGroupRenderWrapperProps} from '@app/features/channel/components/MessageGroup';
|
||||
import {
|
||||
buildSearchResultGroups,
|
||||
buildSearchResultGroupsByMessageId,
|
||||
countSearchResultChannels,
|
||||
} from '@app/features/channel/components/SearchResultGrouping';
|
||||
import {SearchResultMessageList} from '@app/features/channel/components/SearchResultMessageList';
|
||||
import {type ChannelSearchFilters, useChannelSearch} from '@app/features/channel/hooks/useChannelSearch';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
@@ -216,22 +221,17 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
|
||||
() => new Map(successChannels.map((searchChannel) => [searchChannel.id, searchChannel])),
|
||||
[successChannels],
|
||||
);
|
||||
const messagesByChannel = React.useMemo(() => {
|
||||
const grouped = new Map<string, Array<Message>>();
|
||||
for (const message of successResults) {
|
||||
if (!grouped.has(message.channelId)) {
|
||||
grouped.set(message.channelId, []);
|
||||
}
|
||||
grouped.get(message.channelId)!.push(message);
|
||||
}
|
||||
return grouped;
|
||||
}, [successResults]);
|
||||
const resultGroups = React.useMemo(() => buildSearchResultGroups(successResults), [successResults]);
|
||||
const resultGroupsByMessageId = React.useMemo(
|
||||
() => buildSearchResultGroupsByMessageId(resultGroups),
|
||||
[resultGroups],
|
||||
);
|
||||
const spammerOverrideVersion = LocalUserSpamOverride.version;
|
||||
const collapsedMessageVisibility = React.useMemo(
|
||||
() => ({
|
||||
isMessageRevealed: (message: Message) => {
|
||||
const channelMessages = messagesByChannel.get(message.channelId);
|
||||
if (!channelMessages) {
|
||||
const resultGroup = resultGroupsByMessageId.get(message.id);
|
||||
if (!resultGroup) {
|
||||
return false;
|
||||
}
|
||||
const messageChannel = searchChannelsById.get(message.channelId) ?? Channels.getChannel(message.channelId);
|
||||
@@ -240,14 +240,14 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
|
||||
}
|
||||
const groupKey = getCollapsedMessageGroupKey({
|
||||
channel: messageChannel,
|
||||
messages: channelMessages,
|
||||
messages: resultGroup.messages,
|
||||
messageId: message.id,
|
||||
treatSpam: true,
|
||||
});
|
||||
return groupKey != null && revealedGroupKeys.has(groupKey);
|
||||
},
|
||||
}),
|
||||
[messagesByChannel, revealedGroupKeys, searchChannelsById, spammerOverrideVersion],
|
||||
[resultGroupsByMessageId, revealedGroupKeys, searchChannelsById, spammerOverrideVersion],
|
||||
);
|
||||
const handleCollapsedGroupRevealChange = useCallback((groupKey: string, revealed: boolean) => {
|
||||
setRevealedGroupKeys((current) => {
|
||||
@@ -598,7 +598,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
|
||||
);
|
||||
}
|
||||
const totalPages = Math.max(1, Math.ceil(total / hitsPerPage));
|
||||
const hasMultipleChannels = messagesByChannel.size > 1;
|
||||
const hasMultipleChannels = countSearchResultChannels(resultGroups) > 1;
|
||||
return (
|
||||
<>
|
||||
<CollapsedMessageVisibilityProvider
|
||||
@@ -613,8 +613,9 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
|
||||
data-message-selection-root="true"
|
||||
data-flx="channel.channel-search-bottom-sheet.render-content.results-scroller"
|
||||
>
|
||||
{Array.from(messagesByChannel.entries()).map(([channelId, messages]) => {
|
||||
const messageChannel = searchChannelsById.get(channelId) ?? Channels.getChannel(channelId);
|
||||
{resultGroups.map((resultGroup) => {
|
||||
const messageChannel =
|
||||
searchChannelsById.get(resultGroup.channelId) ?? Channels.getChannel(resultGroup.channelId);
|
||||
if (!messageChannel) {
|
||||
return null;
|
||||
}
|
||||
@@ -648,7 +649,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
|
||||
</LongPressable>
|
||||
);
|
||||
return (
|
||||
<React.Fragment key={channelId}>
|
||||
<React.Fragment key={resultGroup.key}>
|
||||
{hasMultipleChannels && (
|
||||
<div
|
||||
className={styles.channelSection}
|
||||
@@ -667,7 +668,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
|
||||
)}
|
||||
<SearchResultMessageList
|
||||
channel={messageChannel}
|
||||
messages={messages}
|
||||
messages={resultGroup.messages}
|
||||
revealedGroupKeys={revealedGroupKeys}
|
||||
onGroupRevealChange={handleCollapsedGroupRevealChange}
|
||||
collapsedGroupClassName={styles.collapsedMessageGroup}
|
||||
|
||||
@@ -44,7 +44,6 @@ import {useVoiceCallFullscreenViewState} from '@app/features/voice/components/us
|
||||
import {VoiceCallView} from '@app/features/voice/components/VoiceCallView';
|
||||
import {VoiceE2EEIndicator} from '@app/features/voice/components/VoiceE2EEIndicator';
|
||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import {isNativeVoiceEngineSelected} from '@app/features/voice/engine/native_voice_engine/getVoiceEngine';
|
||||
import {useCompactCallExpansionState} from '@app/features/voice/hooks/useCompactCallExpansionState';
|
||||
import {usePendingVoiceConnection} from '@app/features/voice/hooks/usePendingVoiceConnection';
|
||||
import {getGuildVoiceCallExpansionKey} from '@app/features/voice/state/CompactVoiceCallHeight';
|
||||
@@ -182,7 +181,7 @@ export const GuildChannelView = observer(({channelId, guildId}: GuildChannelView
|
||||
isVoiceChannel &&
|
||||
connectedChannelId === channelId &&
|
||||
(connectedGuildId ?? null) === (channel?.guildId ?? null) &&
|
||||
(room || (isNativeVoiceEngineSelected() && MediaEngine.connected)),
|
||||
room,
|
||||
);
|
||||
const matureContentGateReason = GuildMatureContentAgree.getGateReason({channelId, guildId});
|
||||
const matureContentResolved = GuildMatureContentAgree.getResolvedContext({channelId, guildId});
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import {isNativeVoiceEngineSelected} from '@app/features/voice/engine/native_voice_engine/getVoiceEngine';
|
||||
import CallInitiator from '@app/features/voice/state/CallInitiator';
|
||||
import CallState, {type Call} from '@app/features/voice/state/CallState';
|
||||
|
||||
@@ -40,9 +39,7 @@ export function useCallHeaderState(channel?: Channel | null): CallHeaderState {
|
||||
const matchesConnectionContext = Boolean(
|
||||
channelId && MediaEngine.channelId === channelId && (MediaEngine.guildId ?? null) === normalizedGuildId,
|
||||
);
|
||||
const isDeviceInRoomForChannelCall = Boolean(
|
||||
matchesConnectionContext && (MediaEngine.room || (isNativeVoiceEngineSelected() && MediaEngine.connected)),
|
||||
);
|
||||
const isDeviceInRoomForChannelCall = Boolean(matchesConnectionContext && MediaEngine.room);
|
||||
const isDeviceConnectingToChannelCall =
|
||||
matchesConnectionContext && (MediaEngine.connecting || (MediaEngine.connected && !isDeviceInRoomForChannelCall));
|
||||
const controlsVariant: CallHeaderControlsVariant = !callExistsAndOngoing
|
||||
|
||||
@@ -113,6 +113,10 @@
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.embedIconPending {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.embedTitle {
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
BLUESKY_EMBED_MEDIA_FALLBACK_OUTER_WIDTH,
|
||||
BLUESKY_EMBED_MIN_CONTENT_WIDTH,
|
||||
calculateBlueskyMediaContainerWidth,
|
||||
calculateBlueskyOuterMaxWidth,
|
||||
EMBED_TEXT_OUTER_WIDTH,
|
||||
formatResponsiveEmbedWidth,
|
||||
} from '@app/features/channel/components/embeds/EmbedRenderUtils';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('formatResponsiveEmbedWidth', () => {
|
||||
it('caps in rem so the width tracks the root font size at any zoom', () => {
|
||||
expect(formatResponsiveEmbedWidth(EMBED_TEXT_OUTER_WIDTH)).toBe('min(100%, 32.25rem)');
|
||||
expect(formatResponsiveEmbedWidth(BLUESKY_EMBED_MEDIA_FALLBACK_OUTER_WIDTH)).toBe('min(100%, 27rem)');
|
||||
});
|
||||
|
||||
it('rounds to whole pixels before converting', () => {
|
||||
expect(formatResponsiveEmbedWidth(400.4)).toBe('min(100%, 25rem)');
|
||||
});
|
||||
|
||||
it('clamps non-positive and non-finite widths', () => {
|
||||
expect(formatResponsiveEmbedWidth(0)).toBe('min(100%, 0rem)');
|
||||
expect(formatResponsiveEmbedWidth(Number.NaN)).toBe('min(100%, 0rem)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateBlueskyOuterMaxWidth', () => {
|
||||
it('falls back to the shared text width when there is no media', () => {
|
||||
expect(calculateBlueskyOuterMaxWidth({hasMedia: false, chromeWidth: 29})).toBe(EMBED_TEXT_OUTER_WIDTH);
|
||||
});
|
||||
|
||||
it('adds chrome around the media container width', () => {
|
||||
expect(calculateBlueskyOuterMaxWidth({mediaWidth: 480, hasMedia: true, chromeWidth: 29})).toBe(509);
|
||||
});
|
||||
|
||||
it('never goes below the minimum content width', () => {
|
||||
expect(calculateBlueskyMediaContainerWidth(120)).toBe(BLUESKY_EMBED_MIN_CONTENT_WIDTH);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {MessageEmbedTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {MessageEmbed} from '@fluxer/schema/src/domains/message/EmbedSchemas';
|
||||
|
||||
@@ -28,7 +29,7 @@ export function isMediaOnlyEmbed(embed: MediaOnlyEmbedFields): boolean {
|
||||
|
||||
export const BLUESKY_EMBED_MIN_CONTENT_WIDTH = 320;
|
||||
export const BLUESKY_EMBED_MEDIA_FALLBACK_OUTER_WIDTH = 432;
|
||||
export const BLUESKY_EMBED_TEXT_OUTER_WIDTH = 516;
|
||||
export const EMBED_TEXT_OUTER_WIDTH = 516;
|
||||
const normalizePositiveWidth = (width: number): number => {
|
||||
if (!Number.isFinite(width) || width <= 0) {
|
||||
return 0;
|
||||
@@ -55,9 +56,9 @@ export function calculateBlueskyOuterMaxWidth({
|
||||
if (mediaContainerWidth !== undefined) {
|
||||
return mediaContainerWidth + normalizePositiveWidth(chromeWidth);
|
||||
}
|
||||
return hasMedia ? BLUESKY_EMBED_MEDIA_FALLBACK_OUTER_WIDTH : BLUESKY_EMBED_TEXT_OUTER_WIDTH;
|
||||
return hasMedia ? BLUESKY_EMBED_MEDIA_FALLBACK_OUTER_WIDTH : EMBED_TEXT_OUTER_WIDTH;
|
||||
}
|
||||
|
||||
export function formatResponsiveEmbedWidth(width: number): string {
|
||||
return `min(100%, ${normalizePositiveWidth(width)}px)`;
|
||||
return `min(100%, ${remFromPx(normalizePositiveWidth(width))})`;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {TextualAttachmentPreview} from '@app/features/channel/components/embeds/attachments/TextualAttachmentPreview';
|
||||
import {splitFilename} from '@app/features/channel/components/embeds/EmbedUtils';
|
||||
import {canDeleteAttachmentUtil} from '@app/features/channel/components/MessageActionUtils';
|
||||
import {ATTACHMENT_CARD_WIDTH} from '@app/features/channel/components/MessageAttachmentUtils';
|
||||
import {useMaybeMessageViewContext} from '@app/features/channel/components/MessageViewContext';
|
||||
import {DELETE_ATTACHMENT_DESCRIPTOR, DOWNLOAD_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {useDeleteAttachment} from '@app/features/messaging/hooks/useDeleteAttachment';
|
||||
@@ -181,8 +182,8 @@ export const AttachmentFile = observer(({attachment, message, isPreview}: Attach
|
||||
containerStyles = {
|
||||
display: 'grid',
|
||||
width: '100%',
|
||||
maxWidth: '400px',
|
||||
minWidth: 'min(400px, 100%)',
|
||||
maxWidth: remFromPx(ATTACHMENT_CARD_WIDTH),
|
||||
minWidth: `min(${remFromPx(ATTACHMENT_CARD_WIDTH)}, 100%)`,
|
||||
};
|
||||
}
|
||||
const handleDownload = async (e: React.MouseEvent) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import styles from '@app/features/channel/components/embeds/ChannelEmbed.module.
|
||||
import {EmbedLink} from '@app/features/channel/components/embeds/channel_embed/EmbedLink';
|
||||
import {SafeMarkdown} from '@app/features/messaging/components/markdown';
|
||||
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
|
||||
import {useCachedImageLoaded} from '@app/features/messaging/hooks/useCachedImageLoaded';
|
||||
import {buildMediaProxyURL} from '@app/features/messaging/utils/MediaProxyUtils';
|
||||
import * as DateUtils from '@app/features/user/utils/DateFormatting';
|
||||
import type {EmbedAuthor, EmbedField, EmbedFooter} from '@fluxer/schema/src/domains/message/EmbedSchemas';
|
||||
@@ -32,13 +33,14 @@ export const EmbedAuthorComponent: FC<{author?: EmbedAuthor}> = observer(({autho
|
||||
const iconSrc = author?.proxy_icon_url
|
||||
? buildMediaProxyURL(author.proxy_icon_url, animatedMediaPlaybackAllowed ? {} : {format: 'webp', animated: false})
|
||||
: undefined;
|
||||
const iconLoaded = useCachedImageLoaded(iconSrc);
|
||||
if (!author) return null;
|
||||
return (
|
||||
<div className={styles.embedAuthor} data-flx="channel.embeds.embed.embed-author-component.embed-author">
|
||||
{iconSrc && (
|
||||
<img
|
||||
alt=""
|
||||
className={styles.embedAuthorIcon}
|
||||
className={clsx(styles.embedAuthorIcon, !iconLoaded && styles.embedIconPending)}
|
||||
src={iconSrc}
|
||||
width={24}
|
||||
height={24}
|
||||
@@ -181,6 +183,7 @@ export const EmbedFooterComponent: FC<{
|
||||
const iconSrc = footer?.proxy_icon_url
|
||||
? buildMediaProxyURL(footer.proxy_icon_url, animatedMediaPlaybackAllowed ? {} : {format: 'webp', animated: false})
|
||||
: undefined;
|
||||
const iconLoaded = useCachedImageLoaded(iconSrc);
|
||||
if (!(footer || formattedTimestamp)) return null;
|
||||
return (
|
||||
<div
|
||||
@@ -190,7 +193,7 @@ export const EmbedFooterComponent: FC<{
|
||||
{iconSrc && (
|
||||
<img
|
||||
alt=""
|
||||
className={styles.embedFooterIcon}
|
||||
className={clsx(styles.embedFooterIcon, !iconLoaded && styles.embedIconPending)}
|
||||
src={iconSrc}
|
||||
width={20}
|
||||
height={20}
|
||||
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
EmbedProvider,
|
||||
EmbedTitle,
|
||||
} from '@app/features/channel/components/embeds/channel_embed/EmbedParts';
|
||||
import {
|
||||
EMBED_TEXT_OUTER_WIDTH,
|
||||
formatResponsiveEmbedWidth,
|
||||
} from '@app/features/channel/components/embeds/EmbedRenderUtils';
|
||||
import markupStyles from '@app/features/theme/styles/Markup.module.css';
|
||||
import {MessageEmbedTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {EmbedMedia} from '@fluxer/schema/src/domains/message/EmbedSchemas';
|
||||
@@ -58,7 +62,7 @@ export const RichEmbed: FC<EmbedProps> = observer(
|
||||
data-search-highlight-scope="message"
|
||||
style={{
|
||||
borderLeft: `4px solid ${getBorderColor(embed.color)}`,
|
||||
maxWidth: useNarrowWidth ? `min(100%, ${EMBED_MEDIA_MAX_WIDTH}px)` : 'min(100%, 516px)',
|
||||
maxWidth: formatResponsiveEmbedWidth(useNarrowWidth ? EMBED_MEDIA_MAX_WIDTH : EMBED_TEXT_OUTER_WIDTH),
|
||||
}}
|
||||
data-flx="channel.embeds.embed.rich-embed.embed"
|
||||
>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {deriveDefaultNameFromMessage} from '@app/features/channel/components/emb
|
||||
import styles from '@app/features/channel/components/embeds/media/EmbedAudio.module.css';
|
||||
import {getMediaButtonVisibility} from '@app/features/channel/components/embeds/media/MediaButtonUtils';
|
||||
import type {BaseMediaProps} from '@app/features/channel/components/embeds/media/MediaTypes';
|
||||
import {ATTACHMENT_CARD_WIDTH} from '@app/features/channel/components/MessageAttachmentUtils';
|
||||
import {useMaybeMessageViewContext} from '@app/features/channel/components/MessageViewContext';
|
||||
import {DELETE_ATTACHMENT_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {useDeleteAttachment} from '@app/features/messaging/hooks/useDeleteAttachment';
|
||||
@@ -130,8 +131,8 @@ const EmbedAudio: FC<EmbedAudioProps> = observer(
|
||||
: {
|
||||
display: 'grid',
|
||||
width: '100%',
|
||||
maxWidth: 'min(100%, 400px)',
|
||||
minWidth: 'min(400px, 100%)',
|
||||
maxWidth: `min(100%, ${remFromPx(ATTACHMENT_CARD_WIDTH)})`,
|
||||
minWidth: `min(${remFromPx(ATTACHMENT_CARD_WIDTH)}, 100%)`,
|
||||
};
|
||||
const {showDeleteButton, showDownloadButton} = getMediaButtonVisibility(
|
||||
canFavorite,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user