Compare commits

...
Author SHA1 Message Date
HampusandGitHub 2c8b3ff45c fix(build): build the messages and users images with scylla support (#1796) 2026-08-20 20:30:16 +02:00
HampusandGitHub 631bc2307a fix(slowmode): stop the local cooldown from outgrowing the channel setting (#1795) 2026-08-20 19:56:02 +02:00
HampusandGitHub 8f4f9a8601 refactor(media): share one external proxy url codec across every service (#1794) 2026-08-20 19:55:40 +02:00
HampusandGitHub 1c920f966e feat(media): emit external proxy urls with a readable path and extension (#1793) 2026-08-20 18:44:45 +02:00
HampusandGitHub 2b1de38949 chore(i18n): regenerate catalogs after the voice engine removal (#1791) 2026-08-20 17:45:49 +02:00
HampusandGitHub d5daf61dbd fix(voice): recalibrate stored participant and stream volumes too (#1790) 2026-08-20 17:45:17 +02:00
HampusandGitHub 06c42ce02f refactor(desktop): delete the unused rust voice module (#1789) 2026-08-20 17:44:55 +02:00
HampusandGitHub 4f21430880 refactor(voice): drop the native only surface from voice_engine_v2 and narrow the desktop bridge (#1788) 2026-08-20 17:44:37 +02:00
HampusandGitHub 9731bac40f refactor(voice): remove the native voice engine from the renderer (#1787) 2026-08-20 17:43:38 +02:00
HampusandGitHub b144e650f2 fix(voice): make deafen reach watched screen share audio (#1786) 2026-08-20 17:43:19 +02:00
HampusandGitHub ef6536644c fix(voice): stop the in call speaker slider clamping a boosted output volume (#1785) 2026-08-20 17:42:59 +02:00
HampusandGitHub 17eab43245 fix(voice): retune the remote playback leveller and measure it in float (#1784) 2026-08-20 17:42:40 +02:00
HampusandGitHub fbd7f1e3b8 fix(voice): compose participant gain in linear space and split the ceilings (#1783) 2026-08-20 17:42:21 +02:00
HampusandGitHub 25af7516a4 fix(voice): widen the volume boost leg and add a master soft clip limiter (#1782) 2026-08-20 17:42:02 +02:00
HampusandGitHub c020eded31 fix(voice): stop forcing automatic gain control off on every capture profile (#1781) 2026-08-20 17:41:44 +02:00
HampusandGitHub 5331c0216a fix(voice): keep a remote track pinned at zero volume across re-attach (#1780) 2026-08-20 17:40:53 +02:00
HampusandGitHub 528777926c fix(api): stop one unreachable community from breaking bookmarks (#1779) 2026-08-20 15:50:51 +02:00
HampusandGitHub 47b5c3d4f0 fix(app): expose the guild id on guild list items again (#1778) 2026-08-20 14:39:24 +02:00
HampusandGitHub d9bfca66d6 fix(app): keep search results in server order instead of per channel (#1777) 2026-08-20 13:10:47 +02:00
HampusandGitHub ded51b65d3 fix(app): restore the mobile voice message recorder (#1776) 2026-08-20 04:33:24 +02:00
HampusandGitHub ddc8837d4f fix(app): scale embed and attachment width caps with zoom (#1775) 2026-08-20 04:33:17 +02:00
HampusandGitHub df16957c95 fix(app): scale tooltip max-width with app zoom (#1774) 2026-08-20 04:33:10 +02:00
HampusandGitHub f38b19d4bd fix(app): let the caret move past emoji and mentions in the composer (#1768) 2026-08-19 23:41:05 +02:00
HampusandGitHub f7cd4f2c74 docs(operator): explain how to reclaim space after upgrades (#1767) 2026-08-19 23:31:15 +02:00
a078392888 chore(i18n): update public marketing catalogs (#1719)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-19 22:46:33 +02:00
HampusandGitHub 3060f23f8c chore(marketing): bump the marketing submodule pointer (#1766) 2026-08-19 22:29:10 +02:00
HampusandGitHub 6a5f0d4d29 fix(i18n): simplify the marketing translation and saved media strings (#1764) 2026-08-19 22:25:01 +02:00
HampusandGitHub 91d989dc7c fix(app): point the language notice at Weblate instead of email (#1763) 2026-08-19 22:05:39 +02:00
HampusandGitHub 7c82804df6 fix(app): stop sensitive content options overlapping in long locales (#1762) 2026-08-19 19:53:46 +02:00
HampusandGitHub b7de83f7fc fix(app): stop the Firefox microphone failure and self-mute storm (#1761) 2026-08-19 19:32:41 +02:00
HampusandGitHub 97bd022bee fix(app): render the discovery language icon at bold weight (#1760) 2026-08-19 19:08:48 +02:00
HampusandGitHub 62324df039 feat(app): add a language filter button to the discovery navbar (#1759) 2026-08-19 18:46:39 +02:00
HampusandGitHub 9f78b3d9a6 feat(app): retry failed image loads with escalating delay and connectivity waits (#1758) 2026-08-19 18:25:34 +02:00
HampusandGitHub 362a89f2b2 fix(app): keep embed icons mounted so they reserve space while loading (#1757) 2026-08-19 18:00:08 +02:00
HampusandGitHub ce41960fcd perf(app): load embed author and footer icons through the image cache (#1756) 2026-08-19 17:38:00 +02:00
HampusandGitHub 2083eaddd6 feat(app): wrap pasted links in every composer that allows masked links (#1755) 2026-08-19 17:19:39 +02:00
339 changed files with 10412 additions and 28529 deletions
Generated
+9
View File
@@ -1777,6 +1777,7 @@ dependencies = [
"axum",
"base64",
"clap",
"fluxer_common",
"hmac 0.13.0",
"hyper 1.10.1",
"hyper-util",
@@ -1800,6 +1801,7 @@ dependencies = [
"anyhow",
"base64",
"fluxer-svc",
"fluxer_common",
"hmac 0.13.0",
"moka",
"reqwest",
@@ -1836,6 +1838,7 @@ dependencies = [
"cc",
"clap",
"criterion",
"fluxer_common",
"hex",
"hmac 0.13.0",
"http 1.4.2",
@@ -1875,6 +1878,7 @@ dependencies = [
"chrono",
"criterion",
"fluxer-svc",
"fluxer_common",
"fluxer_markdown_parser",
"futures",
"hmac 0.13.0",
@@ -1943,6 +1947,7 @@ dependencies = [
"encoding_rs",
"entities",
"fluxer-svc",
"fluxer_common",
"hmac 0.13.0",
"infer",
"moka",
@@ -2038,10 +2043,14 @@ dependencies = [
"aws-credential-types",
"aws-sigv4",
"axum",
"base64",
"hmac 0.13.0",
"maxminddb",
"moka",
"reqwest",
"serde_json",
"sha2 0.11.0",
"thiserror",
"time",
"tracing",
"urlencoding",
@@ -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/'],
},
},
});
@@ -929,9 +929,8 @@ export class MessageSendService {
algorithm: 'leaky_bucket',
});
if (!slowmodeResult.allowed) {
const retryAfter = Math.max(0, slowmodeResult.resetTime.getTime() - Date.now());
throw new SlowmodeRateLimitError({
retryAfter,
retryAfter: slowmodeResult.retryAfter,
retryAfterDecimal: slowmodeResult.retryAfterDecimal,
});
}
@@ -52,4 +52,27 @@ describe('Slowmode Enforcement', () => {
expect(messages).toHaveLength(1);
expect(messages[0]?.id).toBe(firstMessage.id);
});
it('reports the slowmode retry window in seconds on the Retry-After header', async () => {
const rateLimitPerUser = 5;
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const member = members[0]!;
await ensureSessionStarted(harness, member.token);
const channel = await createChannel(harness, owner.token, guild.id, 'slowmode-channel');
await updateChannel(harness, owner.token, channel.id, {
rate_limit_per_user: rateLimitPerUser,
});
await sendChannelMessage(harness, member.token, channel.id, 'first message');
const {response, json} = await createBuilder<{code: string; retry_after: number}>(harness, member.token)
.post(`/channels/${channel.id}/messages`)
.body({content: 'second message'})
.expect(400, APIErrorCodes.SLOWMODE_RATE_LIMITED)
.executeWithResponse();
const headerRetryAfter = Number(response.headers.get('Retry-After'));
expect(Number.isInteger(headerRetryAfter)).toBe(true);
expect(headerRetryAfter).toBeGreaterThan(0);
expect(headerRetryAfter).toBeLessThanOrEqual(rateLimitPerUser);
expect(json.retry_after).toBeGreaterThan(0);
expect(json.retry_after).toBeLessThanOrEqual(rateLimitPerUser);
expect(headerRetryAfter - json.retry_after).toBeLessThan(1);
});
});
@@ -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;
@@ -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);
}
@@ -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">
@@ -28,6 +28,8 @@ export const SUPPORT_EMAIL = '[email protected]';
export const SUPPORT_EMAIL_MAILTO = `mailto:${SUPPORT_EMAIL}`;
export const I18N_EMAIL = '[email protected]';
export const I18N_EMAIL_MAILTO = `mailto:${I18N_EMAIL}`;
export const I18N_WEBLATE_DOMAIN = 'weblate.fluxer.tools';
export const I18N_WEBLATE_URL = `https://${I18N_WEBLATE_DOMAIN}`;
export const EXAMPLE_DOMAIN = 'example.com';
export const EXAMPLE_URL = `https://${EXAMPLE_DOMAIN}`;
export const EXAMPLE_CALLBACK_URL = `${EXAMPLE_URL}/callback`;
@@ -28,6 +28,10 @@ import {
SearchIndexingState,
} from '@app/features/channel/components/channel_search_results/SearchResultsStateViews';
import type {MessageGroupRenderWrapperProps} from '@app/features/channel/components/MessageGroup';
import {
buildSearchResultGroups,
buildSearchResultGroupsByMessageId,
} from '@app/features/channel/components/SearchResultGrouping';
import {SearchResultMessageList} from '@app/features/channel/components/SearchResultMessageList';
import {areSegmentsEqual} from '@app/features/channel/components/SearchResultsUtils';
import {DEFAULT_SCOPE_VALUE, getScopeOptionsForChannel} from '@app/features/channel/components/SearchScopeOptions';
@@ -134,35 +138,27 @@ export const ChannelSearchResults = observer(
() => new Map(successChannels.map((searchChannel) => [searchChannel.id, searchChannel])),
[successChannels],
);
const messagesByChannel = useMemo(() => {
const grouped = new Map<string, Array<Message>>();
for (const message of successResults) {
if (!grouped.has(message.channelId)) {
grouped.set(message.channelId, []);
}
grouped.get(message.channelId)!.push(message);
}
return grouped;
}, [successResults]);
const resultGroups = useMemo(() => buildSearchResultGroups(successResults), [successResults]);
const resultGroupsByMessageId = useMemo(() => buildSearchResultGroupsByMessageId(resultGroups), [resultGroups]);
const onCopySelectedMessages = useMessageSelectionCopyForMessages<HTMLDivElement>(successResults);
const spammerOverrideVersion = LocalUserSpamOverride.version;
const collapsedMessageVisibility = useMemo(
() => ({
isMessageRevealed: (message: Message) => {
const channelMessages = messagesByChannel.get(message.channelId);
if (!channelMessages) return false;
const resultGroup = resultGroupsByMessageId.get(message.id);
if (!resultGroup) return false;
const messageChannel = searchChannelsById.get(message.channelId) ?? Channels.getChannel(message.channelId);
if (!messageChannel) return false;
const groupKey = getCollapsedMessageGroupKey({
channel: messageChannel,
messages: channelMessages,
messages: resultGroup.messages,
messageId: message.id,
treatSpam: true,
});
return groupKey != null && revealedGroupKeys.has(groupKey);
},
}),
[messagesByChannel, revealedGroupKeys, searchChannelsById, spammerOverrideVersion],
[resultGroupsByMessageId, revealedGroupKeys, searchChannelsById, spammerOverrideVersion],
);
const handleCollapsedGroupRevealChange = useCallback((groupKey: string, revealed: boolean) => {
setRevealedGroupKeys((current) => {
@@ -606,9 +602,9 @@ export const ChannelSearchResults = observer(
data-message-selection-root="true"
data-flx="channel.channel-search-results.render-content.results-scroller"
>
{Array.from(messagesByChannel.entries()).map(([resultChannelId, messages]) => {
{resultGroups.map((resultGroup) => {
const renderData = getSearchResultChannelRenderData(
resultChannelId,
resultGroup.channelId,
searchChannelsById,
(activeScope ?? DEFAULT_SCOPE_VALUE) as MessageSearchScope,
);
@@ -649,7 +645,7 @@ export const ChannelSearchResults = observer(
</div>
);
return (
<React.Fragment key={resultChannelId}>
<React.Fragment key={resultGroup.key}>
<MessageContextPrefix
channel={messageChannel}
showGuildMeta={showGuildMeta}
@@ -660,7 +656,7 @@ export const ChannelSearchResults = observer(
/>
<SearchResultMessageList
channel={messageChannel}
messages={messages}
messages={resultGroup.messages}
revealedGroupKeys={revealedGroupKeys}
onGroupRevealChange={handleCollapsedGroupRevealChange}
collapsedGroupClassName={styles.collapsedMessageGroup}
@@ -725,7 +721,7 @@ export const ChannelSearchResults = observer(
handlePaginationJump,
onCopySelectedMessages,
collapsedMessageVisibility,
messagesByChannel,
resultGroups,
searchChannelsById,
revealedGroupKeys,
handleCollapsedGroupRevealChange,
@@ -1375,7 +1375,10 @@ export const LexicalChannelTextareaContent = observer(
showStickersButton={showStickersButton}
showEmojiButton={showEmojiButton}
showMessageSendButton={showMessageSendButton}
showVoiceMessageButton={false}
canRecordVoice={canAttachFilesInChannel(channel)}
isEditingMessage={isEditingMessageInComposer || editingMessageId != null}
hasPendingSticker={hasPendingSticker}
voiceTooltipAnchorRef={contentAreaRef}
expressionPickerOpen={expressionPickerOpen}
selectedTab={selectedTab}
isMobile={mobileLayout.enabled}
@@ -2,6 +2,8 @@
import type {MessageAttachment} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
export const ATTACHMENT_CARD_WIDTH = 400;
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp', 'image/avif'];
const VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime'];
@@ -48,7 +48,7 @@
margin-top: var(--message-container-gap, 0.25rem);
}
.urlEmbedSpoiler {
div.urlEmbedSpoiler {
width: 100%;
max-width: 22.5rem;
}
@@ -109,7 +109,6 @@ const SpoileredUrlEmbed = observer(function SpoileredUrlEmbed({
hidden={hidden}
onReveal={reveal}
className={styles.urlEmbedSpoiler}
style={{width: '100%', maxWidth: 360}}
data-flx="channel.message-attachments.spoilered-url-embed"
>
{children}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ATTACHMENT_CARD_WIDTH} from '@app/features/channel/components/MessageAttachmentUtils';
import styles from '@app/features/channel/components/MessageUploadProgress.module.css';
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import {useMessageUpload} from '@app/features/messaging/hooks/useCloudUpload';
@@ -77,8 +78,8 @@ export const MessageUploadProgress = observer(({attachment, message}: MessageUpl
}
: {
display: 'grid',
width: '400px',
maxWidth: '400px',
width: remFromPx(ATTACHMENT_CARD_WIDTH),
maxWidth: remFromPx(ATTACHMENT_CARD_WIDTH),
};
return (
<div style={containerStyles} data-flx="channel.message-upload-progress.div">
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import {describe, expect, it} from 'vitest';
import {
buildSearchResultGroups,
buildSearchResultGroupsByMessageId,
countSearchResultChannels,
} from './SearchResultGrouping';
const message = (id: string, channelId: string): Message => ({id, channelId}) as Message;
describe('SearchResultGrouping', () => {
it('keeps the order the server returned instead of bucketing per channel', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('400', 'random'),
message('300', 'general'),
]);
expect(groups.map((group) => group.messages.map((result) => result.id))).toEqual([['500'], ['400'], ['300']]);
expect(groups.map((group) => group.channelId)).toEqual(['general', 'random', 'general']);
});
it('merges consecutive results from the same channel into one group', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('490', 'general'),
message('400', 'random'),
]);
expect(groups).toHaveLength(2);
expect(groups[0].messages.map((result) => result.id)).toEqual(['500', '490']);
expect(groups[1].messages.map((result) => result.id)).toEqual(['400']);
});
it('gives every group a unique key even when a channel appears more than once', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('400', 'random'),
message('300', 'general'),
]);
expect(new Set(groups.map((group) => group.key)).size).toBe(3);
});
it('indexes each message to the group it renders in', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('400', 'random'),
message('300', 'general'),
]);
const groupsByMessageId = buildSearchResultGroupsByMessageId(groups);
expect(groupsByMessageId.get('500')).toBe(groups[0]);
expect(groupsByMessageId.get('400')).toBe(groups[1]);
expect(groupsByMessageId.get('300')).toBe(groups[2]);
});
it('counts distinct channels rather than groups', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('400', 'random'),
message('300', 'general'),
]);
expect(countSearchResultChannels(groups)).toBe(2);
expect(countSearchResultChannels(buildSearchResultGroups([]))).toBe(0);
});
});
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Message} from '@app/features/messaging/models/MessagingMessage';
export interface SearchResultGroup {
key: string;
channelId: string;
messages: Array<Message>;
}
export const buildSearchResultGroups = (messages: Array<Message>): Array<SearchResultGroup> => {
const groups: Array<SearchResultGroup> = [];
let currentGroup: SearchResultGroup | null = null;
for (const message of messages) {
if (!currentGroup || currentGroup.channelId !== message.channelId) {
currentGroup = {key: `${message.channelId}-${message.id}`, channelId: message.channelId, messages: []};
groups.push(currentGroup);
}
currentGroup.messages.push(message);
}
return groups;
};
export const buildSearchResultGroupsByMessageId = (
groups: Array<SearchResultGroup>,
): Map<string, SearchResultGroup> => {
const groupsByMessageId = new Map<string, SearchResultGroup>();
for (const group of groups) {
for (const message of group.messages) {
groupsByMessageId.set(message.id, group);
}
}
return groupsByMessageId;
};
export const countSearchResultChannels = (groups: Array<SearchResultGroup>): number => {
return new Set(groups.map((group) => group.channelId)).size;
};
@@ -7,6 +7,34 @@
justify-content: center;
}
.voiceButton {
display: flex;
align-items: center;
justify-content: center;
min-width: var(--textarea-button-height);
height: var(--textarea-button-height);
padding: 0;
color: var(--text-primary-muted);
border-radius: var(--radius-md);
background: transparent;
border: none;
outline: none;
cursor: pointer;
flex-shrink: 0;
touch-action: none;
}
.voiceButton:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.voiceButtonIcon {
width: var(--textarea-button-icon-size);
height: var(--textarea-button-icon-size);
flex-shrink: 0;
}
.holdTooltip {
background-color: var(--brand-primary);
color: var(--text-on-brand-primary);
@@ -33,6 +61,10 @@
}
.overlayInner {
display: flex;
align-items: flex-end;
gap: 0.375rem;
padding: 0.5rem;
width: 100%;
min-width: 0;
box-sizing: border-box;
@@ -133,6 +165,7 @@
flex-shrink: 0;
display: flex;
align-items: center;
width: 2.25rem;
height: 2.25rem;
}
@@ -2,7 +2,6 @@
import Accessibility from '@app/features/accessibility/state/Accessibility';
import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter';
import layoutStyles from '@app/features/channel/components/textarea/MobileTextareaLayout.module.css';
import styles from '@app/features/channel/components/VoiceMessageRecorder.module.css';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {usePortalHost} from '@app/features/ui/overlay/PortalHostContext';
@@ -529,7 +528,7 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
>
<motion.button
type="button"
className={layoutStyles.mobileVoiceButton}
className={styles.voiceButton}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerMove={handlePointerMove}
@@ -541,7 +540,7 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
data-flx="channel.voice-message-recorder.button.pointer-down"
>
<MicrophoneIcon
className={layoutStyles.mobileRightButtonIcon}
className={styles.voiceButtonIcon}
data-flx="channel.voice-message-recorder.microphone-icon"
/>
</motion.button>
@@ -578,10 +577,7 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
transition={resolvedOverlayMotion.transition}
data-flx="channel.voice-message-recorder.recording-overlay"
>
<div
className={clsx(styles.overlayInner, layoutStyles.mobileTextareaWrapper)}
data-flx="channel.voice-message-recorder.overlay-inner"
>
<div className={styles.overlayInner} data-flx="channel.voice-message-recorder.overlay-inner">
<button
type="button"
className={clsx(styles.iconButton, styles.trashButton)}
@@ -610,7 +606,7 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
</div>
</div>
<div
className={clsx(styles.lockIndicatorWrapper, layoutStyles.mobileRightButtonContainer)}
className={styles.lockIndicatorWrapper}
data-flx="channel.voice-message-recorder.lock-indicator-wrapper"
>
<button
@@ -8,6 +8,11 @@ import {Message as MessageComponent} from '@app/features/channel/components/Chan
import {CollapsedMessageVisibilityProvider} from '@app/features/channel/components/CollapsedMessageVisibilityContext';
import {MessageActionBottomSheet} from '@app/features/channel/components/MessageActionBottomSheet';
import type {MessageGroupRenderWrapperProps} from '@app/features/channel/components/MessageGroup';
import {
buildSearchResultGroups,
buildSearchResultGroupsByMessageId,
countSearchResultChannels,
} from '@app/features/channel/components/SearchResultGrouping';
import {SearchResultMessageList} from '@app/features/channel/components/SearchResultMessageList';
import {type ChannelSearchFilters, useChannelSearch} from '@app/features/channel/hooks/useChannelSearch';
import type {Channel} from '@app/features/channel/models/Channel';
@@ -216,22 +221,17 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
() => new Map(successChannels.map((searchChannel) => [searchChannel.id, searchChannel])),
[successChannels],
);
const messagesByChannel = React.useMemo(() => {
const grouped = new Map<string, Array<Message>>();
for (const message of successResults) {
if (!grouped.has(message.channelId)) {
grouped.set(message.channelId, []);
}
grouped.get(message.channelId)!.push(message);
}
return grouped;
}, [successResults]);
const resultGroups = React.useMemo(() => buildSearchResultGroups(successResults), [successResults]);
const resultGroupsByMessageId = React.useMemo(
() => buildSearchResultGroupsByMessageId(resultGroups),
[resultGroups],
);
const spammerOverrideVersion = LocalUserSpamOverride.version;
const collapsedMessageVisibility = React.useMemo(
() => ({
isMessageRevealed: (message: Message) => {
const channelMessages = messagesByChannel.get(message.channelId);
if (!channelMessages) {
const resultGroup = resultGroupsByMessageId.get(message.id);
if (!resultGroup) {
return false;
}
const messageChannel = searchChannelsById.get(message.channelId) ?? Channels.getChannel(message.channelId);
@@ -240,14 +240,14 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
}
const groupKey = getCollapsedMessageGroupKey({
channel: messageChannel,
messages: channelMessages,
messages: resultGroup.messages,
messageId: message.id,
treatSpam: true,
});
return groupKey != null && revealedGroupKeys.has(groupKey);
},
}),
[messagesByChannel, revealedGroupKeys, searchChannelsById, spammerOverrideVersion],
[resultGroupsByMessageId, revealedGroupKeys, searchChannelsById, spammerOverrideVersion],
);
const handleCollapsedGroupRevealChange = useCallback((groupKey: string, revealed: boolean) => {
setRevealedGroupKeys((current) => {
@@ -598,7 +598,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
);
}
const totalPages = Math.max(1, Math.ceil(total / hitsPerPage));
const hasMultipleChannels = messagesByChannel.size > 1;
const hasMultipleChannels = countSearchResultChannels(resultGroups) > 1;
return (
<>
<CollapsedMessageVisibilityProvider
@@ -613,8 +613,9 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
data-message-selection-root="true"
data-flx="channel.channel-search-bottom-sheet.render-content.results-scroller"
>
{Array.from(messagesByChannel.entries()).map(([channelId, messages]) => {
const messageChannel = searchChannelsById.get(channelId) ?? Channels.getChannel(channelId);
{resultGroups.map((resultGroup) => {
const messageChannel =
searchChannelsById.get(resultGroup.channelId) ?? Channels.getChannel(resultGroup.channelId);
if (!messageChannel) {
return null;
}
@@ -648,7 +649,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
</LongPressable>
);
return (
<React.Fragment key={channelId}>
<React.Fragment key={resultGroup.key}>
{hasMultipleChannels && (
<div
className={styles.channelSection}
@@ -667,7 +668,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
)}
<SearchResultMessageList
channel={messageChannel}
messages={messages}
messages={resultGroup.messages}
revealedGroupKeys={revealedGroupKeys}
onGroupRevealChange={handleCollapsedGroupRevealChange}
collapsedGroupClassName={styles.collapsedMessageGroup}
@@ -44,7 +44,6 @@ import {useVoiceCallFullscreenViewState} from '@app/features/voice/components/us
import {VoiceCallView} from '@app/features/voice/components/VoiceCallView';
import {VoiceE2EEIndicator} from '@app/features/voice/components/VoiceE2EEIndicator';
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
import {isNativeVoiceEngineSelected} from '@app/features/voice/engine/native_voice_engine/getVoiceEngine';
import {useCompactCallExpansionState} from '@app/features/voice/hooks/useCompactCallExpansionState';
import {usePendingVoiceConnection} from '@app/features/voice/hooks/usePendingVoiceConnection';
import {getGuildVoiceCallExpansionKey} from '@app/features/voice/state/CompactVoiceCallHeight';
@@ -182,7 +181,7 @@ export const GuildChannelView = observer(({channelId, guildId}: GuildChannelView
isVoiceChannel &&
connectedChannelId === channelId &&
(connectedGuildId ?? null) === (channel?.guildId ?? null) &&
(room || (isNativeVoiceEngineSelected() && MediaEngine.connected)),
room,
);
const matureContentGateReason = GuildMatureContentAgree.getGateReason({channelId, guildId});
const matureContentResolved = GuildMatureContentAgree.getResolvedContext({channelId, guildId});
@@ -3,7 +3,6 @@
import Authentication from '@app/features/auth/state/Authentication';
import type {Channel} from '@app/features/channel/models/Channel';
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
import {isNativeVoiceEngineSelected} from '@app/features/voice/engine/native_voice_engine/getVoiceEngine';
import CallInitiator from '@app/features/voice/state/CallInitiator';
import CallState, {type Call} from '@app/features/voice/state/CallState';
@@ -40,9 +39,7 @@ export function useCallHeaderState(channel?: Channel | null): CallHeaderState {
const matchesConnectionContext = Boolean(
channelId && MediaEngine.channelId === channelId && (MediaEngine.guildId ?? null) === normalizedGuildId,
);
const isDeviceInRoomForChannelCall = Boolean(
matchesConnectionContext && (MediaEngine.room || (isNativeVoiceEngineSelected() && MediaEngine.connected)),
);
const isDeviceInRoomForChannelCall = Boolean(matchesConnectionContext && MediaEngine.room);
const isDeviceConnectingToChannelCall =
matchesConnectionContext && (MediaEngine.connecting || (MediaEngine.connected && !isDeviceInRoomForChannelCall));
const controlsVariant: CallHeaderControlsVariant = !callExistsAndOngoing
@@ -113,6 +113,10 @@
border-radius: 50%;
}
.embedIconPending {
opacity: 0;
}
.embedTitle {
font-size: 1em;
font-weight: 600;
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
BLUESKY_EMBED_MEDIA_FALLBACK_OUTER_WIDTH,
BLUESKY_EMBED_MIN_CONTENT_WIDTH,
calculateBlueskyMediaContainerWidth,
calculateBlueskyOuterMaxWidth,
EMBED_TEXT_OUTER_WIDTH,
formatResponsiveEmbedWidth,
} from '@app/features/channel/components/embeds/EmbedRenderUtils';
import {describe, expect, it} from 'vitest';
describe('formatResponsiveEmbedWidth', () => {
it('caps in rem so the width tracks the root font size at any zoom', () => {
expect(formatResponsiveEmbedWidth(EMBED_TEXT_OUTER_WIDTH)).toBe('min(100%, 32.25rem)');
expect(formatResponsiveEmbedWidth(BLUESKY_EMBED_MEDIA_FALLBACK_OUTER_WIDTH)).toBe('min(100%, 27rem)');
});
it('rounds to whole pixels before converting', () => {
expect(formatResponsiveEmbedWidth(400.4)).toBe('min(100%, 25rem)');
});
it('clamps non-positive and non-finite widths', () => {
expect(formatResponsiveEmbedWidth(0)).toBe('min(100%, 0rem)');
expect(formatResponsiveEmbedWidth(Number.NaN)).toBe('min(100%, 0rem)');
});
});
describe('calculateBlueskyOuterMaxWidth', () => {
it('falls back to the shared text width when there is no media', () => {
expect(calculateBlueskyOuterMaxWidth({hasMedia: false, chromeWidth: 29})).toBe(EMBED_TEXT_OUTER_WIDTH);
});
it('adds chrome around the media container width', () => {
expect(calculateBlueskyOuterMaxWidth({mediaWidth: 480, hasMedia: true, chromeWidth: 29})).toBe(509);
});
it('never goes below the minimum content width', () => {
expect(calculateBlueskyMediaContainerWidth(120)).toBe(BLUESKY_EMBED_MIN_CONTENT_WIDTH);
});
});
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {MessageEmbedTypes} from '@fluxer/constants/src/ChannelConstants';
import type {MessageEmbed} from '@fluxer/schema/src/domains/message/EmbedSchemas';
@@ -28,7 +29,7 @@ export function isMediaOnlyEmbed(embed: MediaOnlyEmbedFields): boolean {
export const BLUESKY_EMBED_MIN_CONTENT_WIDTH = 320;
export const BLUESKY_EMBED_MEDIA_FALLBACK_OUTER_WIDTH = 432;
export const BLUESKY_EMBED_TEXT_OUTER_WIDTH = 516;
export const EMBED_TEXT_OUTER_WIDTH = 516;
const normalizePositiveWidth = (width: number): number => {
if (!Number.isFinite(width) || width <= 0) {
return 0;
@@ -55,9 +56,9 @@ export function calculateBlueskyOuterMaxWidth({
if (mediaContainerWidth !== undefined) {
return mediaContainerWidth + normalizePositiveWidth(chromeWidth);
}
return hasMedia ? BLUESKY_EMBED_MEDIA_FALLBACK_OUTER_WIDTH : BLUESKY_EMBED_TEXT_OUTER_WIDTH;
return hasMedia ? BLUESKY_EMBED_MEDIA_FALLBACK_OUTER_WIDTH : EMBED_TEXT_OUTER_WIDTH;
}
export function formatResponsiveEmbedWidth(width: number): string {
return `min(100%, ${normalizePositiveWidth(width)}px)`;
return `min(100%, ${remFromPx(normalizePositiveWidth(width))})`;
}
@@ -3,6 +3,7 @@
import {TextualAttachmentPreview} from '@app/features/channel/components/embeds/attachments/TextualAttachmentPreview';
import {splitFilename} from '@app/features/channel/components/embeds/EmbedUtils';
import {canDeleteAttachmentUtil} from '@app/features/channel/components/MessageActionUtils';
import {ATTACHMENT_CARD_WIDTH} from '@app/features/channel/components/MessageAttachmentUtils';
import {useMaybeMessageViewContext} from '@app/features/channel/components/MessageViewContext';
import {DELETE_ATTACHMENT_DESCRIPTOR, DOWNLOAD_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {useDeleteAttachment} from '@app/features/messaging/hooks/useDeleteAttachment';
@@ -181,8 +182,8 @@ export const AttachmentFile = observer(({attachment, message, isPreview}: Attach
containerStyles = {
display: 'grid',
width: '100%',
maxWidth: '400px',
minWidth: 'min(400px, 100%)',
maxWidth: remFromPx(ATTACHMENT_CARD_WIDTH),
minWidth: `min(${remFromPx(ATTACHMENT_CARD_WIDTH)}, 100%)`,
};
}
const handleDownload = async (e: React.MouseEvent) => {
@@ -5,6 +5,7 @@ import styles from '@app/features/channel/components/embeds/ChannelEmbed.module.
import {EmbedLink} from '@app/features/channel/components/embeds/channel_embed/EmbedLink';
import {SafeMarkdown} from '@app/features/messaging/components/markdown';
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
import {useCachedImageLoaded} from '@app/features/messaging/hooks/useCachedImageLoaded';
import {buildMediaProxyURL} from '@app/features/messaging/utils/MediaProxyUtils';
import * as DateUtils from '@app/features/user/utils/DateFormatting';
import type {EmbedAuthor, EmbedField, EmbedFooter} from '@fluxer/schema/src/domains/message/EmbedSchemas';
@@ -32,13 +33,14 @@ export const EmbedAuthorComponent: FC<{author?: EmbedAuthor}> = observer(({autho
const iconSrc = author?.proxy_icon_url
? buildMediaProxyURL(author.proxy_icon_url, animatedMediaPlaybackAllowed ? {} : {format: 'webp', animated: false})
: undefined;
const iconLoaded = useCachedImageLoaded(iconSrc);
if (!author) return null;
return (
<div className={styles.embedAuthor} data-flx="channel.embeds.embed.embed-author-component.embed-author">
{iconSrc && (
<img
alt=""
className={styles.embedAuthorIcon}
className={clsx(styles.embedAuthorIcon, !iconLoaded && styles.embedIconPending)}
src={iconSrc}
width={24}
height={24}
@@ -181,6 +183,7 @@ export const EmbedFooterComponent: FC<{
const iconSrc = footer?.proxy_icon_url
? buildMediaProxyURL(footer.proxy_icon_url, animatedMediaPlaybackAllowed ? {} : {format: 'webp', animated: false})
: undefined;
const iconLoaded = useCachedImageLoaded(iconSrc);
if (!(footer || formattedTimestamp)) return null;
return (
<div
@@ -190,7 +193,7 @@ export const EmbedFooterComponent: FC<{
{iconSrc && (
<img
alt=""
className={styles.embedFooterIcon}
className={clsx(styles.embedFooterIcon, !iconLoaded && styles.embedIconPending)}
src={iconSrc}
width={20}
height={20}
@@ -23,6 +23,10 @@ import {
EmbedProvider,
EmbedTitle,
} from '@app/features/channel/components/embeds/channel_embed/EmbedParts';
import {
EMBED_TEXT_OUTER_WIDTH,
formatResponsiveEmbedWidth,
} from '@app/features/channel/components/embeds/EmbedRenderUtils';
import markupStyles from '@app/features/theme/styles/Markup.module.css';
import {MessageEmbedTypes} from '@fluxer/constants/src/ChannelConstants';
import type {EmbedMedia} from '@fluxer/schema/src/domains/message/EmbedSchemas';
@@ -58,7 +62,7 @@ export const RichEmbed: FC<EmbedProps> = observer(
data-search-highlight-scope="message"
style={{
borderLeft: `4px solid ${getBorderColor(embed.color)}`,
maxWidth: useNarrowWidth ? `min(100%, ${EMBED_MEDIA_MAX_WIDTH}px)` : 'min(100%, 516px)',
maxWidth: formatResponsiveEmbedWidth(useNarrowWidth ? EMBED_MEDIA_MAX_WIDTH : EMBED_TEXT_OUTER_WIDTH),
}}
data-flx="channel.embeds.embed.rich-embed.embed"
>
@@ -4,6 +4,7 @@ import {deriveDefaultNameFromMessage} from '@app/features/channel/components/emb
import styles from '@app/features/channel/components/embeds/media/EmbedAudio.module.css';
import {getMediaButtonVisibility} from '@app/features/channel/components/embeds/media/MediaButtonUtils';
import type {BaseMediaProps} from '@app/features/channel/components/embeds/media/MediaTypes';
import {ATTACHMENT_CARD_WIDTH} from '@app/features/channel/components/MessageAttachmentUtils';
import {useMaybeMessageViewContext} from '@app/features/channel/components/MessageViewContext';
import {DELETE_ATTACHMENT_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {useDeleteAttachment} from '@app/features/messaging/hooks/useDeleteAttachment';
@@ -130,8 +131,8 @@ const EmbedAudio: FC<EmbedAudioProps> = observer(
: {
display: 'grid',
width: '100%',
maxWidth: 'min(100%, 400px)',
minWidth: 'min(400px, 100%)',
maxWidth: `min(100%, ${remFromPx(ATTACHMENT_CARD_WIDTH)})`,
minWidth: `min(${remFromPx(ATTACHMENT_CARD_WIDTH)}, 100%)`,
};
const {showDeleteButton, showDownloadButton} = getMediaButtonVisibility(
canFavorite,
@@ -233,7 +233,7 @@ export const EmbedYouTube: FC<EmbedYouTubeProps> = observer(({embed, width = YOU
className={styles.videoContainer}
style={{
...containerStyle,
width: `${dimensions.width}px`,
width: remFromPx(dimensions.width),
aspectRatio,
maxWidth: '100%',
}}
@@ -1,260 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Accessibility from '@app/features/accessibility/state/Accessibility';
import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter';
import {TextareaButton} from '@app/features/channel/components/textarea/TextareaButton';
import styles from '@app/features/channel/components/textarea/TextareaButtons.module.css';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {prepareVoiceMessageWav} from '@app/features/voice/utils/VoiceMessageRecordingUtils';
import {sendVoiceMessage} from '@app/features/voice/utils/VoiceMessageSendUtils';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {MicrophoneIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {motion, type Variants} from 'framer-motion';
import type React from 'react';
import {useCallback, useMemo, useRef, useState} from 'react';
const RECORDING_FAILED_PLEASE_TRY_AGAIN_DESCRIPTOR = msg({
message: 'Recording failed. Try again.',
comment: 'Error toast shown when the hold-to-record voice message recording fails.',
});
const VOICE_MESSAGE_DESCRIPTOR = msg({
message: 'Voice message',
comment: 'Filename label used for voice messages sent via the hold-to-record button.',
});
const UNABLE_TO_SEND_VOICE_MESSAGE_PLEASE_TRY_AGAIN_DESCRIPTOR = msg({
message: 'Unable to send voice message. Try again.',
comment: 'Error toast shown when sending a hold-to-record voice message fails.',
});
const VOICE_RECORDING_IS_NOT_SUPPORTED_IN_THIS_BROWSER_DESCRIPTOR = msg({
message: 'Voice recording is not supported in this browser.',
comment: 'Inline error when MediaRecorder is unsupported for the hold-to-record voice message flow.',
});
const UNABLE_TO_START_RECORDING_PLEASE_ALLOW_MICROPHONE_ACCESS_DESCRIPTOR = msg({
message: 'Unable to start recording. Allow microphone access.',
comment: 'Inline error when microphone permission is denied for the hold-to-record voice message flow.',
});
const RELEASE_TO_SEND_DESCRIPTOR = msg({
message: 'Release to send',
comment: 'Tooltip on the hold-to-record button while the user is actively holding to record.',
});
const HOLD_TO_SEND_VOICE_MESSAGE_DESCRIPTOR = msg({
message: 'Hold to send voice message',
comment: 'Tooltip on the hold-to-record button in its idle state.',
});
const logger = new Logger('HoldToRecordButton');
const recordingVariants: Variants = {
idle: {
scale: 1,
boxShadow: '0 0 0 0px rgba(252, 95, 105, 0)',
},
recording: {
scale: [1, 1.05, 1],
boxShadow: [
'0 0 0 0px rgba(252, 95, 105, 0.35)',
'0 0 0 8px rgba(252, 95, 105, 0.15)',
'0 0 0 0px rgba(252, 95, 105, 0.35)',
],
transition: {
duration: 1.2,
repeat: Infinity,
ease: 'easeInOut',
},
},
};
interface HoldToRecordButtonProps {
channelId: string;
disabled?: boolean;
onFallback?: () => void;
}
const HoldToRecordButton: React.FC<HoldToRecordButtonProps> = ({channelId, disabled, onFallback}) => {
const {i18n} = useLingui();
const [status, setStatus] = useState<'idle' | 'recording' | 'sending'>('idle');
const [error, setError] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Array<Blob>>([]);
const pointerIdRef = useRef<number | null>(null);
const maxTimerRef = useRef<number | null>(null);
const maxRecordingSeconds = useMemo(
() => LimitResolver.resolve({key: 'max_voice_message_duration', fallback: 1200}),
[],
);
const clearMaxTimer = useCallback(() => {
if (maxTimerRef.current) {
clearTimeout(maxTimerRef.current);
maxTimerRef.current = null;
}
}, []);
const releaseStream = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
}, []);
const cleanupRecording = useCallback(() => {
clearMaxTimer();
releaseStream();
mediaRecorderRef.current = null;
}, [clearMaxTimer, releaseStream]);
const handleRecordingStop = useCallback(
async (blob: Blob) => {
if (blob.size === 0) {
setStatus('idle');
setError(i18n._(RECORDING_FAILED_PLEASE_TRY_AGAIN_DESCRIPTOR));
return;
}
setStatus('sending');
try {
const {file, duration, waveform} = await prepareVoiceMessageWav(blob, `voice-message-${Date.now()}.wav`);
await sendVoiceMessage({
channelId,
file,
waveform,
duration,
title: i18n._(VOICE_MESSAGE_DESCRIPTOR),
});
setStatus('idle');
setError(null);
} catch (err) {
logger.error('Failed to send voice message', err);
setError(i18n._(UNABLE_TO_SEND_VOICE_MESSAGE_PLEASE_TRY_AGAIN_DESCRIPTOR));
setStatus('idle');
}
},
[channelId, i18n],
);
const handleStopEvent = useCallback(() => {
const recorder = mediaRecorderRef.current;
if (!recorder) {
setStatus('idle');
return;
}
const recordedChunks = chunksRef.current;
const blob = new Blob(recordedChunks, {type: recorder.mimeType || 'audio/ogg'});
chunksRef.current = [];
cleanupRecording();
if (recordedChunks.length === 0) {
setError(i18n._(RECORDING_FAILED_PLEASE_TRY_AGAIN_DESCRIPTOR));
setStatus('idle');
return;
}
void handleRecordingStop(blob);
}, [cleanupRecording, handleRecordingStop, i18n]);
const handleDataAvailable = useCallback((event: BlobEvent) => {
if (event.data && event.data.size > 0) {
chunksRef.current = [...chunksRef.current, event.data];
}
}, []);
const stopRecording = useCallback(() => {
const recorder = mediaRecorderRef.current;
if (recorder && recorder.state !== 'inactive') {
recorder.stop();
}
}, []);
const startRecording = useCallback(async () => {
if (status === 'recording' || status === 'sending') return;
if (typeof navigator.mediaDevices === 'undefined' || typeof MediaRecorder === 'undefined') {
setError(i18n._(VOICE_RECORDING_IS_NOT_SUPPORTED_IN_THIS_BROWSER_DESCRIPTOR));
onFallback?.();
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({audio: true});
const preferredType = MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')
? 'audio/ogg;codecs=opus'
: 'audio/webm;codecs=opus';
const recorder = new MediaRecorder(stream, {mimeType: preferredType});
chunksRef.current = [];
recorder.addEventListener('dataavailable', handleDataAvailable);
recorder.addEventListener('stop', handleStopEvent, {once: true});
recorder.start();
mediaRecorderRef.current = recorder;
streamRef.current = stream;
setStatus('recording');
setError(null);
clearMaxTimer();
maxTimerRef.current = window.setTimeout(() => {
stopRecording();
}, maxRecordingSeconds * 1000);
} catch (err) {
logger.error('Failed to start voice recording', err);
setError(i18n._(UNABLE_TO_START_RECORDING_PLEASE_ALLOW_MICROPHONE_ACCESS_DESCRIPTOR));
}
}, [clearMaxTimer, handleDataAvailable, handleStopEvent, maxRecordingSeconds, status, i18n]);
const handlePointerDown = useCallback(
(event: React.PointerEvent<HTMLButtonElement>) => {
if (disabled || status === 'sending') return;
event.preventDefault();
pointerIdRef.current = event.pointerId;
event.currentTarget.setPointerCapture(event.pointerId);
void startRecording();
},
[disabled, startRecording, status],
);
const handlePointerUp = useCallback(
(event: React.PointerEvent<HTMLButtonElement>) => {
if (pointerIdRef.current !== event.pointerId) return;
event.preventDefault();
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {}
pointerIdRef.current = null;
stopRecording();
},
[stopRecording],
);
const handlePointerCancel = useCallback(
(event: React.PointerEvent<HTMLButtonElement>) => {
if (pointerIdRef.current !== event.pointerId) return;
event.preventDefault();
pointerIdRef.current = null;
stopRecording();
},
[stopRecording],
);
const indicatorText = useMemo(() => {
if (error) return error;
if (status === 'recording') return i18n._(RELEASE_TO_SEND_DESCRIPTOR);
return i18n._(HOLD_TO_SEND_VOICE_MESSAGE_DESCRIPTOR);
}, [error, status, i18n.locale]);
return (
<motion.div
className={styles.holdButtonWrapper}
variants={recordingVariants}
animate={status === 'recording' ? 'recording' : 'idle'}
data-flx="channel.textarea.hold-to-record-button.hold-button-wrapper"
>
<TextareaButton
icon={MicrophoneIcon}
label={i18n._(VOICE_MESSAGE_DESCRIPTOR)}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
onPointerLeave={handlePointerCancel}
disabled={disabled || status === 'sending'}
className={clsx(styles.holdButton, status === 'recording' && styles.holdButtonRecording)}
data-flx="channel.textarea.hold-to-record-button.hold-button.pointer-down"
/>
<motion.span
key={indicatorText}
className={clsx(
styles.holdIndicator,
status === 'recording' && styles.holdIndicatorRecording,
error && styles.holdIndicatorError,
)}
initial={Accessibility.useReducedMotion ? {opacity: 1, y: 0} : {opacity: 0, y: 5}}
animate={{opacity: 1, y: 0}}
transition={{duration: Accessibility.useReducedMotion ? 0 : 0.15}}
data-flx="channel.textarea.hold-to-record-button.hold-indicator"
>
{indicatorText}
</motion.span>
</motion.div>
);
};
export default HoldToRecordButton;
@@ -1,158 +0,0 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.mobileTextareaWrapper {
display: flex;
align-items: flex-end;
gap: 0.375rem;
padding: 0.5rem;
width: 100%;
box-sizing: border-box;
}
.mobilePlusButtonContainer {
display: flex;
align-items: flex-end;
flex-shrink: 0;
height: 2.25rem;
}
.mobilePlusButton {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
background-color: var(--background-tertiary);
color: var(--text-primary);
border: none;
cursor: pointer;
flex-shrink: 0;
padding: 0;
}
.mobilePlusButtonIcon {
width: 1.25rem;
height: 1.25rem;
}
.mobileContentWrapper {
flex: 1;
min-width: 0;
display: flex;
align-items: flex-end;
gap: 0.375rem;
}
.mobileInputContainer {
flex: 1;
min-width: 0;
display: flex;
align-items: flex-end;
background-color: var(--background-tertiary);
border-radius: 1.125rem;
padding: 0;
position: relative;
min-height: 2.25rem;
}
.mobileInputContent {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding: 0;
font-size: 0.9375rem;
}
.mobileEmojiButtonContainer {
position: absolute;
right: 0.25rem;
bottom: 0.125rem;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.mobileEmojiButton {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background-color: transparent;
color: var(--text-primary-muted);
border: none;
cursor: pointer;
flex-shrink: 0;
padding: 0;
}
.mobileEmojiButtonIcon {
width: 1.375rem;
height: 1.375rem;
}
.mobileRightButtonContainer {
flex-shrink: 0;
display: flex;
align-items: flex-end;
position: relative;
width: 2.25rem;
height: 2.25rem;
}
.mobileRightButtonSlot {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.mobileSendButton {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
background-color: var(--brand-primary);
color: white;
border: none;
cursor: pointer;
flex-shrink: 0;
}
.mobileSendButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mobileVoiceButton {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
background-color: var(--background-tertiary);
color: var(--text-primary);
border: none;
cursor: pointer;
flex-shrink: 0;
touch-action: none;
}
.mobileVoiceButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mobileRightButtonIcon {
width: 1.25rem;
height: 1.25rem;
}
@@ -1,297 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Accessibility from '@app/features/accessibility/state/Accessibility';
import type {AutocompleteOption} from '@app/features/channel/components/Autocomplete';
import wrapperStyles from '@app/features/channel/components/textarea/InputWrapper.module.css';
import {transitionMobileTextareaButtonState} from '@app/features/channel/components/textarea/MobileTextareaButtonStateMachine';
import styles from '@app/features/channel/components/textarea/MobileTextareaLayout.module.css';
import textareaStyles from '@app/features/channel/components/textarea/TextareaInput.module.css';
import {TextareaInputField} from '@app/features/channel/components/textarea/TextareaInputField';
import VoiceMessageRecorder from '@app/features/channel/components/VoiceMessageRecorder';
import {EMOJIS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import type {TextareaTextChangeHint} from '@app/features/messaging/utils/TextareaSegmentManager';
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
import {getReducedMotionProps, type MotionAnimation} from '@app/features/ui/utils/ReducedMotionAnimation';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {ArrowUpIcon, PlusIcon, SmileyIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {AnimatePresence, motion} from 'framer-motion';
import type React from 'react';
import {type Dispatch, type SetStateAction, useMemo, useRef} from 'react';
const OPEN_MENU_DESCRIPTOR = msg({
message: 'Open menu',
comment: 'Button or menu action label in the channel and chat mobile textarea layout. Keep it concise.',
});
const SEND_MESSAGE_DESCRIPTOR = msg({
message: 'Send message',
comment: 'Button or menu action label in the channel and chat mobile textarea layout. Keep it concise.',
});
const MOBILE_BUTTON_SWAP_MOTION: MotionAnimation = {
initial: {opacity: 0, scale: 0.86, y: 3},
animate: {opacity: 1, scale: 1, y: 0},
exit: {opacity: 0, scale: 0.9, y: -2},
transition: {
opacity: {duration: 0.08},
y: {duration: 0.08, ease: 'easeOut'},
scale: {type: 'spring', stiffness: 560, damping: 28, mass: 0.55},
},
};
interface MobileTextareaLayoutProps {
disabled: boolean;
canAttachFiles: boolean;
value: string;
placeholderText: string;
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
scrollerRef: React.RefObject<ScrollerHandle | null>;
isFocused: boolean;
isAutocompleteAttached: boolean;
autocompleteListId?: string;
autocompleteOptions: Array<AutocompleteOption>;
selectedIndex: number;
channelId: string;
isSlowmodeActive: boolean;
isOverCharacterLimit: boolean;
isEditingMessage: boolean;
hasContent: boolean;
hasAttachments: boolean;
hasPendingSticker?: boolean;
isEditingScheduledMessage: boolean;
onFocus: () => void;
onBlur: () => void;
onChange: (value: string, inputType?: string, hint?: TextareaTextChangeHint) => void;
onHeightChange: (height: number) => void;
onCursorMove: () => void;
onArrowUp: (event: React.KeyboardEvent) => void;
onSubmit: () => void;
onAutocompleteSelect: (option: AutocompleteOption) => void;
setSelectedIndex: Dispatch<SetStateAction<number>>;
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onPlusClick: () => void;
onEmojiClick: () => void;
}
export function MobileTextareaLayout({
disabled,
canAttachFiles,
value,
placeholderText,
textareaRef,
scrollerRef,
isFocused,
isAutocompleteAttached,
autocompleteListId,
autocompleteOptions,
selectedIndex,
channelId,
isSlowmodeActive,
isOverCharacterLimit,
isEditingMessage,
hasContent,
hasAttachments,
hasPendingSticker,
isEditingScheduledMessage,
onFocus,
onBlur,
onChange,
onHeightChange,
onCursorMove,
onArrowUp,
onSubmit,
onAutocompleteSelect,
setSelectedIndex,
onKeyDown,
onPlusClick,
onEmojiClick,
}: MobileTextareaLayoutProps) {
const {i18n} = useLingui();
const buttonSwapMotion = getReducedMotionProps(MOBILE_BUTTON_SWAP_MOTION, Accessibility.useReducedMotion);
const rightButtonState = useMemo(
() =>
transitionMobileTextareaButtonState({
disabled,
canRecordVoice: canAttachFiles,
value,
isSlowmodeActive,
isOverCharacterLimit,
isEditingMessage,
hasContent,
hasAttachments,
hasPendingSticker: Boolean(hasPendingSticker),
isEditingScheduledMessage,
}),
[
canAttachFiles,
disabled,
hasAttachments,
hasContent,
hasPendingSticker,
isEditingMessage,
isEditingScheduledMessage,
isOverCharacterLimit,
isSlowmodeActive,
value,
],
);
const sendButtonDisabled = rightButtonState.sendButton.disabled;
const sendLockRef = useRef(false);
const handleSendClick = () => {
if (sendLockRef.current) {
return;
}
sendLockRef.current = true;
setTimeout(() => {
sendLockRef.current = false;
}, 500);
onSubmit();
};
const shouldShowVoiceButton = rightButtonState.visibleButton === 'voice';
const voiceButtonDisabled = rightButtonState.voiceButton.disabled;
const voiceTooltipAnchorRef = useRef<HTMLDivElement | null>(null);
return (
<div
className={clsx(styles.mobileTextareaWrapper, disabled && wrapperStyles.disabled)}
data-flx="channel.textarea.mobile-textarea-layout.mobile-textarea-wrapper"
>
{!disabled && canAttachFiles && (
<div
className={styles.mobilePlusButtonContainer}
data-flx="channel.textarea.mobile-textarea-layout.mobile-plus-button-container"
>
<button
type="button"
className={styles.mobilePlusButton}
onClick={onPlusClick}
aria-label={i18n._(OPEN_MENU_DESCRIPTOR)}
data-flx="channel.textarea.mobile-textarea-layout.mobile-plus-button.plus-click"
>
<PlusIcon
className={styles.mobilePlusButtonIcon}
weight="bold"
data-flx="channel.textarea.mobile-textarea-layout.mobile-plus-button-icon"
/>
</button>
</div>
)}
<div
className={styles.mobileContentWrapper}
data-flx="channel.textarea.mobile-textarea-layout.mobile-content-wrapper"
>
<div
className={styles.mobileInputContainer}
ref={voiceTooltipAnchorRef}
data-flx="channel.textarea.mobile-textarea-layout.mobile-input-container"
>
<div
className={styles.mobileInputContent}
data-flx="channel.textarea.mobile-textarea-layout.mobile-input-content"
>
<Scroller
ref={scrollerRef}
fade={true}
className={textareaStyles.scroller}
key="mobile-textarea-scroller"
data-flx="channel.textarea.mobile-textarea-layout.scroller"
>
<div className={textareaStyles.flexColumn} data-flx="channel.textarea.mobile-textarea-layout.div">
<TextareaInputField
channelId={channelId}
disabled={disabled}
isMobile={true}
value={value}
placeholder={placeholderText}
textareaRef={textareaRef}
isFocused={isFocused}
isAutocompleteAttached={isAutocompleteAttached}
autocompleteListId={autocompleteListId}
autocompleteOptions={autocompleteOptions}
selectedIndex={selectedIndex}
className={textareaStyles.textareaMobile}
onFocus={onFocus}
onBlur={onBlur}
onChange={onChange}
onHeightChange={onHeightChange}
onCursorMove={onCursorMove}
onArrowUp={onArrowUp}
onEnter={onSubmit}
onAutocompleteSelect={onAutocompleteSelect}
setSelectedIndex={setSelectedIndex}
onKeyDown={onKeyDown}
data-flx="channel.textarea.mobile-textarea-layout.textarea-input-field.change"
/>
</div>
</Scroller>
</div>
{!disabled && (
<div
className={styles.mobileEmojiButtonContainer}
data-flx="channel.textarea.mobile-textarea-layout.mobile-emoji-button-container"
>
<button
type="button"
className={styles.mobileEmojiButton}
onClick={onEmojiClick}
aria-label={i18n._(EMOJIS_DESCRIPTOR)}
data-flx="channel.textarea.mobile-textarea-layout.mobile-emoji-button.emoji-click"
>
<SmileyIcon
className={styles.mobileEmojiButtonIcon}
weight="fill"
data-flx="channel.textarea.mobile-textarea-layout.mobile-emoji-button-icon"
/>
</button>
</div>
)}
</div>
<div
className={styles.mobileRightButtonContainer}
data-flx="channel.textarea.mobile-textarea-layout.mobile-right-button-container"
>
<AnimatePresence initial={false} data-flx="channel.textarea.mobile-textarea-layout.animate-presence">
{shouldShowVoiceButton ? (
<motion.div
key="voice-button"
className={styles.mobileRightButtonSlot}
data-flx="channel.textarea.mobile-textarea-layout.div--2"
{...buttonSwapMotion}
>
<VoiceMessageRecorder
channelId={channelId}
disabled={voiceButtonDisabled}
tooltipAnchorRef={voiceTooltipAnchorRef}
data-flx="channel.textarea.mobile-textarea-layout.voice-message-recorder"
/>
</motion.div>
) : (
<motion.div
key="send-button"
className={styles.mobileRightButtonSlot}
data-flx="channel.textarea.mobile-textarea-layout.mobile-send-button.send-click"
{...buttonSwapMotion}
>
<button
type="button"
className={styles.mobileSendButton}
onClick={handleSendClick}
aria-label={i18n._(SEND_MESSAGE_DESCRIPTOR)}
disabled={sendButtonDisabled}
data-flx="channel.textarea.mobile-textarea-layout.mobile-send-button.button"
>
<ArrowUpIcon
className={styles.mobileRightButtonIcon}
weight="bold"
data-flx="channel.textarea.mobile-textarea-layout.mobile-right-button-icon"
/>
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
);
}
MobileTextareaLayout.displayName = 'MobileTextareaLayout';
@@ -14,33 +14,17 @@
align-items: stretch;
}
.holdButtonWrapper {
.mobileRightButtonContainer {
position: relative;
flex-shrink: 0;
width: var(--textarea-button-height);
height: var(--textarea-button-height);
}
.mobileRightButtonSlot {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
touch-action: none;
}
.holdButton {
touch-action: none;
}
.holdButtonRecording {
box-shadow: 0 0 0 0.125rem rgba(252, 95, 105, 0.35);
}
.holdIndicator {
font-size: 0.625rem;
text-transform: uppercase;
color: var(--text-primary-muted);
}
.holdIndicatorRecording {
color: var(--accent-danger);
}
.holdIndicatorError {
color: var(--accent-danger);
font-weight: 600;
justify-content: center;
}
@@ -1,10 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Accessibility from '@app/features/accessibility/state/Accessibility';
import HoldToRecordButton from '@app/features/channel/components/textarea/HoldToRecordButton';
import {transitionMobileTextareaButtonState} from '@app/features/channel/components/textarea/MobileTextareaButtonStateMachine';
import {TextareaButton} from '@app/features/channel/components/textarea/TextareaButton';
import textareaButtonsStyles from '@app/features/channel/components/textarea/TextareaButtons.module.css';
import styles from '@app/features/channel/components/textarea/TextareaInput.module.css';
import VoiceMessageRecorder from '@app/features/channel/components/VoiceMessageRecorder';
import type {ExpressionPickerTabType} from '@app/features/expressions/components/popouts/ExpressionPickerPopout';
import {
EMOJIS_DESCRIPTOR,
@@ -12,29 +13,28 @@ import {
MEDIA_DESCRIPTOR,
STICKERS_DESCRIPTOR,
} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {getReducedMotionProps} from '@app/features/ui/utils/ReducedMotionAnimation';
import {getReducedMotionProps, type MotionAnimation} from '@app/features/ui/utils/ReducedMotionAnimation';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {
GifIcon,
ImageSquareIcon,
MicrophoneIcon,
PaperPlaneRightIcon,
SmileyIcon,
StickerIcon,
} from '@phosphor-icons/react';
import {GifIcon, ImageSquareIcon, PaperPlaneRightIcon, SmileyIcon, StickerIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {AnimatePresence, motion} from 'framer-motion';
import React from 'react';
import React, {useMemo} from 'react';
const VOICE_MESSAGE_DESCRIPTOR = msg({
message: 'Voice message',
comment: 'Short label in the channel and chat textarea buttons. Keep it concise.',
});
const SEND_MESSAGE_DESCRIPTOR = msg({
message: 'Send message',
comment: 'Button or menu action label in the channel and chat textarea buttons. Keep it concise.',
});
const MOBILE_BUTTON_SWAP_MOTION: MotionAnimation = {
initial: {opacity: 0, scale: 0.86, y: 3},
animate: {opacity: 1, scale: 1, y: 0},
exit: {opacity: 0, scale: 0.9, y: -2},
transition: {
opacity: {duration: 0.08},
y: {duration: 0.08, ease: 'easeOut'},
scale: {type: 'spring', stiffness: 560, damping: 28, mass: 0.55},
},
};
interface TextareaButtonsProps {
disabled: boolean;
@@ -44,8 +44,10 @@ interface TextareaButtonsProps {
showStickersButton: boolean;
showEmojiButton: boolean;
showMessageSendButton: boolean;
showVoiceMessageButton?: boolean;
onVoiceMessageClick?: () => void;
canRecordVoice: boolean;
isEditingMessage: boolean;
hasPendingSticker: boolean;
voiceTooltipAnchorRef?: React.RefObject<HTMLElement | null>;
channelId: string;
expressionPickerOpen: boolean;
selectedTab: ExpressionPickerTabType;
@@ -71,8 +73,10 @@ export const TextareaButtons = React.forwardRef<HTMLDivElement, TextareaButtonsP
showStickersButton,
showEmojiButton,
showMessageSendButton,
showVoiceMessageButton,
onVoiceMessageClick,
canRecordVoice,
isEditingMessage,
hasPendingSticker,
voiceTooltipAnchorRef,
channelId,
expressionPickerOpen,
selectedTab,
@@ -90,22 +94,40 @@ export const TextareaButtons = React.forwardRef<HTMLDivElement, TextareaButtonsP
ref,
) => {
const {i18n} = useLingui();
const buttonModel = useMemo(
() =>
transitionMobileTextareaButtonState({
disabled,
canRecordVoice,
value: '',
isSlowmodeActive,
isOverCharacterLimit: isOverLimit,
isEditingMessage,
hasContent,
hasAttachments,
hasPendingSticker,
isEditingScheduledMessage: false,
}),
[
canRecordVoice,
disabled,
hasAttachments,
hasContent,
hasPendingSticker,
isEditingMessage,
isOverLimit,
isSlowmodeActive,
],
);
if (disabled) {
return null;
}
const buttonSwapMotion = getReducedMotionProps(
{
initial: {scale: 0.8, opacity: 0},
animate: {scale: 1, opacity: 1},
exit: {scale: 0.8, opacity: 0},
transition: {duration: 0.15, ease: 'easeOut'},
},
Accessibility.useReducedMotion,
);
const buttonSwapMotion = getReducedMotionProps(MOBILE_BUTTON_SWAP_MOTION, Accessibility.useReducedMotion);
const shouldShowDesktopSendButton = showMessageSendButton;
const baseSendDisabled = isSlowmodeActive || isOverLimit || disableSendButton;
const sendButtonDisabled = baseSendDisabled || (!hasContent && !hasAttachments);
const shouldShowHoldToRecord = isMobile && showVoiceMessageButton && !hasContent && !hasAttachments;
const desktopSendDisabled =
isSlowmodeActive || isOverLimit || (!hasContent && !hasAttachments) || disableSendButton;
const shouldShowVoiceButton = buttonModel.visibleButton === 'voice';
const mobileSendDisabled = buttonModel.sendButton.disabled || Boolean(disableSendButton);
return (
<div
className={clsx(styles.buttonContainerDense, styles.sideButtonPadding)}
@@ -167,48 +189,51 @@ export const TextareaButtons = React.forwardRef<HTMLDivElement, TextareaButtonsP
className={textareaButtonsStyles.invisibleTrigger}
data-flx="channel.textarea.textarea-buttons.div"
/>
{isMobile && showVoiceMessageButton && onVoiceMessageClick && !shouldShowHoldToRecord && (
<TextareaButton
icon={MicrophoneIcon}
label={i18n._(VOICE_MESSAGE_DESCRIPTOR)}
onClick={onVoiceMessageClick}
data-flx="channel.textarea.textarea-buttons.textarea-button.voice-message-click"
/>
)}
{isMobile && (
<AnimatePresence mode="wait" initial={false} data-flx="channel.textarea.textarea-buttons.animate-presence">
{shouldShowHoldToRecord ? (
<motion.div
key="hold-to-record"
data-flx="channel.textarea.textarea-buttons.div--2"
{...buttonSwapMotion}
>
<HoldToRecordButton
channelId={channelId}
disabled={baseSendDisabled}
onFallback={onVoiceMessageClick}
data-flx="channel.textarea.textarea-buttons.hold-to-record-button"
/>
</motion.div>
) : (
<motion.div key="send-button" data-flx="channel.textarea.textarea-buttons.div--3" {...buttonSwapMotion}>
<TextareaButton
disabled={sendButtonDisabled}
icon={PaperPlaneRightIcon}
label={i18n._(SEND_MESSAGE_DESCRIPTOR)}
onClick={onSubmit}
keybindCombo={{key: 'Enter'}}
data-flx="channel.textarea.textarea-buttons.textarea-button.submit"
/>
</motion.div>
)}
</AnimatePresence>
<div
className={textareaButtonsStyles.mobileRightButtonContainer}
data-flx="channel.textarea.textarea-buttons.mobile-right-button-container"
>
<AnimatePresence initial={false} data-flx="channel.textarea.textarea-buttons.animate-presence">
{shouldShowVoiceButton ? (
<motion.div
key="voice-button"
className={textareaButtonsStyles.mobileRightButtonSlot}
data-flx="channel.textarea.textarea-buttons.div--2"
{...buttonSwapMotion}
>
<VoiceMessageRecorder
channelId={channelId}
disabled={buttonModel.voiceButton.disabled}
tooltipAnchorRef={voiceTooltipAnchorRef}
data-flx="channel.textarea.textarea-buttons.voice-message-recorder"
/>
</motion.div>
) : (
<motion.div
key="send-button"
className={textareaButtonsStyles.mobileRightButtonSlot}
data-flx="channel.textarea.textarea-buttons.div--3"
{...buttonSwapMotion}
>
<TextareaButton
disabled={mobileSendDisabled}
icon={PaperPlaneRightIcon}
label={i18n._(SEND_MESSAGE_DESCRIPTOR)}
onClick={onSubmit}
keybindCombo={{key: 'Enter'}}
data-flx="channel.textarea.textarea-buttons.textarea-button.submit"
/>
</motion.div>
)}
</AnimatePresence>
</div>
)}
{!isMobile && shouldShowDesktopSendButton && (
<>
<div className={styles.divider} data-flx="channel.textarea.textarea-buttons.divider" />
<TextareaButton
disabled={isSlowmodeActive || isOverLimit || (!hasContent && !hasAttachments) || disableSendButton}
disabled={desktopSendDisabled}
icon={PaperPlaneRightIcon}
label={i18n._(SEND_MESSAGE_DESCRIPTOR)}
onClick={onSubmit}
@@ -13,7 +13,6 @@ import {
parseComposerClipboardSlice,
} from '@app/features/lexical/composer/ComposerClipboard';
import type {ComposerHandle} from '@app/features/lexical/composer/ComposerHandle';
import {resolvePastedLinkInsertion} from '@app/features/lexical/composer/ComposerLinkPaste';
import {showAttachmentPermissionDeniedModal} from '@app/features/messaging/components/alerts/AttachmentPermissionDeniedModal';
import {FileSizeTooLargeModal} from '@app/features/messaging/components/alerts/FileSizeTooLargeModal';
import {TooManyAttachmentsModal} from '@app/features/messaging/components/alerts/TooManyAttachmentsModal';
@@ -33,7 +32,7 @@ import {modal} from '@app/features/ui/commands/ModalCommands';
import ContextMenuState from '@app/features/ui/state/ContextMenu';
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
import Users from '@app/features/user/state/Users';
import {$getSelection, $isRangeSelection, COMMAND_PRIORITY_HIGH, PASTE_COMMAND} from 'lexical';
import {COMMAND_PRIORITY_HIGH, PASTE_COMMAND} from 'lexical';
import type React from 'react';
import {useCallback, useEffect} from 'react';
@@ -69,19 +68,6 @@ function createComposerPasteSlice(pastedText: string, segments: ReadonlyArray<Me
return {display: displayParts.join(''), segments: projectedSegments};
}
function $insertLinkAroundSelection(pastedText: string): boolean {
const selection = $getSelection();
if (!$isRangeSelection(selection) || selection.isCollapsed()) {
return false;
}
const replacement = resolvePastedLinkInsertion(pastedText, selection.getTextContent());
if (replacement === null) {
return false;
}
selection.insertText(replacement);
return true;
}
export function useChannelComposerPaste({
channel,
handleRef,
@@ -244,10 +230,6 @@ export function useChannelComposerPaste({
void handlePasteExceedsLimit(pastedText);
return true;
}
if ($insertLinkAroundSelection(pastedText)) {
event.preventDefault();
return true;
}
if (!insertPastedText(pastedText)) {
return false;
}
@@ -125,7 +125,8 @@ export async function toggleSelfMute(_guildId: string | null = null): Promise<vo
}
const room = MediaEngine.room;
const connectedChannelId = MediaEngine.channelId;
const currentMute = LocalVoiceState.getSelfMute();
const microphoneFailureLatched = MediaEngine.isMicrophoneFailureLatched();
const currentMute = LocalVoiceState.getSelfMute() || microphoneFailureLatched;
const currentDeaf = LocalVoiceState.getSelfDeaf();
const willUndeafen = currentDeaf;
const willUnmute = currentMute;
@@ -139,6 +140,7 @@ export async function toggleSelfMute(_guildId: string | null = null): Promise<vo
logger.info('toggleSelfMute', {
currentMute,
currentDeaf,
microphoneFailureLatched,
willUnmute,
willUndeafen,
willMute,
@@ -158,6 +160,7 @@ export async function toggleSelfMute(_guildId: string | null = null): Promise<vo
handleMicrophonePermissionBlocked();
return;
}
MediaEngine.clearMicrophoneFailureLatch();
if (!MediaPermission.isMicrophoneGranted()) {
logger.info('Microphone permission not granted, requesting permission');
const permissionGranted = room?.localParticipant
@@ -175,6 +178,11 @@ export async function toggleSelfMute(_guildId: string | null = null): Promise<vo
return;
}
}
if (!LocalVoiceState.getSelfMute() && !LocalVoiceState.getSelfDeaf()) {
logger.debug('Unmute intent only had to clear the microphone failure latch');
SoundCommands.playSound(SoundType.Unmute);
return;
}
}
LocalVoiceState.toggleSelfMute();
const newMute = LocalVoiceState.getSelfMute();
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ChannelHeaderIcon} from '@app/features/channel/components/channel_header_components/ChannelHeaderIcon';
import {DiscoveryLanguageDropdown} from '@app/features/discovery/discovery/DiscoveryLanguageDropdown';
import Discovery from '@app/features/discovery/state/Discovery';
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
import {useContextMenuTrigger} from '@app/features/ui/hooks/useContextMenuTrigger';
import {getDiscoveryLanguageLabel} from '@app/features/user/utils/LocaleUtils';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {TranslateIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback} from 'react';
const FILTER_BY_LANGUAGE_DESCRIPTOR = msg({
message: 'Filter by language',
comment: 'Label for the discovery language filter button. Keep it concise.',
});
const FILTERING_BY_LANGUAGE_DESCRIPTOR = msg({
message: 'Filtering by {language}',
comment:
'Label for the discovery language filter button once a language is chosen. Preserve {language}; it is inserted by code.',
});
export const DiscoveryLanguageButton = observer(() => {
const {i18n} = useLingui();
const {isOpen, withTracking} = useContextMenuTrigger();
const activeLanguage = Discovery.language;
const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
ContextMenuCommands.openFromElementBottomRight(
event,
({onClose}) => (
<DiscoveryLanguageDropdown
onClose={onClose}
data-flx="discovery.discovery.discovery-language-button.handle-click.discovery-language-dropdown"
/>
),
withTracking(),
);
},
[withTracking],
);
return (
<ChannelHeaderIcon
icon={TranslateIcon}
iconWeight="bold"
label={
activeLanguage == null
? i18n._(FILTER_BY_LANGUAGE_DESCRIPTOR)
: i18n._(FILTERING_BY_LANGUAGE_DESCRIPTOR, {language: getDiscoveryLanguageLabel(activeLanguage)})
}
isSelected={isOpen || activeLanguage != null}
aria-haspopup="menu"
aria-expanded={isOpen}
onClick={handleClick}
data-flx="discovery.discovery.discovery-language-button.channel-header-icon.click"
/>
);
});
@@ -0,0 +1,59 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Discovery from '@app/features/discovery/state/Discovery';
import {ContextMenuCloseProvider} from '@app/features/ui/action_menu/ContextMenu';
import {MenuGroup} from '@app/features/ui/action_menu/MenuGroup';
import {MenuItemRadio} from '@app/features/ui/action_menu/MenuItemRadio';
import {getSortedDiscoveryLanguages} from '@app/features/user/utils/LocaleUtils';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useMemo} from 'react';
const ALL_LANGUAGES_DESCRIPTOR = msg({
message: 'All languages',
comment: 'Option in the discovery language filter that clears the filter. Keep it concise.',
});
interface Props {
onClose: () => void;
}
export const DiscoveryLanguageDropdown: React.FC<Props> = observer(({onClose}) => {
const {i18n} = useLingui();
const languages = useMemo(() => getSortedDiscoveryLanguages(), [i18n.locale]);
const selectLanguage = useCallback(
(language: string | null) => {
if (Discovery.language !== language) {
void Discovery.search({language, offset: 0});
}
onClose();
},
[onClose],
);
return (
<ContextMenuCloseProvider
value={onClose}
data-flx="discovery.discovery.discovery-language-dropdown.context-menu-close-provider"
>
<MenuGroup data-flx="discovery.discovery.discovery-language-dropdown.menu-group">
<MenuItemRadio
label={i18n._(ALL_LANGUAGES_DESCRIPTOR)}
selected={Discovery.language == null}
onSelect={() => selectLanguage(null)}
data-flx="discovery.discovery.discovery-language-dropdown.menu-item-radio.all"
/>
{languages.map((language) => (
<MenuItemRadio
key={language.code}
label={language.label}
selected={Discovery.language === language.code}
onSelect={() => selectLanguage(language.code)}
data-flx="discovery.discovery.discovery-language-dropdown.menu-item-radio.language"
/>
))}
</MenuGroup>
</ContextMenuCloseProvider>
);
});
@@ -114,6 +114,14 @@
}
}
.searchArea {
display: flex;
align-items: center;
gap: var(--spacing-2);
flex: 0 0 auto;
min-width: 0;
}
.searchForm {
display: flex;
align-items: center;
@@ -4,6 +4,7 @@ import {reportSkeletonDiscoveryCategoryTabs} from '@app/features/app/components/
import {measureSkeletonWidthPx, useSkeletonLayoutReport} from '@app/features/app/hooks/useSkeletonLayoutMemoryCapture';
import {ChannelHeaderIcon} from '@app/features/channel/components/channel_header_components/ChannelHeaderIcon';
import searchBarStyles from '@app/features/channel/components/message_search_bar/MessageSearchBar.module.css';
import {DiscoveryLanguageButton} from '@app/features/discovery/discovery/DiscoveryLanguageButton';
import styles from '@app/features/discovery/discovery/DiscoveryNavbar.module.css';
import {DiscoveryMotionKind, DiscoveryTransition} from '@app/features/discovery/discovery/DiscoveryTransition';
import Discovery from '@app/features/discovery/state/Discovery';
@@ -251,51 +252,54 @@ export const DiscoveryNavbar = observer(function DiscoveryNavbar({
)}
</DiscoveryTransition>
</div>
<form
className={styles.searchForm}
role="search"
aria-label={i18n._(DISCOVERY_SEARCH_DESCRIPTOR)}
onSubmit={handleSubmit}
data-flx="discovery.discovery.discovery-navbar.search-form.submit"
>
<div className={searchBarStyles.inputContainer} data-flx="discovery.discovery.discovery-navbar.div">
<MagnifyingGlassIcon
className={clsx(searchBarStyles.searchIcon, styles.searchIcon)}
weight="bold"
aria-hidden
data-flx="discovery.discovery.discovery-navbar.search-icon"
/>
<input
ref={searchInputRef}
className={styles.searchInput}
type="search"
aria-label={i18n._(SEARCH_COMMUNITIES_DESCRIPTOR)}
aria-controls={resultsRegionId}
placeholder={i18n._(SEARCH_COMMUNITIES_DESCRIPTOR)}
value={draftQuery}
onChange={(event) => setDraftQuery(event.target.value)}
data-flx="discovery.discovery.discovery-navbar.search-input.set-draft-query"
/>
{draftQuery.length > 0 && (
<FocusRing offset={-2} data-flx="discovery.discovery.discovery-navbar.focus-ring">
<button
type="button"
className={searchBarStyles.clearButton}
aria-label={i18n._(CLEAR_SEARCH_DESCRIPTOR)}
onClick={handleClear}
data-flx="discovery.discovery.discovery-navbar.button.clear"
>
<XIcon
weight="bold"
className={searchBarStyles.optionMetaIcon}
aria-hidden
data-flx="discovery.discovery.discovery-navbar.x-icon"
/>
</button>
</FocusRing>
)}
</div>
</form>
<div className={styles.searchArea} data-flx="discovery.discovery.discovery-navbar.search-area">
<form
className={styles.searchForm}
role="search"
aria-label={i18n._(DISCOVERY_SEARCH_DESCRIPTOR)}
onSubmit={handleSubmit}
data-flx="discovery.discovery.discovery-navbar.search-form.submit"
>
<div className={searchBarStyles.inputContainer} data-flx="discovery.discovery.discovery-navbar.div">
<MagnifyingGlassIcon
className={clsx(searchBarStyles.searchIcon, styles.searchIcon)}
weight="bold"
aria-hidden
data-flx="discovery.discovery.discovery-navbar.search-icon"
/>
<input
ref={searchInputRef}
className={styles.searchInput}
type="search"
aria-label={i18n._(SEARCH_COMMUNITIES_DESCRIPTOR)}
aria-controls={resultsRegionId}
placeholder={i18n._(SEARCH_COMMUNITIES_DESCRIPTOR)}
value={draftQuery}
onChange={(event) => setDraftQuery(event.target.value)}
data-flx="discovery.discovery.discovery-navbar.search-input.set-draft-query"
/>
{draftQuery.length > 0 && (
<FocusRing offset={-2} data-flx="discovery.discovery.discovery-navbar.focus-ring">
<button
type="button"
className={searchBarStyles.clearButton}
aria-label={i18n._(CLEAR_SEARCH_DESCRIPTOR)}
onClick={handleClear}
data-flx="discovery.discovery.discovery-navbar.button.clear"
>
<XIcon
weight="bold"
className={searchBarStyles.optionMetaIcon}
aria-hidden
data-flx="discovery.discovery.discovery-navbar.x-icon"
/>
</button>
</FocusRing>
)}
</div>
</form>
<DiscoveryLanguageButton data-flx="discovery.discovery.discovery-navbar.discovery-language-button" />
</div>
</div>
);
});
File diff suppressed because it is too large Load Diff
@@ -1380,6 +1380,9 @@
{
"msgid": "File ordering"
},
{
"msgid": "Filtering by {language}"
},
{
"msgid": "Finish"
},
@@ -1407,6 +1410,9 @@
{
"msgid": "Granted"
},
{
"msgid": "Help translate {PRODUCT_NAME} into your language on <0>{I18N_WEBLATE_DOMAIN}</0>."
},
{
"msgid": "How much of the member's recent message history to delete."
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {resolvePastedLinkInsertion} from '@app/features/lexical/composer/ComposerLinkPaste';
import {
$createComposerSegmentNodes,
$projectComposer,
@@ -8,6 +9,7 @@ import {
import {$captureSelectionOffsets, $selectComposerOffset} from '@app/features/lexical/composer/composerOffsets';
import {$isSlashSlotNode, type SlashSlotNode} from '@app/features/lexical/composer/nodes/SlashSlotNode';
import {COMPOSER_SLASH_SLOT_STATE_MAX_ID_LENGTH} from '@app/features/lexical/composer/SlashSlotPersistence';
import {ParserFlags} from '@app/features/messaging/utils/markdown/parser/Enums';
import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager';
import {mergeRegister} from '@lexical/utils';
import {
@@ -21,6 +23,7 @@ import {
$isNodeSelection,
$isRangeSelection,
$isTextNode,
COMMAND_PRIORITY_CRITICAL,
COMMAND_PRIORITY_NORMAL,
COPY_COMMAND,
CUT_COMMAND,
@@ -60,6 +63,7 @@ export interface ComposerClipboardSelection extends ComposerClipboardSlice {
export interface ComposerClipboardCommandState {
getPlainText(): boolean;
isEditable(): boolean;
getMarkdownParserFlags(): number;
}
interface SerializedComposerClipboardPayload {
@@ -460,6 +464,34 @@ export function registerComposerClipboardCommands(
state: ComposerClipboardCommandState,
): () => void {
return mergeRegister(
editor.registerCommand(
PASTE_COMMAND,
(event) => {
if (!state.isEditable() || (state.getMarkdownParserFlags() & ParserFlags.ALLOW_MASKED_LINKS) === 0) {
return false;
}
const clipboardEvent = getClipboardEvent(event);
if (clipboardEvent == null || clipboardEvent.clipboardData == null) {
return false;
}
const selection = $getSelection();
if (!$isRangeSelection(selection) || selection.isCollapsed()) {
return false;
}
const replacement = resolvePastedLinkInsertion(
clipboardEvent.clipboardData.getData('text/plain'),
selection.getTextContent(),
);
if (replacement === null) {
return false;
}
selection.insertText(replacement);
$addUpdateTag(PASTE_TAG);
clipboardEvent.preventDefault();
return true;
},
COMMAND_PRIORITY_CRITICAL,
),
editor.registerCommand(
COPY_COMMAND,
(event) => {
@@ -6,6 +6,10 @@ import {
parsePastedUrl,
resolvePastedLinkInsertion,
} from '@app/features/lexical/composer/ComposerLinkPaste';
import {DEFAULT_COMPOSER_MARKDOWN_FLAGS} from '@app/features/lexical/composer/markdownSpans';
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
import {getParserFlagsForContext} from '@app/features/messaging/utils/markdown/MarkdownParserFlags';
import {ParserFlags} from '@app/features/messaging/utils/markdown/parser/Enums';
import {describe, expect, it} from 'vitest';
describe('parsePastedUrl', () => {
@@ -87,3 +91,16 @@ describe('resolvePastedLinkInsertion', () => {
expect(resolvePastedLinkInsertion('javascript:alert(1)', 'click me')).toBeNull();
});
});
describe('masked link support across composer surfaces', () => {
const surfaces: Array<[string, number]> = [
['channel composer', DEFAULT_COMPOSER_MARKDOWN_FLAGS],
['message edit / forward', getParserFlagsForContext(MarkdownContext.STANDARD_WITH_JUMBO)],
['channel topic', getParserFlagsForContext(MarkdownContext.STANDARD_WITHOUT_JUMBO)],
['user bio', getParserFlagsForContext(MarkdownContext.RESTRICTED_USER_BIO)],
];
it.each(surfaces)('%s allows masked links, so pasting a link must wrap', (_name, flags) => {
expect(flags & ParserFlags.ALLOW_MASKED_LINKS).not.toBe(0);
});
});
@@ -33,6 +33,7 @@ import {
$wrapComposerSelection,
} from '@app/features/lexical/composer/composerOffsets';
import styles from '@app/features/lexical/composer/LexicalMessageComposer.module.css';
import {DEFAULT_COMPOSER_MARKDOWN_FLAGS} from '@app/features/lexical/composer/markdownSpans';
import {ComposerCommandNode} from '@app/features/lexical/composer/nodes/ComposerCommandNode';
import {ComposerCustomEmojiNode} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode';
import {ComposerMentionNode} from '@app/features/lexical/composer/nodes/ComposerMentionNode';
@@ -263,6 +264,8 @@ const ComposerInner = ({
submitOnEnterRef.current = submitOnEnter;
const plainTextRef = useRef(plainText);
plainTextRef.current = plainText;
const markdownParserFlagsRef = useRef(markdownParserFlags ?? DEFAULT_COMPOSER_MARKDOWN_FLAGS);
markdownParserFlagsRef.current = markdownParserFlags ?? DEFAULT_COMPOSER_MARKDOWN_FLAGS;
const disabledRef = useRef(disabled);
disabledRef.current = disabled;
const onSlashCommandStateChangeRef = useRef(onSlashCommandStateChange);
@@ -607,6 +610,7 @@ const ComposerInner = ({
registerComposerClipboardCommands(editor, {
getPlainText: () => plainTextRef.current,
isEditable: () => !disabledRef.current && editor.isEditable(),
getMarkdownParserFlags: () => markdownParserFlagsRef.current,
}),
[editor],
);
@@ -152,8 +152,8 @@ export class ComposerCustomEmojiNode extends DecoratorNode<JSX.Element> {
return true;
}
override isKeyboardSelectable(): boolean {
return true;
override isKeyboardSelectable(): false {
return false;
}
override decorate(): JSX.Element {
@@ -184,8 +184,8 @@ export class ComposerMentionNode extends DecoratorNode<JSX.Element> {
return true;
}
override isKeyboardSelectable(): boolean {
return true;
override isKeyboardSelectable(): false {
return false;
}
override decorate(): JSX.Element {
@@ -143,8 +143,8 @@ export class ComposerStandardEmojiNode extends DecoratorNode<JSX.Element> {
return true;
}
override isKeyboardSelectable(): boolean {
return true;
override isKeyboardSelectable(): false {
return false;
}
override decorate(): JSX.Element {
@@ -834,7 +834,7 @@ export async function forward(
logger.warn(`Forward send failed in channel ${channelId}`);
return false;
}
SlowmodeCommands.recordMessageSend(channelId);
SlowmodeCommands.confirmMessageSend(channelId, forwardedMessage.timestamp);
if (optionalMessage) {
const commentNonce = SnowflakeUtils.fromTimestamp(Date.now() + 1);
const commentMessage = await send(channelId, {
@@ -845,7 +845,7 @@ export async function forward(
logger.warn(`Forward comment send failed in channel ${channelId}`);
return false;
}
SlowmodeCommands.recordMessageSend(channelId);
SlowmodeCommands.confirmMessageSend(channelId, commentMessage.timestamp);
}
}
logger.debug('Successfully forwarded message to all channels');
@@ -24,6 +24,7 @@ import {
normalizeMessageContent,
} from '@app/features/messaging/utils/MessageRequestUtils';
import * as MessageSubmitUtils from '@app/features/messaging/utils/MessageSubmitUtils';
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
import {MatureContentRejectedModal} from '@app/features/moderation/components/alerts/MatureContentRejectedModal';
import {http} from '@app/features/platform/transport/RestTransport';
import {HttpError} from '@app/features/platform/types/EndpointError';
@@ -66,9 +67,7 @@ type ScheduledMessageRequest = MessageCreateRequest & {
interface ApiErrorBody {
code?: number | string;
retry_after?: number;
message?: string;
details?: unknown;
}
export interface ScheduleMessageParams {
@@ -372,37 +371,6 @@ const getApiErrorBody = (error: HttpError): ApiErrorBody | undefined => {
return typeof error.body === 'object' && error.body !== null ? (error.body as ApiErrorBody) : undefined;
};
function parseNestedRetryAfterSeconds(body: ApiErrorBody | undefined): number | undefined {
if (body === undefined || typeof body.details !== 'object' || body.details === null || Array.isArray(body.details)) {
return undefined;
}
const details = body.details as Record<string, unknown>;
const retry = details.retry;
if (typeof retry !== 'object' || retry === null || Array.isArray(retry)) return undefined;
const afterSeconds = (retry as Record<string, unknown>).after_seconds;
if (typeof afterSeconds !== 'number' || !Number.isFinite(afterSeconds) || afterSeconds <= 0) return undefined;
return afterSeconds;
}
function resolveRetryAfterSeconds(error: HttpError): number | undefined {
const body = getApiErrorBody(error);
const nestedRetryAfter = parseNestedRetryAfterSeconds(body);
if (nestedRetryAfter !== undefined) return nestedRetryAfter;
const bodyRetryAfter = body === undefined ? undefined : body.retry_after;
if (typeof bodyRetryAfter === 'number' && Number.isFinite(bodyRetryAfter) && bodyRetryAfter > 0) {
return bodyRetryAfter;
}
const responseHeaders: Record<string, string> | undefined = error.responseHeaders;
const header = responseHeaders === undefined ? undefined : responseHeaders['retry-after'];
if (header === undefined || header.trim() === '') return undefined;
const numeric = Number(header);
if (Number.isFinite(numeric) && numeric > 0) return numeric;
const deadline = Date.parse(header);
if (!Number.isFinite(deadline)) return undefined;
const remainingSeconds = (deadline - Date.now()) / 1000;
return remainingSeconds > 0 ? remainingSeconds : undefined;
}
function handleScheduleError(
i18n: I18n,
error: unknown,
@@ -430,8 +398,7 @@ function handleScheduleError(
return;
}
if (isSlowmodeError(error)) {
const retryAfterSeconds = resolveRetryAfterSeconds(error);
const retryAfterMs = SlowmodeCommands.retryAfterSecondsToMs(retryAfterSeconds);
const retryAfterMs = SlowmodeCommands.clampSlowmodeRetryAfterMs(resolveRetryAfterMs(error));
if (retryAfterMs <= 0) {
ModalCommands.push(
modal(() => (
@@ -490,8 +457,8 @@ function handleScheduleError(
}
function handleScheduleRateLimit(_i18n: I18n, error: HttpError): void {
const retryAfterSecondsValue = resolveRetryAfterSeconds(error);
const retryAfterSeconds = retryAfterSecondsValue === undefined ? undefined : Math.ceil(retryAfterSecondsValue);
const retryAfterMs = resolveRetryAfterMs(error);
const retryAfterSeconds = retryAfterMs === null ? undefined : Math.ceil(retryAfterMs / 1000);
ModalCommands.push(
modal(() => (
<MessageSendTooQuickModal
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils';
import {useEffect, useState} from 'react';
export function useCachedImageLoaded(src: string | null | undefined): boolean {
const resolved = src ?? null;
const [readySrc, setReadySrc] = useState<string | null>(() =>
resolved !== null && ImageCacheUtils.hasImage(resolved) ? resolved : null,
);
useEffect(() => {
if (resolved === null) {
return;
}
let active = true;
const markReady = (): void => {
if (active) setReadySrc(resolved);
};
const cleanup = ImageCacheUtils.loadImage(resolved, markReady, markReady);
return () => {
active = false;
cleanup();
};
}, [resolved]);
return resolved !== null && readySrc === resolved;
}
@@ -131,6 +131,7 @@ export const useMessageSubmission = ({channel, referencedMessage, replyingMessag
referenced_message: referencedMessage?.toJSON(),
});
SlowmodeCommands.prepareMessageSend(channel.id);
const pendingSend = SlowmodeCommands.recordPendingMessageSend(channel.id);
void MessageCommands.send(channel.id, {
content: message.content,
nonce,
@@ -141,11 +142,17 @@ export const useMessageSubmission = ({channel, referencedMessage, replyingMessag
stickers,
favoriteMemeId,
tts,
}).then((sentMessage) => {
if (sentMessage) {
SlowmodeCommands.recordMessageSend(channel.id);
}
});
})
.then((sentMessage) => {
if (sentMessage) {
SlowmodeCommands.confirmMessageSend(channel.id, sentMessage.timestamp, pendingSend);
return;
}
SlowmodeCommands.discardPendingMessageSend(channel.id, pendingSend);
})
.catch(() => {
SlowmodeCommands.discardPendingMessageSend(channel.id, pendingSend);
});
ComponentDispatch.dispatch('MESSAGE_SENT', {channelId: channel.id});
return true;
},
@@ -196,6 +203,7 @@ export const useMessageSubmission = ({channel, referencedMessage, replyingMessag
});
SlowmodeCommands.prepareMessageSend(channel.id);
const allowedMentions: AllowedMentions = {replied_user: replyingMessage?.mentioning ?? true};
const pendingSend = SlowmodeCommands.recordPendingMessageSend(channel.id);
void MessageCommands.send(channel.id, {
content: messageData.content,
nonce,
@@ -207,11 +215,17 @@ export const useMessageSubmission = ({channel, referencedMessage, replyingMessag
flags: 0,
stickers: messageData.stickers || [],
favoriteMemeId: sendOptions.favoriteMemeId,
}).then((sentMessage) => {
if (sentMessage) {
SlowmodeCommands.recordMessageSend(channel.id);
}
});
})
.then((sentMessage) => {
if (sentMessage) {
SlowmodeCommands.confirmMessageSend(channel.id, sentMessage.timestamp, pendingSend);
return;
}
SlowmodeCommands.discardPendingMessageSend(channel.id, pendingSend);
})
.catch(() => {
SlowmodeCommands.discardPendingMessageSend(channel.id, pendingSend);
});
ComponentDispatch.dispatch('MESSAGE_SENT', {channelId: channel.id});
},
[channel?.id, referencedMessage, replyingMessage],
@@ -38,6 +38,7 @@ import {
type MessageEditRequest,
normalizeMessageEditContent,
} from '@app/features/messaging/utils/MessageRequestUtils';
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
import {MatureContentRejectedModal} from '@app/features/moderation/components/alerts/MatureContentRejectedModal';
import {http} from '@app/features/platform/transport/RestTransport';
import {HttpError} from '@app/features/platform/types/EndpointError';
@@ -141,9 +142,7 @@ export interface RetryError {
export interface ApiErrorBody {
code?: number | string;
retry_after?: number;
message?: string;
details?: unknown;
}
interface PresignedAttachmentUploadSinglepartResponse {
@@ -225,70 +224,25 @@ const getApiErrorBody = (error: HttpError): ApiErrorBody | undefined => {
};
interface MessageRateLimitRetry {
retryAfterSeconds: number | null;
retryAfterMs: number | null;
automaticRetryDelayMs: number | null;
}
function parsePositiveRetryAfterSeconds(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null;
return value;
}
function parseRetryAfterHeaderSeconds(value: string | undefined): number | null {
if (value === undefined || value.trim() === '') return null;
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric > 0) return numeric;
const deadline = Date.parse(value);
if (!Number.isFinite(deadline)) return null;
const remainingSeconds = (deadline - Date.now()) / 1000;
return remainingSeconds > 0 ? remainingSeconds : null;
}
function parseNestedRetryAfterSeconds(body: ApiErrorBody | undefined): number | null {
if (body === undefined || typeof body.details !== 'object' || body.details === null || Array.isArray(body.details)) {
return null;
}
const details = body.details as Record<string, unknown>;
const retry = details.retry;
if (typeof retry !== 'object' || retry === null || Array.isArray(retry)) return null;
const afterSeconds = (retry as Record<string, unknown>).after_seconds;
if (typeof afterSeconds !== 'number' || !Number.isFinite(afterSeconds) || afterSeconds <= 0) return null;
return afterSeconds;
}
function readRateLimitHeader(error: HttpError, name: string): string | undefined {
return error.responseHeaders[name.toLowerCase()];
}
function resolveMessageRateLimitRetry(error: HttpError): MessageRateLimitRetry {
const body = getApiErrorBody(error);
const candidates: Array<number> = [];
const nestedRetryAfter = parseNestedRetryAfterSeconds(body);
if (nestedRetryAfter !== null) candidates.push(nestedRetryAfter);
const bodyRetryAfter = body === undefined ? undefined : body.retry_after;
const parsedBodyRetryAfter = parsePositiveRetryAfterSeconds(bodyRetryAfter);
if (parsedBodyRetryAfter !== null) candidates.push(parsedBodyRetryAfter);
const parsedHeaderRetryAfter = parseRetryAfterHeaderSeconds(readRateLimitHeader(error, 'retry-after'));
if (parsedHeaderRetryAfter !== null) candidates.push(parsedHeaderRetryAfter);
const resetAfterHeader = readRateLimitHeader(error, 'x-ratelimit-reset-after');
if (resetAfterHeader !== undefined) {
const parsedResetAfter = parsePositiveRetryAfterSeconds(Number(resetAfterHeader));
if (parsedResetAfter !== null) candidates.push(parsedResetAfter);
}
if (candidates.length === 0) {
return {retryAfterSeconds: null, automaticRetryDelayMs: null};
}
const rawRetryAfter = Math.max(...candidates);
const retryAfterSeconds = Math.ceil(rawRetryAfter);
const retryAfterMs = Math.ceil(rawRetryAfter * 1000);
if (!Number.isSafeInteger(retryAfterSeconds) || !Number.isSafeInteger(retryAfterMs)) {
return {retryAfterSeconds: null, automaticRetryDelayMs: null};
const retryAfterMs = resolveRetryAfterMs(error);
if (retryAfterMs === null) {
return {retryAfterMs: null, automaticRetryDelayMs: null};
}
let automaticRetryDelayMs: number | null = null;
if (retryAfterMs <= MESSAGE_SEND_RATE_LIMIT_MAX_AUTOMATIC_DELAY_MS) {
automaticRetryDelayMs = retryAfterMs;
}
return {retryAfterSeconds, automaticRetryDelayMs};
return {retryAfterMs, automaticRetryDelayMs};
}
function retryAfterMsToWholeSeconds(retryAfterMs: number | null): number | null {
if (retryAfterMs === null) return null;
return Math.ceil(retryAfterMs / 1000);
}
const isAbortError = (error: unknown): boolean => {
return error instanceof DOMException && error.name === 'AbortError';
@@ -1344,7 +1298,7 @@ export class MessageQueue extends Queue<MessageQueuePayload, RestResponse<Messag
this.restoreFailedMessage(payload.channelId, payload.nonce);
}
completed(null, undefined, error);
this.handleRateLimitError(retry.retryAfterSeconds);
this.handleRateLimitError(retryAfterMsToWholeSeconds(retry.retryAfterMs));
}
private handleSendError(
@@ -1425,9 +1379,7 @@ export class MessageQueue extends Queue<MessageQueuePayload, RestResponse<Messag
);
} else if (error instanceof HttpError && isSlowmodeError(error)) {
const retry = resolveMessageRateLimitRetry(error);
const retryAfterMs = SlowmodeCommands.retryAfterSecondsToMs(
retry.retryAfterSeconds === null ? undefined : retry.retryAfterSeconds,
);
const retryAfterMs = SlowmodeCommands.clampSlowmodeRetryAfterMs(retry.retryAfterMs);
if (retryAfterMs <= 0) {
ModalCommands.push(
modal(() => (
@@ -1579,7 +1531,7 @@ export class MessageQueue extends Queue<MessageQueuePayload, RestResponse<Messag
return;
}
completed(null, undefined, error);
this.handleEditRateLimitError(retry.retryAfterSeconds);
this.handleEditRateLimitError(retryAfterMsToWholeSeconds(retry.retryAfterMs));
}
private showEditErrorModal(error: HttpError): void {
@@ -0,0 +1,100 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils';
import {afterEach, describe, expect, it, vi} from 'vitest';
const IMAGE_RETRY_CEILING_MS = 20_000;
afterEach(() => {
ImageCacheUtils._clearForTests();
});
describe('loadImage rejection path', () => {
it('reports an unusable source through onError so callers can fall back', () => {
const onLoad = vi.fn();
const onError = vi.fn();
ImageCacheUtils.loadImage(null, onLoad, onError);
expect(onLoad).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalledTimes(1);
});
it('reports an oversized source through onError rather than queueing it', () => {
const onLoad = vi.fn();
const onError = vi.fn();
ImageCacheUtils.loadImage(`https://cdn.test/${'a'.repeat(32 * 1024)}.png`, onLoad, onError);
expect(onLoad).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalledTimes(1);
});
it('returns a no-op disposer when it rejects, so cleanup stays safe', () => {
const dispose = ImageCacheUtils.loadImage(null, vi.fn(), vi.fn());
expect(() => dispose()).not.toThrow();
});
});
describe('image load retry', () => {
class StubImage {
static instances: Array<StubImage> = [];
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
decoding = 'auto';
naturalWidth = 0;
naturalHeight = 0;
#src = '';
constructor() {
StubImage.instances.push(this);
}
get src(): string {
return this.#src;
}
set src(value: string) {
this.#src = value;
}
fail(): void {
this.onerror?.();
}
}
const withStubImage = (run: () => void): void => {
const original = globalThis.Image;
StubImage.instances = [];
globalThis.Image = StubImage as unknown as typeof Image;
try {
run();
} finally {
globalThis.Image = original;
}
};
it('retries a failed load instead of reporting failure immediately', () => {
vi.useFakeTimers();
withStubImage(() => {
const onError = vi.fn();
ImageCacheUtils.loadImage('https://cdn.test/a.png', vi.fn(), onError);
const first = StubImage.instances.at(-1);
expect(first).toBeDefined();
first?.fail();
expect(onError).not.toHaveBeenCalled();
vi.advanceTimersByTime(600);
expect(StubImage.instances.length).toBeGreaterThan(1);
});
vi.useRealTimers();
});
it('gives up after a bounded number of attempts and reports the failure once', () => {
vi.useFakeTimers();
withStubImage(() => {
const onError = vi.fn();
ImageCacheUtils.loadImage('https://cdn.test/b.png', vi.fn(), onError);
for (let attempt = 0; attempt < 12; attempt++) {
StubImage.instances.at(-1)?.fail();
vi.advanceTimersByTime(IMAGE_RETRY_CEILING_MS);
}
expect(StubImage.instances.length).toBeGreaterThan(1);
expect(StubImage.instances.length).toBeLessThanOrEqual(6);
expect(onError).toHaveBeenCalledTimes(1);
});
vi.useRealTimers();
});
});
@@ -24,6 +24,9 @@ interface PendingImageLoad {
subscribers: Set<PendingImageSubscriber>;
active: boolean;
timeoutId: number;
failedAttempts: number;
retryTimeoutId: number;
connectivityListener: (() => void) | null;
}
const MAX_CACHE_ENTRIES = 500;
@@ -37,6 +40,9 @@ const MAX_PENDING_IMAGE_CALLBACKS_PER_LOAD = 256;
const MAX_IMAGE_SOURCE_LENGTH = 16 * 1024;
const IMAGE_LOAD_TIMEOUT_MS = 30_000;
const IMAGE_LOAD_ACTIVATION_TIMEOUT_MS = 10_000;
const IMAGE_RETRY_ATTEMPT_LIMIT = 5;
const IMAGE_RETRY_INITIAL_DELAY_MS = 500;
const IMAGE_RETRY_MAX_DELAY_MS = 15_000;
const getDimensionByteSize = (width: number, height: number): number | null => {
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) {
@@ -169,10 +175,57 @@ function releaseActiveImageLoad(): void {
if (activeImageLoadCount < 0) throw new Error('Active image load count became negative');
}
function clearRetryState(pendingLoad: PendingImageLoad): void {
window.clearTimeout(pendingLoad.retryTimeoutId);
pendingLoad.retryTimeoutId = 0;
if (pendingLoad.connectivityListener != null) {
window.removeEventListener('online', pendingLoad.connectivityListener);
pendingLoad.connectivityListener = null;
}
}
function retryDelayForAttempt(failedAttempts: number): number {
return Math.min(IMAGE_RETRY_INITIAL_DELAY_MS * 2 ** (failedAttempts - 1), IMAGE_RETRY_MAX_DELAY_MS);
}
function scheduleImageLoadRetry(src: string, pendingLoad: PendingImageLoad): boolean {
if (pendingLoad.failedAttempts >= IMAGE_RETRY_ATTEMPT_LIMIT) return false;
pendingLoad.failedAttempts += 1;
window.clearTimeout(pendingLoad.timeoutId);
pendingLoad.timeoutId = 0;
if (pendingLoad.image != null) {
pendingLoad.image.onload = null;
pendingLoad.image.onerror = null;
pendingLoad.image.src = 'data:,';
pendingLoad.image = null;
}
if (pendingLoad.active) {
pendingLoad.active = false;
releaseActiveImageLoad();
}
const resume = (): void => {
clearRetryState(pendingLoad);
if (pendingImageLoads.get(src) !== pendingLoad) return;
pendingLoad.timeoutId = window.setTimeout(() => {
settlePendingImageLoad(src, pendingLoad, false, null);
}, IMAGE_LOAD_ACTIVATION_TIMEOUT_MS);
pumpPendingImageLoads();
};
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
pendingLoad.connectivityListener = resume;
window.addEventListener('online', resume, {once: true});
} else {
pendingLoad.retryTimeoutId = window.setTimeout(resume, retryDelayForAttempt(pendingLoad.failedAttempts));
}
pumpPendingImageLoads();
return true;
}
function detachPendingImageLoad(src: string, pendingLoad: PendingImageLoad): boolean {
if (pendingImageLoads.get(src) !== pendingLoad) return false;
pendingImageLoads.delete(src);
window.clearTimeout(pendingLoad.timeoutId);
clearRetryState(pendingLoad);
if (pendingLoad.image != null) {
pendingLoad.image.onload = null;
pendingLoad.image.onerror = null;
@@ -240,6 +293,7 @@ function activatePendingImageLoad(src: string, pendingLoad: PendingImageLoad): v
image.onerror = () => {
if (pendingImageLoads.get(src) !== pendingLoad) return;
imageCache.delete(src);
if (scheduleImageLoadRetry(src, pendingLoad)) return;
settlePendingImageLoad(src, pendingLoad, false, null);
};
image.src = src;
@@ -259,6 +313,9 @@ function createPendingImageLoad(src: string): PendingImageLoad {
subscribers: new Set(),
active: false,
timeoutId: 0,
failedAttempts: 0,
retryTimeoutId: 0,
connectivityListener: null,
};
pendingLoad.timeoutId = window.setTimeout(() => {
settlePendingImageLoad(src, pendingLoad, false, null);
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
import {HttpError} from '@app/features/platform/types/EndpointError';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {describe, expect, it} from 'vitest';
function slowmodeRejection(body: unknown, responseHeaders: Record<string, string>): HttpError {
return new HttpError({
method: 'POST',
path: '/channels/1234567890123456789/messages',
status: 400,
body,
responseHeaders,
});
}
describe('resolveRetryAfterMs', () => {
it('uses the decimal seconds from the body instead of the Retry-After header', () => {
const error = slowmodeRejection(
{code: APIErrorCodes.SLOWMODE_RATE_LIMITED, retry_after: 4.44},
{'retry-after': '5'},
);
expect(resolveRetryAfterMs(error)).toBe(4440);
});
it('never multiplies a millisecond Retry-After header into a longer window than the body', () => {
const error = slowmodeRejection(
{code: APIErrorCodes.SLOWMODE_RATE_LIMITED, retry_after: 4.7},
{'retry-after': '4700'},
);
expect(resolveRetryAfterMs(error)).toBe(4700);
});
it('prefers a nested retry window over every other source', () => {
const error = slowmodeRejection(
{code: APIErrorCodes.SLOWMODE_RATE_LIMITED, retry_after: 30, details: {retry: {after_seconds: 2.5}}},
{'retry-after': '30'},
);
expect(resolveRetryAfterMs(error)).toBe(2500);
});
it('falls back to the Retry-After header when the body carries no window', () => {
const error = slowmodeRejection({code: APIErrorCodes.SLOWMODE_RATE_LIMITED}, {'retry-after': '3'});
expect(resolveRetryAfterMs(error)).toBe(3000);
});
it('falls back to the reset-after header when nothing else is present', () => {
const error = slowmodeRejection({code: APIErrorCodes.RATE_LIMITED}, {'x-ratelimit-reset-after': '1.25'});
expect(resolveRetryAfterMs(error)).toBe(1250);
});
it('reads an HTTP date Retry-After header as a remaining duration', () => {
const deadline = new Date(Date.now() + 4000).toUTCString();
const error = slowmodeRejection({code: APIErrorCodes.SLOWMODE_RATE_LIMITED}, {'retry-after': deadline});
const retryAfterMs = resolveRetryAfterMs(error);
expect(retryAfterMs).not.toBeNull();
expect(retryAfterMs!).toBeGreaterThan(0);
expect(retryAfterMs!).toBeLessThanOrEqual(4000);
});
it('returns null when no retry window is advertised', () => {
expect(resolveRetryAfterMs(slowmodeRejection({code: APIErrorCodes.SLOWMODE_RATE_LIMITED}, {}))).toBeNull();
});
});
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {HttpError} from '@app/features/platform/types/EndpointError';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function parseSeconds(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null;
return value;
}
function parseNestedSeconds(body: Record<string, unknown> | undefined): number | null {
if (body === undefined || !isRecord(body.details)) return null;
const retry = body.details.retry;
if (!isRecord(retry)) return null;
return parseSeconds(retry.after_seconds);
}
function parseHeaderSeconds(value: string | undefined): number | null {
if (value === undefined || value.trim() === '') return null;
const numeric = Number(value);
if (Number.isFinite(numeric)) return parseSeconds(numeric);
const deadline = Date.parse(value);
if (!Number.isFinite(deadline)) return null;
return parseSeconds((deadline - Date.now()) / 1000);
}
function resolveRetryAfterSeconds(error: HttpError): number | null {
const body = isRecord(error.body) ? error.body : undefined;
const nestedSeconds = parseNestedSeconds(body);
if (nestedSeconds !== null) return nestedSeconds;
const bodySeconds = body === undefined ? null : parseSeconds(body.retry_after);
if (bodySeconds !== null) return bodySeconds;
const headerSeconds = parseHeaderSeconds(error.responseHeaders['retry-after']);
if (headerSeconds !== null) return headerSeconds;
return parseHeaderSeconds(error.responseHeaders['x-ratelimit-reset-after']);
}
export function resolveRetryAfterMs(error: HttpError): number | null {
const retryAfterSeconds = resolveRetryAfterSeconds(error);
if (retryAfterSeconds === null) return null;
const retryAfterMs = Math.ceil(retryAfterSeconds * 1000);
if (!Number.isSafeInteger(retryAfterMs)) return null;
return retryAfterMs;
}
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {VoiceEngineV2BridgeApi} from '@fluxer/voice_engine_v2/bridge';
import type {VoiceEngineV2BridgeHardwareEncoderApi} from '@fluxer/voice_engine_v2/bridge';
import type {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
@@ -440,7 +440,7 @@ export interface ElectronAPI {
selectDisplayMediaSource: (requestId: string, sourceId: string | null, withAudio: boolean) => void;
virtmic: VirtmicApi;
nativeAudio: NativeAudioApi;
voiceEngine?: VoiceEngineV2BridgeApi;
voiceEngine?: VoiceEngineV2BridgeHardwareEncoderApi;
}
export type VirtmicUnavailableReason =
@@ -6,32 +6,47 @@ import {CHANNEL_RATE_LIMIT_PER_USER_MAX} from '@fluxer/constants/src/LimitConsta
const MAX_RETRY_AFTER_MS = CHANNEL_RATE_LIMIT_PER_USER_MAX * 1000;
function clearSendScopedSticker(channelId: string): void {
ChannelSticker.clearPendingStickerOnMessageSend(channelId);
export interface PendingMessageSend {
readonly previousSendTimestamp: number | null;
readonly pendingSendTimestamp: number;
}
function markSlowmodeSend(channelId: string): void {
Slowmode.recordMessageSend(channelId);
function clearSendScopedSticker(channelId: string): void {
ChannelSticker.clearPendingStickerOnMessageSend(channelId);
}
export function prepareMessageSend(channelId: string): void {
clearSendScopedSticker(channelId);
}
export function recordMessageSend(channelId: string): void {
markSlowmodeSend(channelId);
export function recordPendingMessageSend(channelId: string): PendingMessageSend {
const previousSendTimestamp = Slowmode.getLastSendTimestamp(channelId);
const pendingSendTimestamp = Slowmode.recordMessageSend(channelId);
return {previousSendTimestamp, pendingSendTimestamp};
}
export function confirmMessageSend(channelId: string, sentAt: string, pending?: PendingMessageSend): void {
const timestamp = Date.parse(sentAt);
if (!Number.isFinite(timestamp)) return;
const floor = pending?.pendingSendTimestamp;
const anchored = floor == null ? timestamp : Math.max(timestamp, floor);
Slowmode.updateSlowmodeTimestamp(channelId, anchored);
}
export function discardPendingMessageSend(channelId: string, pending: PendingMessageSend): void {
if (Slowmode.getLastSendTimestamp(channelId) !== pending.pendingSendTimestamp) return;
Slowmode.updateSlowmodeTimestamp(channelId, pending.previousSendTimestamp);
}
export function updateSlowmodeRemaining(channelId: string, retryAfterMs: number): void {
Slowmode.updateSlowmodeRemaining(channelId, retryAfterMs);
}
export function retryAfterSecondsToMs(retryAfterSeconds: number | undefined): number {
if (retryAfterSeconds == null || !Number.isFinite(retryAfterSeconds) || retryAfterSeconds <= 0) {
export function clampSlowmodeRetryAfterMs(retryAfterMs: number | null): number {
if (retryAfterMs == null || !Number.isSafeInteger(retryAfterMs) || retryAfterMs <= 0) {
return 0;
}
const retryAfterMs = Math.ceil(retryAfterSeconds * 1000);
if (!Number.isSafeInteger(retryAfterMs) || retryAfterMs > MAX_RETRY_AFTER_MS) {
if (retryAfterMs > MAX_RETRY_AFTER_MS) {
return 0;
}
return retryAfterMs;
@@ -52,7 +52,7 @@ class Slowmode {
this.pruneExpired(Date.now());
}
recordMessageSend(channelId: string): void {
recordMessageSend(channelId: string): number {
const now = Date.now();
this.pruneExpired(now);
const current = this.getEntry(channelId);
@@ -60,17 +60,22 @@ class Slowmode {
explicitExpiresAt: current.explicitExpiresAt,
lastSendTimestamp: now,
});
return now;
}
updateSlowmodeTimestamp(channelId: string, timestamp: number): void {
updateSlowmodeTimestamp(channelId: string, timestamp: number | null): void {
const now = Date.now();
if (!isValidTimestamp(timestamp, now)) return;
let nextTimestamp = timestamp;
if (nextTimestamp !== null) {
if (!isValidTimestamp(nextTimestamp, now)) return;
nextTimestamp = Math.min(nextTimestamp, now);
}
this.pruneExpired(now);
const current = this.getEntry(channelId);
if (current.lastSendTimestamp === timestamp) return;
if (current.lastSendTimestamp === nextTimestamp) return;
this.setEntry(channelId, {
explicitExpiresAt: current.explicitExpiresAt,
lastSendTimestamp: timestamp,
lastSendTimestamp: nextTimestamp,
});
}

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