Compare commits

...
Author SHA1 Message Date
HampusandGitHub c541b86c00 fix(voice): show buffering while screen share recovery runs (#2682) 2026-09-11 15:03:21 +02:00
HampusandGitHub 53b3fa2f4a fix(voice): key watch attempts by published track (#2681) 2026-09-11 15:01:14 +02:00
HampusandGitHub 67e01be34a fix(voice): judge H.264 hardware support by negotiated format (#2680) 2026-09-11 14:59:04 +02:00
HampusandGitHub 81fd8c9aad fix(gateway): skip empty dm partner registration casts (#2677) 2026-09-11 13:49:03 +02:00
HampusandGitHub bcef7b3123 feat(app): edit blockquote lines in the composer (#2676) 2026-09-11 13:27:50 +02:00
HampusandGitHub a98d8ef679 fix(app): wrap multiline selections in code blocks (#2675) 2026-09-11 13:22:03 +02:00
HampusandGitHub a5af857564 fix(app): insert a newline on Enter inside code blocks (#2674) 2026-09-11 13:20:26 +02:00
HampusandGitHub 2830221949 fix(desktop): download the version a linux update prompt names (#2673) 2026-09-11 13:19:25 +02:00
HampusandGitHub 84aa8880f5 fix(app): format typed @everyone and @here in the composer (#2672) 2026-09-11 13:18:48 +02:00
HampusandGitHub 395ec1d60f fix(ci): publish desktop update feeds only after the release (#2671) 2026-09-11 13:18:15 +02:00
HampusandGitHub e6ee3b8059 fix(api): only offer desktop builds whose release is published (#2670) 2026-09-11 13:17:41 +02:00
HampusandGitHub 61a13e1c1a fix(app): download the version a linux update prompt names (#2669) 2026-09-11 13:16:27 +02:00
HampusandGitHub fc0e2628a4 fix(app): honour @silent in the message composer (#2668) 2026-09-11 13:16:13 +02:00
HampusandGitHub 87fdfd9c34 fix(app): show DMs opened by an incoming message as unread (#2667) 2026-09-11 13:14:06 +02:00
HampusandGitHub 88a5ff9c45 feat(voice): record watch failures and decode counters (#2666) 2026-09-11 13:05:34 +02:00
HampusandGitHub 320949a79d fix(gateway): drop dead clauses in dm partner visibility (#2665) 2026-09-11 13:00:23 +02:00
HampusandGitHub 3a862f1484 fix(voice): record why a screen share stopped (#2664) 2026-09-11 12:59:51 +02:00
HampusandGitHub 5da256df12 fix(voice): poll the current video element for a first frame (#2663) 2026-09-11 12:57:52 +02:00
HampusandGitHub baf2cbf3fd fix(voice): rebind codec negotiation after a region hot swap (#2662) 2026-09-11 12:55:50 +02:00
HampusandGitHub 74782dc4f2 fix(voice): confirm a decode stall before withdrawing a codec (#2661) 2026-09-11 12:53:29 +02:00
HampusandGitHub 7d8778495f chore(desktop): drop Chromium switches that no longer exist (#2660) 2026-09-11 12:50:54 +02:00
HampusandGitHub 53399ffb44 fix(gateway): track dm partner presence in mutual guilds (#2658) 2026-09-11 04:23:20 +02:00
HampusandGitHub 35d73eae76 fix(voice): stop asking for camera and mic access on page load (#2657) 2026-09-11 02:00:48 +02:00
HampusandGitHub 54128e049a test(api): restore the stripe webhook secret after mocking it (#2656) 2026-09-11 01:36:26 +02:00
HampusandGitHub 7d8d0ff804 fix(ci): retry release publish after transient GitHub failures (#2655) 2026-09-11 01:35:24 +02:00
HampusandGitHub 2e8f381efc fix(gateway): keep ets tids opaque in the permission cache (#2654) 2026-09-11 01:31:58 +02:00
HampusandGitHub b29da84282 perf(gateway): trim large guild connect snapshots by default (#2653) 2026-09-11 01:03:22 +02:00
HampusandGitHub 2988c846c8 perf(gateway): read cached members from the guild member table (#2652) 2026-09-11 00:58:17 +02:00
fluxer-ci[bot]andGitHub 8e91c1412b chore(marketing): advance pointer 5908507 → 7867cf8 (#2650) 2026-09-11 00:51:33 +02:00
fluxer-ci[bot]andGitHub 5b2099c777 chore(i18n): update public marketing catalogs (#2651) 2026-09-11 00:51:25 +02:00
HampusandGitHub f97841a58f fix(installer): resolve the compose file name Compose loads (#2649) 2026-09-11 00:38:09 +02:00
169 changed files with 14144 additions and 997 deletions
+8
View File
@@ -525,6 +525,7 @@ jobs:
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
PUBLIC_DL_BASE: https://api.fluxer.app/dl
@@ -602,7 +603,9 @@ jobs:
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
@@ -660,3 +663,8 @@ jobs:
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_release_marker
- name: Publish payload metadata to S3
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_payload_metadata
+302 -239
View File
@@ -17,7 +17,7 @@ import {
StorageObjectRangeNotSatisfiableError,
} from '../infrastructure/IStorageService';
import {Logger} from '../Logger';
import {isJsonRecord, parseJsonRecord, parseJsonUnknown} from '../utils/JsonBoundaryUtils';
import {isJsonRecord, parseJsonRecord} from '../utils/JsonBoundaryUtils';
import {
parseDesktopArtifactScope,
parseDesktopReleaseDescriptor,
@@ -58,6 +58,7 @@ function isUnsatisfiableRangeError(error: unknown): boolean {
);
}
const MAX_DESKTOP_OBJECTS_PER_PREFIX = 10_000;
const MAX_DESKTOP_RELEASE_CANDIDATES = 10;
const DESKTOP_BUCKET_PREFIX = 'desktop';
const DESKTOP_TEST_BUCKET_PREFIX = 'desktop-test';
const DOWNLOAD_KEY_ALLOWED_PREFIXES = [`${DESKTOP_BUCKET_PREFIX}/`, `${DESKTOP_TEST_BUCKET_PREFIX}/`];
@@ -198,6 +199,14 @@ export type GitHubDesktopReleaseResolution =
| {kind: 'awaiting_release'}
| {kind: 'ready'; location: string};
type DesktopReleaseState = {kind: 'untracked'} | {kind: 'unpublished'} | {kind: 'published'; descriptorText: string};
type ListedDesktopVersion = {
version: string;
pub_date: Date;
files: Map<DesktopFormat, {filename: string; sha256Key: string | null}>;
};
export class DownloadService {
constructor(private readonly storageService: IStorageService) {}
@@ -216,41 +225,28 @@ export class DownloadService {
) {
return {kind: 'not_current'};
}
const descriptorKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.json`;
const descriptorText = await this.readOptionalTextFromStorage(descriptorKey);
if (descriptorText == null) {
const release = await this.readDesktopReleaseState(scope.channel, manifest.version);
if (release.kind === 'untracked') {
return {kind: 'not_current'};
}
const descriptor = parseDesktopReleaseDescriptor(parseJsonUnknown(descriptorText));
if (release.kind === 'unpublished') {
return {kind: 'awaiting_release'};
}
const descriptor = parseDesktopReleaseDescriptor(parseJsonRecord(release.descriptorText));
if (
!descriptor ||
descriptor.channel !== scope.channel ||
descriptor.version !== manifest.version ||
descriptor.release_tag !== `fluxer-desktop-${scope.channel}@${manifest.version}`
) {
throw new Error(`Invalid GitHub desktop release descriptor: ${descriptorKey}`);
throw new Error(
`Invalid GitHub desktop release descriptor: ${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.json`,
);
}
const releaseAsset = descriptor.assets.find((asset) => asset.storage_key === key);
if (!releaseAsset) {
return {kind: 'not_current'};
}
const markerKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.ready.json`;
const marker = await this.readOptionalJsonObjectFromStorage(markerKey);
if (marker == null) {
return {kind: 'awaiting_release'};
}
const readiness = parseDesktopReleaseReadiness(marker);
const descriptorSha256 = createHash('sha256').update(descriptorText).digest('hex');
if (
!readiness ||
readiness.channel !== descriptor.channel ||
readiness.version !== descriptor.version ||
readiness.release_tag !== descriptor.release_tag ||
readiness.source_sha !== descriptor.source_sha ||
readiness.descriptor_sha256 !== descriptorSha256
) {
throw new Error(`Invalid GitHub desktop release readiness marker: ${markerKey}`);
}
return {
kind: 'ready',
location: `${GITHUB_RELEASE_DOWNLOAD_BASE_URL}/${encodeURIComponent(descriptor.release_tag)}/${encodeURIComponent(releaseAsset.release_asset)}`,
@@ -269,18 +265,22 @@ export class DownloadService {
return null;
}
const manifestKey = `${prefix}/manifest.json`;
const releasability = new Map<string, boolean>();
try {
const manifest = await this.readJsonObjectFromStorage(manifestKey);
if (!isDesktopManifest(manifest)) {
return this.resolveLatestDesktopKeyFromObjects(params);
if (
!isDesktopManifest(manifest) ||
!(await this.isReleasableDesktopVersion(params, manifest.version, releasability))
) {
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
}
const entry = manifest.files[params.format];
if (!entry) {
return this.resolveLatestDesktopKeyFromObjects(params);
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
}
const filename = this.extractFilename(entry);
if (filename.trim().length === 0) {
return this.resolveLatestDesktopKeyFromObjects(params);
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
}
const resolvedFilename = await this.resolveManifestFilename({
channel: params.channel,
@@ -291,7 +291,11 @@ export class DownloadService {
test: params.test,
});
if (!resolvedFilename) {
return this.resolveLatestDesktopKeyFromObjects(params);
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
}
const parsed = this.parseVersionFromFilename(resolvedFilename, params.channel, params.plat, params.arch);
if (parsed && parsed.version !== manifest.version) {
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
}
return this.buildDesktopArtifactKey({
channel: params.channel,
@@ -302,7 +306,7 @@ export class DownloadService {
});
} catch (error) {
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
return this.resolveLatestDesktopKeyFromObjects(params);
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
}
throw error;
}
@@ -320,10 +324,14 @@ export class DownloadService {
return null;
}
const manifestKey = `${prefix}/manifest.json`;
const releasability = new Map<string, boolean>();
try {
const manifest = await this.readJsonObjectFromStorage(manifestKey);
if (!isDesktopManifest(manifest)) {
return this.getLatestDesktopVersionFromObjects(params);
if (
!isDesktopManifest(manifest) ||
!(await this.isReleasableDesktopVersion(params, manifest.version, releasability))
) {
return this.getLatestDesktopVersionFromObjects(params, releasability);
}
const result = await this.getLatestDesktopVersionFromManifest(params, manifest);
if (result) {
@@ -331,11 +339,11 @@ export class DownloadService {
}
} catch (error) {
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
return this.getLatestDesktopVersionFromObjects(params);
return this.getLatestDesktopVersionFromObjects(params, releasability);
}
throw error;
}
return this.getLatestDesktopVersionFromObjects(params);
return this.getLatestDesktopVersionFromObjects(params, releasability);
}
async listDesktopVersions(params: {
@@ -351,158 +359,17 @@ export class DownloadService {
versions: Array<VersionInfo>;
hasMore: boolean;
}> {
const basePrefix = desktopArtifactPrefix(params);
if (!basePrefix) {
return {versions: [], hasMore: false};
let listedVersions = await this.listDesktopVersionFiles(params);
if (params.before) {
listedVersions = listedVersions.filter((entry) => this.compareVersions(entry.version, params.before ?? '') > 0);
}
const prefix = `${basePrefix}/`;
try {
const objects = await this.listDesktopArtifacts(prefix);
if (objects.length === 0) {
return {versions: [], hasMore: false};
}
const versionMap = new Map<
string,
{
pub_date: Date;
files: Map<
DesktopFormat,
{
filename: string;
sha256Key: string | null;
}
>;
}
>();
const sha256Files = new Set<string>();
for (const obj of objects) {
if (obj.key.endsWith('.sha256')) {
sha256Files.add(obj.key);
}
}
for (const obj of objects) {
const filename = obj.key.slice(prefix.length);
if (filename.includes('/') || filename.endsWith('.sha256') || filename === 'manifest.json') {
continue;
}
const parsed = this.parseVersionFromFilename(filename, params.channel, params.plat, params.arch);
if (!parsed) {
continue;
}
const {version, format} = parsed;
const sha256Key = sha256Files.has(`${obj.key}.sha256`) ? `${obj.key}.sha256` : null;
if (!versionMap.has(version)) {
versionMap.set(version, {
pub_date: obj.lastModified ?? new Date(),
files: new Map(),
});
}
const entry = versionMap.get(version);
if (entry) {
if (!entry.files.has(format)) {
entry.files.set(format, {filename, sha256Key});
}
if (obj.lastModified && obj.lastModified > entry.pub_date) {
entry.pub_date = obj.lastModified;
}
}
}
const sortedVersions = Array.from(versionMap.keys()).sort(this.compareVersions);
let filteredVersions = sortedVersions;
if (params.before) {
filteredVersions = filteredVersions.filter((v) => this.compareVersions(v, params.before ?? '') > 0);
}
if (params.after) {
filteredVersions = filteredVersions.filter((v) => this.compareVersions(v, params.after ?? '') < 0);
}
const hasMore = filteredVersions.length > params.limit;
const paginatedVersions = filteredVersions.slice(0, params.limit);
const sha256Promises: Array<
Promise<{
key: string;
hash: string | null;
}>
> = [];
for (const version of paginatedVersions) {
const entry = versionMap.get(version);
if (!entry) {
continue;
}
for (const [, fileInfo] of entry.files) {
if (fileInfo.sha256Key) {
sha256Promises.push(
(async () => {
try {
const streamResult = await this.storageService.streamObject({
bucket: Config.s3.buckets.downloads,
key: fileInfo.sha256Key as string,
});
if (streamResult) {
const body = Readable.toWeb(streamResult.body);
const text = await new Response(body as ReadableStream).text();
return {key: fileInfo.sha256Key as string, hash: text.trim().split(/\s+/u)[0]};
}
} catch {
return {key: fileInfo.sha256Key as string, hash: null};
}
return {key: fileInfo.sha256Key as string, hash: null};
})(),
);
}
}
}
const sha256Results = await Promise.all(sha256Promises);
const sha256Map = new Map<string, string | null>();
for (const result of sha256Results) {
sha256Map.set(result.key, result.hash);
}
const versions: Array<VersionInfo> = [];
for (const version of paginatedVersions) {
const entry = versionMap.get(version);
if (!entry) {
continue;
}
const files: Record<string, VersionFile> = {};
for (const [format, fileInfo] of entry.files) {
const sha256 = fileInfo.sha256Key ? (sha256Map.get(fileInfo.sha256Key) ?? null) : null;
const validSha256 = sha256 && this.isValidSha256(sha256) ? sha256 : null;
files[format] = {
url: this.buildDesktopVersionUrl({
channel: params.channel,
plat: params.plat,
arch: params.arch,
version,
format,
baseUrl: params.baseUrl,
test: params.test,
}),
sha256: validSha256,
checksum_url: validSha256
? this.buildDesktopVersionChecksumUrl({
channel: params.channel,
plat: params.plat,
arch: params.arch,
version,
format,
baseUrl: params.baseUrl,
test: params.test,
})
: null,
};
}
versions.push({
version,
pub_date: entry.pub_date.toISOString(),
files,
});
}
return {versions, hasMore};
} catch (error) {
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
return {versions: [], hasMore: false};
}
throw error;
if (params.after) {
listedVersions = listedVersions.filter((entry) => this.compareVersions(entry.version, params.after ?? '') < 0);
}
return {
versions: await this.buildListedDesktopVersions(params, listedVersions.slice(0, params.limit)),
hasMore: listedVersions.length > params.limit,
};
}
async resolveVersionedDesktopKey(params: {
@@ -558,10 +425,10 @@ export class DownloadService {
}): Promise<DesktopChecksumFile | null> {
const version = await this.getLatestDesktopVersion(params);
const file = version?.files[params.format];
if (!file?.sha256 || !this.isValidSha256(file.sha256)) {
if (!version || !file?.sha256 || !this.isValidSha256(file.sha256)) {
return null;
}
const key = await this.resolveLatestDesktopKey(params);
const key = await this.resolveVersionedDesktopKey({...params, version: version.version});
if (!key) {
return null;
}
@@ -586,8 +453,16 @@ export class DownloadService {
if (objectSha256) {
return this.buildDesktopChecksumFile(key, filename, objectSha256);
}
const latest = await this.getLatestDesktopVersion(params);
const file = latest?.version === params.version ? latest.files[params.format] : undefined;
const prefix = desktopArtifactPrefix(params);
if (!prefix) {
return null;
}
const manifest = await this.readOptionalJsonObjectFromStorage(`${prefix}/manifest.json`);
const versionInfo =
isDesktopManifest(manifest) && manifest.version === params.version
? await this.getLatestDesktopVersionFromManifest(params, manifest)
: null;
const file = versionInfo?.files[params.format];
if (!file?.sha256 || !this.isValidSha256(file.sha256)) {
return null;
}
@@ -768,6 +643,77 @@ export class DownloadService {
}
}
private async readDesktopReleaseState(channel: DesktopChannel, version: string): Promise<DesktopReleaseState> {
const descriptorKey = `${DESKTOP_BUCKET_PREFIX}/${channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${version}.json`;
const markerKey = `${DESKTOP_BUCKET_PREFIX}/${channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${version}.ready.json`;
const [descriptorText, markerText] = await Promise.all([
this.readOptionalTextFromStorage(descriptorKey),
this.readOptionalTextFromStorage(markerKey),
]);
if (descriptorText == null) {
return {kind: 'untracked'};
}
if (markerText == null) {
return {kind: 'unpublished'};
}
const readiness = parseDesktopReleaseReadiness(parseJsonRecord(markerText));
if (
!readiness ||
readiness.channel !== channel ||
readiness.version !== version ||
readiness.release_tag !== `fluxer-desktop-${channel}@${version}` ||
readiness.descriptor_sha256 !== createHash('sha256').update(descriptorText).digest('hex')
) {
Logger.error({key: markerKey}, 'Invalid GitHub desktop release readiness marker');
return {kind: 'unpublished'};
}
return {kind: 'published', descriptorText};
}
private async isReleasableDesktopVersion(
params: {channel: DesktopChannel; test?: boolean},
version: string,
releasability: Map<string, boolean>,
): Promise<boolean> {
if (params.test || Config.instance.selfHosted) {
return true;
}
const checked = releasability.get(version);
if (checked !== undefined) {
return checked;
}
let releasable = true;
try {
releasable = (await this.readDesktopReleaseState(params.channel, version)).kind !== 'unpublished';
} catch (error) {
Logger.error({error, channel: params.channel, version}, 'Failed to read desktop release readiness');
}
releasability.set(version, releasable);
return releasable;
}
private async findNewestReleasableDesktopVersion<T extends {version: string}>(
params: {channel: DesktopChannel; test?: boolean},
candidates: ReadonlyArray<T>,
releasability: Map<string, boolean>,
): Promise<T | null> {
const newestCandidates = candidates.slice(0, MAX_DESKTOP_RELEASE_CANDIDATES);
for (const candidate of newestCandidates) {
if (await this.isReleasableDesktopVersion(params, candidate.version, releasability)) {
return candidate;
}
}
const [newest] = newestCandidates;
if (!newest) {
return null;
}
Logger.error(
{channel: params.channel, version: newest.version},
'No recent desktop version has a published release',
);
return newest;
}
private isValidSha256(value: string): boolean {
return /^[a-f0-9]{64}$/u.test(value);
}
@@ -789,7 +735,13 @@ export class DownloadService {
) {
return manifestFilename;
}
return this.findLatestFilenameForRequestedArch(params);
for (const entry of await this.listDesktopVersionFiles(params)) {
const file = entry.files.get(params.format);
if (file) {
return file.filename;
}
}
return null;
}
private async listDesktopArtifacts(prefix: string): Promise<ReadonlyArray<{key: string; lastModified?: Date}>> {
@@ -826,44 +778,145 @@ export class DownloadService {
return params.filename.toLowerCase().endsWith('.exe');
}
private async findLatestFilenameForRequestedArch(params: LatestFilenameLookupParams): Promise<string | null> {
private async listDesktopVersionFiles(params: {
channel: DesktopChannel;
plat: DesktopPlatform;
arch: DesktopArch;
test?: boolean;
}): Promise<Array<ListedDesktopVersion>> {
const basePrefix = desktopArtifactPrefix(params);
if (!basePrefix) {
return null;
return [];
}
const prefix = `${basePrefix}/`;
const objects = await this.listDesktopArtifacts(prefix);
if (objects.length === 0) {
return null;
try {
const objects = await this.listDesktopArtifacts(prefix);
const versionMap = new Map<string, ListedDesktopVersion>();
const sha256Files = new Set<string>();
for (const obj of objects) {
if (obj.key.endsWith('.sha256')) {
sha256Files.add(obj.key);
}
}
for (const obj of objects) {
const filename = obj.key.slice(prefix.length);
if (filename.includes('/') || filename.endsWith('.sha256') || filename === 'manifest.json') {
continue;
}
const parsed = this.parseVersionFromFilename(filename, params.channel, params.plat, params.arch);
if (!parsed) {
continue;
}
const {version, format} = parsed;
const sha256Key = sha256Files.has(`${obj.key}.sha256`) ? `${obj.key}.sha256` : null;
if (!versionMap.has(version)) {
versionMap.set(version, {
version,
pub_date: obj.lastModified ?? new Date(),
files: new Map(),
});
}
const entry = versionMap.get(version);
if (entry) {
if (!entry.files.has(format)) {
entry.files.set(format, {filename, sha256Key});
}
if (obj.lastModified && obj.lastModified > entry.pub_date) {
entry.pub_date = obj.lastModified;
}
}
}
return Array.from(versionMap.values()).sort((left, right) => this.compareVersions(left.version, right.version));
} catch (error) {
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
return [];
}
throw error;
}
let latestFilename: string | null = null;
let latestVersion: string | null = null;
for (const obj of objects) {
const filename = obj.key.slice(prefix.length);
if (filename.length === 0) {
continue;
}
if (
filename.includes('/') ||
filename.endsWith('.sha256') ||
filename.endsWith('.blockmap') ||
filename.endsWith('.yml') ||
filename === 'manifest.json' ||
filename === 'RELEASES.json' ||
filename === 'releases.json'
) {
continue;
}
const parsed = this.parseVersionFromFilename(filename, params.channel, params.plat, params.arch);
if (!parsed || parsed.format !== params.format) {
continue;
}
if (!latestVersion || this.compareVersions(parsed.version, latestVersion) < 0) {
latestVersion = parsed.version;
latestFilename = filename;
}
private async buildListedDesktopVersions(
params: {
channel: DesktopChannel;
plat: DesktopPlatform;
arch: DesktopArch;
baseUrl?: string;
test?: boolean;
},
listedVersions: ReadonlyArray<ListedDesktopVersion>,
): Promise<Array<VersionInfo>> {
const sha256Promises: Array<
Promise<{
key: string;
hash: string | null;
}>
> = [];
for (const entry of listedVersions) {
for (const [, fileInfo] of entry.files) {
if (fileInfo.sha256Key) {
sha256Promises.push(
(async () => {
try {
const streamResult = await this.storageService.streamObject({
bucket: Config.s3.buckets.downloads,
key: fileInfo.sha256Key as string,
});
if (streamResult) {
const body = Readable.toWeb(streamResult.body);
const text = await new Response(body as ReadableStream).text();
return {key: fileInfo.sha256Key as string, hash: text.trim().split(/\s+/u)[0]};
}
} catch {
return {key: fileInfo.sha256Key as string, hash: null};
}
return {key: fileInfo.sha256Key as string, hash: null};
})(),
);
}
}
}
return latestFilename;
const sha256Results = await Promise.all(sha256Promises);
const sha256Map = new Map<string, string | null>();
for (const result of sha256Results) {
sha256Map.set(result.key, result.hash);
}
const versions: Array<VersionInfo> = [];
for (const entry of listedVersions) {
const files: Record<string, VersionFile> = {};
for (const [format, fileInfo] of entry.files) {
const sha256 = fileInfo.sha256Key ? (sha256Map.get(fileInfo.sha256Key) ?? null) : null;
const validSha256 = sha256 && this.isValidSha256(sha256) ? sha256 : null;
files[format] = {
url: this.buildDesktopVersionUrl({
channel: params.channel,
plat: params.plat,
arch: params.arch,
version: entry.version,
format,
baseUrl: params.baseUrl,
test: params.test,
}),
sha256: validSha256,
checksum_url: validSha256
? this.buildDesktopVersionChecksumUrl({
channel: params.channel,
plat: params.plat,
arch: params.arch,
version: entry.version,
format,
baseUrl: params.baseUrl,
test: params.test,
})
: null,
};
}
versions.push({
version: entry.version,
pub_date: entry.pub_date.toISOString(),
files,
});
}
return versions;
}
private escapeRegex(str: string): string {
@@ -1030,16 +1083,25 @@ export class DownloadService {
return key;
}
private async resolveLatestDesktopKeyFromObjects(params: LatestFilenameLookupParams): Promise<string | null> {
const filename = await this.findLatestFilenameForRequestedArch(params);
if (!filename) {
private async resolveLatestDesktopKeyFromObjects(
params: LatestFilenameLookupParams,
releasability: Map<string, boolean>,
): Promise<string | null> {
const listedVersions = await this.listDesktopVersionFiles(params);
const latest = await this.findNewestReleasableDesktopVersion(
params,
listedVersions.filter((entry) => entry.files.has(params.format)),
releasability,
);
const file = latest?.files.get(params.format);
if (!file) {
return null;
}
return this.buildDesktopArtifactKey({
channel: params.channel,
plat: params.plat,
arch: params.arch,
filename,
filename: file.filename,
test: params.test,
});
}
@@ -1189,22 +1251,23 @@ export class DownloadService {
};
}
private async getLatestDesktopVersionFromObjects(params: {
channel: DesktopChannel;
plat: DesktopPlatform;
arch: DesktopArch;
baseUrl?: string;
test?: boolean;
}): Promise<VersionInfo | null> {
const {versions} = await this.listDesktopVersions({
channel: params.channel,
plat: params.plat,
arch: params.arch,
limit: 1,
baseUrl: params.baseUrl,
test: params.test,
});
return versions[0] ?? null;
private async getLatestDesktopVersionFromObjects(
params: {
channel: DesktopChannel;
plat: DesktopPlatform;
arch: DesktopArch;
baseUrl?: string;
test?: boolean;
},
releasability: Map<string, boolean>,
): Promise<VersionInfo | null> {
const listedVersions = await this.listDesktopVersionFiles(params);
const latest = await this.findNewestReleasableDesktopVersion(params, listedVersions, releasability);
if (!latest) {
return null;
}
const [versionInfo] = await this.buildListedDesktopVersions(params, [latest]);
return versionInfo ?? null;
}
private async resolveDesktopFileSha256(params: {
@@ -0,0 +1,337 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {Readable} from 'node:stream';
import {S3ServiceException} from '@aws-sdk/client-s3';
import {describe, expect, it} from 'vitest';
import {getConfig} from '../../Config';
import type {IStorageService} from '../../infrastructure/IStorageService';
import {DownloadService} from '../DownloadService';
const PREFIX = 'desktop/canary/linux/x64';
const TEST_PREFIX = 'desktop-test/canary/linux/x64';
const RELEASES_PREFIX = 'desktop/canary/github-releases';
const SOURCE_SHA = 'b'.repeat(40);
const V904 = '2026.904.135113';
const V908 = '2026.908.173325';
const V909 = '2026.909.202036';
const LATEST_PARAMS = {channel: 'canary', plat: 'linux', arch: 'x64'} as const;
const APPIMAGE_PARAMS = {...LATEST_PARAMS, format: 'appimage'} as const;
const RELEASE_ROUTES: ReadonlyArray<readonly [string, string, number]> = [
['darwin', 'arm64', 4],
['darwin', 'x64', 4],
['linux', 'arm64', 4],
['linux', 'x64', 4],
['win32', 'arm64', 6],
['win32', 'x64', 6],
];
type StoredObjects = Map<string, string>;
function sha256Hex(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
function appImageFilename(version: string): string {
return `Fluxer-Canary-${version}-linux-x86_64.AppImage`;
}
function uploadBuild(objects: StoredObjects, version: string, options: {prefix?: string; checksum?: boolean} = {}) {
const prefix = options.prefix ?? PREFIX;
const filename = appImageFilename(version);
objects.set(`${prefix}/${filename}`, filename);
if (options.checksum !== false) {
objects.set(`${prefix}/${filename}.sha256`, `${sha256Hex(filename)} ${filename}`);
}
objects.set(
`${prefix}/manifest.json`,
JSON.stringify({
channel: 'canary',
platform: 'linux',
arch: 'x64',
version,
pub_date: '2026-09-08T18:06:00Z',
files: {appimage: {filename, sha256: sha256Hex(filename)}},
}),
);
}
function publishDescriptor(objects: StoredObjects, version: string, routes = RELEASE_ROUTES): string {
const assets = routes.flatMap(([plat, arch, count]) =>
Array.from({length: count}, (_, index) => {
const filename =
plat === 'linux' && arch === 'x64' && index === 0
? appImageFilename(version)
: `Fluxer-Canary-${version}-${plat}-${arch}-${index}.bin`;
return {
storage_key: `desktop/canary/${plat}/${arch}/${filename}`,
release_asset: filename,
sha256: sha256Hex(filename),
size: 1,
};
}),
);
const descriptor = JSON.stringify({
schema_version: 1,
channel: 'canary',
version,
release_tag: `fluxer-desktop-canary@${version}`,
source_sha: SOURCE_SHA,
assets,
});
objects.set(`${RELEASES_PREFIX}/${version}.json`, descriptor);
return descriptor;
}
function publishMarker(objects: StoredObjects, version: string, descriptor: string) {
objects.set(
`${RELEASES_PREFIX}/${version}.ready.json`,
JSON.stringify({
schema_version: 1,
channel: 'canary',
version,
release_tag: `fluxer-desktop-canary@${version}`,
source_sha: SOURCE_SHA,
descriptor_sha256: sha256Hex(descriptor),
}),
);
}
function releaseBuild(objects: StoredObjects, version: string) {
const descriptor = publishDescriptor(objects, version);
uploadBuild(objects, version);
publishMarker(objects, version, descriptor);
}
function incidentObjects(): StoredObjects {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
publishDescriptor(objects, V908);
uploadBuild(objects, V908);
return objects;
}
function createService(objects: StoredObjects, onRead?: (key: string) => void) {
const reads: Array<string> = [];
const listings: Array<string> = [];
const storageService = {
streamObject: async (params: {key: string}) => {
reads.push(params.key);
onRead?.(params.key);
const body = objects.get(params.key);
if (body == null) {
return null;
}
const buffer = Buffer.from(body, 'utf8');
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
},
listObjects: async (params: {prefix: string}) => {
listings.push(params.prefix);
return Array.from(objects.keys())
.filter((key) => key.startsWith(params.prefix))
.sort()
.map((key) => ({key}));
},
getObjectMetadata: async (_bucket: string, key: string) =>
objects.has(key) ? {contentLength: 1, contentType: 'application/octet-stream'} : null,
} as unknown as IStorageService;
return {service: new DownloadService(storageService), reads, listings};
}
async function resolveLatest(service: DownloadService, test?: boolean) {
const metadata = await service.getLatestDesktopVersion({...LATEST_PARAMS, test});
const key = await service.resolveLatestDesktopKey({...APPIMAGE_PARAMS, test});
const checksum = await service.resolveLatestDesktopChecksumFile({...APPIMAGE_PARAMS, test});
return {version: metadata?.version, key, checksum: checksum?.body};
}
function latestOf(version: string, prefix = PREFIX) {
const filename = appImageFilename(version);
return {version, key: `${prefix}/${filename}`, checksum: `${sha256Hex(filename)} ${filename}\n`};
}
describe('desktop release readiness', () => {
it('offers a published manifest version after reading only its release state', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
releaseBuild(objects, V909);
const {service, reads, listings} = createService(objects);
await expect(service.getLatestDesktopVersion({...LATEST_PARAMS})).resolves.toMatchObject({version: V909});
expect(reads).toEqual([
`${PREFIX}/manifest.json`,
`${RELEASES_PREFIX}/${V909}.json`,
`${RELEASES_PREFIX}/${V909}.ready.json`,
]);
expect(listings).toEqual([]);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V909));
});
it('falls back to the newest published version while the manifest version awaits its release', async () => {
const {service} = createService(incidentObjects());
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
});
it('reads each release state once and one checksum while the manifest version awaits its release', async () => {
const {service, reads, listings} = createService(incidentObjects());
await expect(service.getLatestDesktopVersion({...LATEST_PARAMS})).resolves.toMatchObject({version: V904});
expect(reads).toEqual([
`${PREFIX}/manifest.json`,
`${RELEASES_PREFIX}/${V908}.json`,
`${RELEASES_PREFIX}/${V908}.ready.json`,
`${RELEASES_PREFIX}/${V904}.json`,
`${RELEASES_PREFIX}/${V904}.ready.json`,
`${PREFIX}/${appImageFilename(V904)}.sha256`,
]);
expect(listings).toEqual([`${PREFIX}/`]);
});
it('offers a manifest version that has no release descriptor', async () => {
const objects: StoredObjects = new Map();
uploadBuild(objects, V904);
uploadBuild(objects, V908);
const {service} = createService(objects);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
});
it('treats a readiness marker that does not match the stored descriptor as unpublished', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
publishDescriptor(objects, V908);
uploadBuild(objects, V908);
publishMarker(objects, V908, 'another descriptor');
const {service} = createService(objects);
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).resolves.toEqual({
kind: 'awaiting_release',
});
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
});
it('offers a version whose descriptor the parser rejects when its readiness marker matches', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
const descriptor = publishDescriptor(
objects,
V908,
RELEASE_ROUTES.map(([plat, arch, count]) => [plat, arch, plat === 'linux' ? count - 1 : count] as const),
);
uploadBuild(objects, V908);
publishMarker(objects, V908, descriptor);
const {service} = createService(objects);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).rejects.toThrow(
'Invalid GitHub desktop release descriptor',
);
});
it.each([
['descriptor', `${RELEASES_PREFIX}/${V908}.json`],
['readiness marker', `${RELEASES_PREFIX}/${V908}.ready.json`],
])('offers the manifest version when reading its release %s fails with a storage error', async (_name, failingKey) => {
const {service} = createService(incidentObjects(), (key) => {
if (key === failingKey) {
throw new S3ServiceException({
name: 'SlowDown',
$fault: 'server',
$metadata: {httpStatusCode: 503},
message: 'Please reduce your request rate.',
});
}
});
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
});
it('still resolves the unpublished version through versioned routes', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
publishDescriptor(objects, V908);
uploadBuild(objects, V908, {checksum: false});
const {service} = createService(objects);
const params = {...APPIMAGE_PARAMS, version: V908};
const filename = appImageFilename(V908);
await expect(service.resolveVersionedDesktopKey(params)).resolves.toBe(`${PREFIX}/${filename}`);
await expect(service.resolveVersionedDesktopChecksumFile(params)).resolves.toMatchObject({
sha256: sha256Hex(filename),
});
});
it('keeps offering the manifest version on self-hosted instances', async () => {
const config = getConfig();
const originalSelfHosted = config.instance.selfHosted;
config.instance.selfHosted = true;
try {
const {service, reads} = createService(incidentObjects());
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
expect(reads.filter((key) => key.startsWith(RELEASES_PREFIX))).toEqual([]);
} finally {
config.instance.selfHosted = originalSelfHosted;
}
});
it('keeps offering the newest test build', async () => {
const objects: StoredObjects = new Map();
publishDescriptor(objects, V908);
uploadBuild(objects, V908, {prefix: TEST_PREFIX});
const {service, reads} = createService(objects);
await expect(resolveLatest(service, true)).resolves.toEqual(latestOf(V908, TEST_PREFIX));
expect(reads.filter((key) => key.startsWith(RELEASES_PREFIX))).toEqual([]);
});
it('offers 904 while 908 awaits its release, then 909 once its marker lands', async () => {
const objects = incidentObjects();
const {service} = createService(objects);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).resolves.toEqual({
kind: 'awaiting_release',
});
const descriptor = publishDescriptor(objects, V909);
uploadBuild(objects, V909);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
publishMarker(objects, V909, descriptor);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V909));
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V909)}`)).resolves.toEqual({
kind: 'ready',
location: `https://github.com/fluxerapp/fluxer/releases/download/${encodeURIComponent(`fluxer-desktop-canary@${V909}`)}/${appImageFilename(V909)}`,
});
});
it('offers the newest version when ten unpublished versions hide a published one', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
const newest = '2026.908.170009';
for (let build = 0; build < 10; build++) {
const version = `2026.908.${170000 + build}`;
publishDescriptor(objects, version);
uploadBuild(objects, version);
}
const {service, reads} = createService(objects);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(newest));
expect(reads).not.toContain(`${RELEASES_PREFIX}/${V904}.ready.json`);
});
it('pairs the latest checksum with the filename of the same version when a marker lands mid-request', async () => {
const objects = incidentObjects();
const descriptor = publishDescriptor(objects, V909);
uploadBuild(objects, V909);
let manifestReads = 0;
const {service} = createService(objects, (key) => {
if (key !== `${PREFIX}/manifest.json`) {
return;
}
manifestReads += 1;
if (manifestReads === 2) {
publishMarker(objects, V909, descriptor);
}
});
const checksum = await service.resolveLatestDesktopChecksumFile({...APPIMAGE_PARAMS});
expect(checksum?.body).toBe(latestOf(V904).checksum);
});
it('lists an unpublished version while latest skips it', async () => {
const {service} = createService(incidentObjects());
const listed = await service.listDesktopVersions({...LATEST_PARAMS, limit: 10});
expect(listed.versions.map((entry) => entry.version)).toEqual([V908, V904]);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
});
});
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Readable} from 'node:stream';
import {describe, expect, it} from 'vitest';
import type {IStorageService} from '../../infrastructure/IStorageService';
import {DownloadService} from '../DownloadService';
const PREFIX = 'desktop/canary/linux/x64';
const BASE_URL = 'https://api.example.test';
const V1 = '2026.901.100000';
const V2 = '2026.902.100000';
const V3 = '2026.903.100000';
const V4 = '2026.904.100000';
const V5 = '2026.905.100000';
const LIST_PARAMS = {channel: 'canary', plat: 'linux', arch: 'x64', baseUrl: BASE_URL} as const;
type StoredObject = {body?: string; lastModified?: Date};
type StoredObjects = Map<string, StoredObject>;
function appImageFilename(version: string): string {
return `Fluxer-Canary-${version}-linux-x86_64.AppImage`;
}
function debFilename(version: string): string {
return `Fluxer-Canary-${version}-linux-amd64.deb`;
}
function addArtifact(objects: StoredObjects, filename: string, options: {sha256?: string; lastModified?: Date} = {}) {
objects.set(`${PREFIX}/${filename}`, {lastModified: options.lastModified});
if (options.sha256 !== undefined) {
objects.set(`${PREFIX}/${filename}.sha256`, {body: `${options.sha256} ${filename}\n`});
}
}
function createService(objects: StoredObjects) {
const reads: Array<string> = [];
const storageService = {
streamObject: async (params: {key: string}) => {
reads.push(params.key);
const object = objects.get(params.key);
if (object?.body == null) {
return null;
}
const buffer = Buffer.from(object.body, 'utf8');
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
},
listObjects: async (params: {prefix: string}) =>
Array.from(objects.entries())
.filter(([key]) => key.startsWith(params.prefix))
.sort(([left], [right]) => (left < right ? -1 : 1))
.map(([key, object]) => ({key, lastModified: object.lastModified})),
getObjectMetadata: async () => null,
} as unknown as IStorageService;
return {service: new DownloadService(storageService), reads};
}
function versionNumbers(versions: Array<{version: string}>): Array<string> {
return versions.map((entry) => entry.version);
}
describe('desktop version listing', () => {
it('lists versions newest first with the files of each version', async () => {
const objects: StoredObjects = new Map();
addArtifact(objects, appImageFilename(V1), {lastModified: new Date('2026-09-01T10:00:00Z')});
addArtifact(objects, appImageFilename(V3), {lastModified: new Date('2026-09-03T10:00:00Z')});
addArtifact(objects, debFilename(V3), {lastModified: new Date('2026-09-03T12:00:00Z')});
addArtifact(objects, appImageFilename(V5), {lastModified: new Date('2026-09-05T10:00:00Z')});
const {service} = createService(objects);
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
expect(versionNumbers(listed.versions)).toEqual([V5, V3, V1]);
expect(listed.hasMore).toBe(false);
expect(Object.keys(listed.versions[1].files).sort()).toEqual(['appimage', 'deb']);
expect(listed.versions[1].pub_date).toBe('2026-09-03T12:00:00.000Z');
expect(listed.versions[0].files.appimage.url).toBe(`${BASE_URL}/dl/desktop/canary/linux/x64/${V5}/appimage`);
});
it('excludes names that are not artefacts for the requested coordinate', async () => {
const objects: StoredObjects = new Map();
addArtifact(objects, appImageFilename(V3), {sha256: 'a'.repeat(64)});
objects.set(`${PREFIX}/nested/${appImageFilename(V5)}`, {});
objects.set(`${PREFIX}/manifest.json`, {body: '{}'});
objects.set(`${PREFIX}/RELEASES.json`, {body: '{}'});
objects.set(`${PREFIX}/releases.json`, {body: '{}'});
objects.set(`${PREFIX}/latest-linux.yml`, {body: 'version: 1'});
objects.set(`${PREFIX}/${appImageFilename(V4)}.blockmap`, {});
objects.set(`${PREFIX}/Fluxer-Canary-${V4}-linux-aarch64.AppImage`, {});
objects.set(`${PREFIX}/Fluxer-Canary-${V4}-mac-universal.dmg`, {});
const {service} = createService(objects);
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
expect(versionNumbers(listed.versions)).toEqual([V3]);
expect(Object.keys(listed.versions[0].files)).toEqual(['appimage']);
});
it('pages with limit, before and after and reports whether more remain', async () => {
const objects: StoredObjects = new Map();
for (const version of [V1, V2, V3, V4, V5]) {
addArtifact(objects, appImageFilename(version));
}
const {service} = createService(objects);
const firstPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2});
expect(versionNumbers(firstPage.versions)).toEqual([V5, V4]);
expect(firstPage.hasMore).toBe(true);
const olderPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2, before: V3});
expect(versionNumbers(olderPage.versions)).toEqual([V2, V1]);
expect(olderPage.hasMore).toBe(false);
const newerPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2, after: V3});
expect(versionNumbers(newerPage.versions)).toEqual([V5, V4]);
expect(newerPage.hasMore).toBe(false);
const between = await service.listDesktopVersions({...LIST_PARAMS, limit: 1, before: V5, after: V1});
expect(versionNumbers(between.versions)).toEqual([V4]);
expect(between.hasMore).toBe(true);
});
it('reports the sibling hash and treats a missing or malformed one as absent', async () => {
const hash = 'b'.repeat(64);
const objects: StoredObjects = new Map();
addArtifact(objects, appImageFilename(V3), {sha256: hash});
addArtifact(objects, appImageFilename(V2));
addArtifact(objects, appImageFilename(V1), {sha256: 'C'.repeat(64)});
const {service} = createService(objects);
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
expect(listed.versions[0].files.appimage).toEqual({
url: `${BASE_URL}/dl/desktop/canary/linux/x64/${V3}/appimage`,
sha256: hash,
checksum_url: `${BASE_URL}/dl/desktop/canary/linux/x64/${V3}/appimage.sha256`,
});
expect(listed.versions[1].files.appimage.sha256).toBeNull();
expect(listed.versions[1].files.appimage.checksum_url).toBeNull();
expect(listed.versions[2].files.appimage.sha256).toBeNull();
expect(listed.versions[2].files.appimage.checksum_url).toBeNull();
});
it('reads a checksum only for the versions it returns', async () => {
const objects: StoredObjects = new Map();
for (const version of [V1, V2, V3, V4, V5]) {
addArtifact(objects, appImageFilename(version), {sha256: 'd'.repeat(64)});
}
const {service, reads} = createService(objects);
await service.listDesktopVersions({...LIST_PARAMS, limit: 2});
expect(reads).toEqual([`${PREFIX}/${appImageFilename(V5)}.sha256`, `${PREFIX}/${appImageFilename(V4)}.sha256`]);
});
});
@@ -113,7 +113,10 @@ export function setupSyncStripeWebhookWorker(): void {
setInjectedWorkerService(new SyncTaskWorkerService({processStripeWebhook}));
}
let originalWebhookSecretDescriptor: PropertyDescriptor | undefined;
export function mockStripeWebhookSecret(secret = 'whsec_test'): void {
originalWebhookSecretDescriptor ??= Object.getOwnPropertyDescriptor(Config.stripe, 'webhookSecret');
Object.defineProperty(Config.stripe, 'webhookSecret', {
get: () => secret,
configurable: true,
@@ -121,6 +124,11 @@ export function mockStripeWebhookSecret(secret = 'whsec_test'): void {
}
export function restoreStripeWebhookSecret(): void {
if (originalWebhookSecretDescriptor) {
Object.defineProperty(Config.stripe, 'webhookSecret', originalWebhookSecretDescriptor);
originalWebhookSecretDescriptor = undefined;
return;
}
delete (
Config.stripe as {
webhookSecret?: string;
-2
View File
@@ -55,7 +55,6 @@ import {VoiceLiveKitRoot} from '@app/features/voice/components/VoiceLiveKitRoot'
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
import {useElectronScreenSharePicker} from '@app/features/voice/hooks/useElectronScreenSharePicker';
import {startScreenSharePiPController} from '@app/features/voice/state/ScreenSharePiPController';
import {startMediaDeviceStartupPreload} from '@app/features/voice/utils/MediaDeviceStartupPreload';
import {useNativeTitleBar} from '@app/features/window/hooks/useNativeTitleBar';
import {useStopFlashFrameOnFocus} from '@app/features/window/hooks/useStopFlashFrameOnFocus';
import {useWindowEventListeners} from '@app/features/window/hooks/useWindowEventListeners';
@@ -149,7 +148,6 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
};
}, []);
useEffect(() => startScreenSharePiPController(), []);
useEffect(() => startMediaDeviceStartupPreload(), []);
useServiceWorkerBadge();
useKeybindManager(i18n);
useDesktopElectronBridges();
@@ -8,6 +8,7 @@ import * as AuthenticationCommands from '@app/features/auth/commands/Authenticat
import AccountManager from '@app/features/auth/state/AccountManager';
import Authentication from '@app/features/auth/state/Authentication';
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
import {MediaDeviceStartupPreloadManager} from '@app/features/voice/components/MediaDeviceStartupPreloadManager';
import {NewDeviceMonitoringManager} from '@app/features/voice/components/NewDeviceMonitoringManager';
import {VoiceReconnectionManager} from '@app/features/voice/components/VoiceReconnectionManager';
import {clsx} from 'clsx';
@@ -35,6 +36,9 @@ export const AppLayout = observer(({children}: {children: React.ReactNode}) => {
return (
<>
{isAuthenticated && socket && <VoiceReconnectionManager data-flx="app.app-layout.voice-reconnection-manager" />}
{isAuthenticated && (
<MediaDeviceStartupPreloadManager data-flx="app.app-layout.media-device-startup-preload-manager" />
)}
{isAuthenticated && <NewDeviceMonitoringManager data-flx="app.app-layout.new-device-monitoring-manager" />}
{isAuthenticated && <RequiredActionGate data-flx="app.app-layout.required-action-gate" />}
<div
+216 -15
View File
@@ -1,21 +1,39 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UpdaterDownloadOption} from '@app/features/platform/types/Electron';
import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap';
import type {UpdaterEvent} from '@app/types/electron.d';
import {afterEach, describe, expect, test, vi} from 'vitest';
const {pushUpdateReadyModal} = vi.hoisted(() => ({pushUpdateReadyModal: vi.fn()}));
interface ManualUpdatePrompt {
options: ReadonlyArray<UpdaterDownloadOption>;
onDownload: (option: UpdaterDownloadOption) => void | Promise<void>;
}
const {
pushManualUpdateAvailableModal,
pushUnsupportedUpdateModal,
pushUpdateCheckFailedModal,
pushUpdateReadyModal,
pushUpToDateModal,
} = vi.hoisted(() => ({
pushManualUpdateAvailableModal: vi.fn<(prompt: ManualUpdatePrompt) => void>(),
pushUnsupportedUpdateModal: vi.fn(),
pushUpdateCheckFailedModal: vi.fn(),
pushUpdateReadyModal: vi.fn(),
pushUpToDateModal: vi.fn(),
}));
vi.mock('@app/features/updater/commands/UpdaterModalCommands', () => ({
pushDesktopUpdateDownloadFailedModal: vi.fn(),
pushDesktopUpdateInstallFailedModal: vi.fn(),
pushManualUpdateAvailableModal: vi.fn(),
pushUnsupportedUpdateModal: vi.fn(),
pushManualUpdateAvailableModal,
pushUnsupportedUpdateModal,
pushUpdateAvailableModal: vi.fn(),
pushUpdateCheckFailedModal: vi.fn(),
pushUpdateCheckFailedModal,
pushUpdateReadyModal,
pushUpToDateModal: vi.fn(),
pushUpToDateModal,
}));
vi.mock('@lingui/core/macro', () => ({
@@ -29,15 +47,30 @@ vi.mock('@app/features/platform/utils/ClientInfo', () => ({
installVoiceMenuTestBootstrap();
const REPORTED_VERSION = '2026.908.173325';
const NEWER_VERSION = '2026.909.202036';
const CANARY_X64 = 'https://api.canary.fluxer.app/dl/desktop/canary/linux/x64';
const REPORTED_DEB_DOWNLOAD = [
`${CANARY_X64}/${REPORTED_VERSION}/deb`,
`Fluxer-Canary-${REPORTED_VERSION}-linux-amd64.deb`,
];
const NEWER_DEB_DOWNLOAD = [`${CANARY_X64}/${NEWER_VERSION}/deb`, `Fluxer-Canary-${NEWER_VERSION}-linux-amd64.deb`];
let nativeEventListener: ((event: UpdaterEvent) => void) | null = null;
let onUpdaterCheck: (() => void) | null = null;
let onUpdaterCheck: ((context: string) => void | Promise<void>) | null = null;
let loadedUpdater: {dispose: () => void} | null = null;
function installElectronApi(): void {
const updaterCheck = vi.fn((context: string) => Promise.resolve(onUpdaterCheck?.(context)));
const downloadFile = vi.fn<(url: string, suggestedName: string) => Promise<{success: boolean}>>(() =>
Promise.resolve({success: true}),
);
const openExternal = vi.fn<(url: string) => Promise<void>>(() => Promise.resolve());
function installElectronApi(platform: string): void {
nativeEventListener = null;
onUpdaterCheck = null;
(window as unknown as {electron: unknown}).electron = {
platform: 'win32',
platform,
buildChannel: 'canary',
onUpdaterEvent: (listener: (event: UpdaterEvent) => void) => {
nativeEventListener = listener;
@@ -45,12 +78,11 @@ function installElectronApi(): void {
nativeEventListener = null;
};
},
updaterCheck: () => {
onUpdaterCheck?.();
return Promise.resolve();
},
updaterCheck,
updaterDownload: () => Promise.resolve(),
updaterInstall: () => Promise.resolve(),
downloadFile,
openExternal,
};
}
@@ -59,16 +91,16 @@ function emit(event: UpdaterEvent): void {
nativeEventListener(event);
}
async function loadUpdater() {
async function loadUpdater(platform = 'win32') {
vi.resetModules();
installElectronApi();
installElectronApi(platform);
const {default: Updater} = await import('@app/features/app/state/Updater');
loadedUpdater = Updater;
await vi.waitFor(() => {
expect(nativeEventListener).not.toBeNull();
expect(Updater.lastCheckedAt).not.toBeNull();
});
pushUpdateReadyModal.mockClear();
vi.clearAllMocks();
return Updater;
}
@@ -77,7 +109,37 @@ function emitUserDownloadCompletion(version: string): void {
emit({type: 'downloaded', context: 'user', version});
}
function emitLinuxManualUpdate(context: 'user' | 'background', version: string): void {
emit({
type: 'available',
context,
version,
downloadStarted: false,
downloadUrl: `${CANARY_X64}/latest/appimage`,
downloadOptions: [
{
format: 'deb',
label: 'DEB package',
url: `${CANARY_X64}/latest/deb`,
suggestedName: `Fluxer-Canary-${version}-linux-amd64.deb`,
sha256: `deb-${version}`,
},
],
});
}
async function openLinuxManualUpdatePrompt() {
const Updater = await loadUpdater('linux');
emitLinuxManualUpdate('background', REPORTED_VERSION);
await Updater.applyUpdate();
const prompt = pushManualUpdateAvailableModal.mock.lastCall?.[0];
const debOption = prompt?.options.find((option) => option.format === 'deb');
if (!prompt || !debOption) throw new Error('Manual update prompt never offered a deb package');
return {Updater, download: () => Promise.resolve(prompt.onDownload(debOption))};
}
afterEach(() => {
vi.useRealTimers();
loadedUpdater?.dispose();
loadedUpdater = null;
});
@@ -110,3 +172,142 @@ describe('updater update-ready surface', () => {
expect(pushUpdateReadyModal).toHaveBeenCalledTimes(1);
});
});
describe('updater manual Linux package download', () => {
test('refreshes the check as the user and saves a newer build under its own name', async () => {
const {Updater, download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => {
emit({type: 'checking', context: 'user'});
emitLinuxManualUpdate('user', NEWER_VERSION);
};
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([NEWER_DEB_DOWNLOAD]);
expect(pushManualUpdateAvailableModal).toHaveBeenCalledTimes(1);
expect(Updater.displayVersion).toBe(NEWER_VERSION);
expect(Updater.isChecking).toBe(false);
});
test('saves the build the prompt named when the refresh fails', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emit({type: 'error', context: 'user', message: 'fetch failed'});
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(pushUpdateCheckFailedModal).not.toHaveBeenCalled();
});
test('saves the build the prompt named when the refresh finds no update', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emit({type: 'not-available', context: 'user'});
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(pushUpToDateModal).not.toHaveBeenCalled();
});
test('saves the build the prompt named when the refresh reports an unsupported install', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emit({type: 'unsupported', context: 'user', reason: 'managed-package'});
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(pushUnsupportedUpdateModal).not.toHaveBeenCalled();
});
test('saves the build the prompt named when the refresh does not answer in time', async () => {
const {Updater, download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => new Promise<void>(() => {});
vi.useFakeTimers();
const downloaded = download();
await vi.advanceTimersByTimeAsync(5_000);
await downloaded;
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(Updater.isChecking).toBe(false);
});
test.each([
['a newer build', () => emitLinuxManualUpdate('user', NEWER_VERSION)],
['no update', () => emit({type: 'not-available', context: 'user'})],
['a failure', () => emit({type: 'error', context: 'user', message: 'fetch failed'})],
])('stays quiet when a timed out refresh answers with %s after the download', async (_answer, answerLate) => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => new Promise<void>(() => {});
vi.useFakeTimers();
const downloaded = download();
await vi.advanceTimersByTimeAsync(5_000);
await downloaded;
answerLate();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(pushManualUpdateAvailableModal).toHaveBeenCalledTimes(1);
expect(pushUpToDateModal).not.toHaveBeenCalled();
expect(pushUpdateCheckFailedModal).not.toHaveBeenCalled();
});
test('saves the refreshed build when a late failure from an earlier refresh lands inside the next one', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => new Promise<void>(() => {});
vi.useFakeTimers();
const firstDownload = download();
await vi.advanceTimersByTimeAsync(5_000);
await firstDownload;
onUpdaterCheck = () => {
emit({type: 'error', context: 'user', message: 'fetch failed'});
emitLinuxManualUpdate('user', NEWER_VERSION);
};
await download();
expect(updaterCheck.mock.calls).toEqual([['user'], ['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD, NEWER_DEB_DOWNLOAD]);
expect(pushManualUpdateAvailableModal).toHaveBeenCalledTimes(1);
expect(pushUpdateCheckFailedModal).not.toHaveBeenCalled();
});
test('still shows the failure of a user check made while a package is being saved', async () => {
const {Updater, download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emitLinuxManualUpdate('user', REPORTED_VERSION);
downloadFile.mockImplementationOnce(() => new Promise<{success: boolean}>(() => {}));
void download();
await vi.waitFor(() => expect(downloadFile).toHaveBeenCalledTimes(1));
onUpdaterCheck = () => emit({type: 'error', context: 'user', message: 'fetch failed'});
await Updater.checkForUpdates(true, true);
expect(updaterCheck.mock.calls).toEqual([['user'], ['user']]);
expect(pushUpdateCheckFailedModal).toHaveBeenCalledTimes(1);
});
test('does not refresh when a check is already running at the click', async () => {
const {Updater, download} = await openLinuxManualUpdatePrompt();
void Updater.checkForUpdates(true);
await download();
expect(updaterCheck).not.toHaveBeenCalled();
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
});
test('saves one package when the button is pressed twice during the refresh', async () => {
const {download} = await openLinuxManualUpdatePrompt();
let answerRefresh = () => {};
onUpdaterCheck = () =>
new Promise<void>((resolve) => {
answerRefresh = () => {
emitLinuxManualUpdate('user', NEWER_VERSION);
resolve();
};
});
const firstClick = download();
const secondClick = download();
answerRefresh();
await Promise.all([firstClick, secondClick]);
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([NEWER_DEB_DOWNLOAD]);
});
test('opens the pinned link and never the download page when saving fails', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emitLinuxManualUpdate('user', REPORTED_VERSION);
downloadFile.mockResolvedValueOnce({success: false});
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(openExternal.mock.calls).toEqual([[`${CANARY_X64}/${REPORTED_VERSION}/deb`]]);
});
});
+50 -2
View File
@@ -51,6 +51,7 @@ export const DOWNLOADING_UPDATE_DESCRIPTOR = msg({
const logger = new Logger('Updater');
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
const MIN_CHECK_INTERVAL_MS = 60 * 1000;
const MANUAL_DOWNLOAD_REFRESH_TIMEOUT_MS = 5 * 1000;
const VERSION_ENDPOINT = '/version.json';
const CURRENT_BUILD_VERSION = Config.PUBLIC_BUILD_VERSION ?? null;
const ALLOWED_WEB_UPDATE_HOSTS = new Set(['web.fluxer.app', 'web.canary.fluxer.app']);
@@ -132,6 +133,7 @@ class Updater {
private backgroundCheckCleanups: Array<() => void> = [];
private unsubscribeNativeEvents: (() => void) | null = null;
private updateReadyNagbarDismissedVersion: string | null = null;
private pendingManualDownloadRefreshes = 0;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
@@ -288,7 +290,17 @@ class Updater {
}
private handleNativeEvent(event: UpdaterEvent): void {
const isUserCheck = event.context === 'user';
const isManualDownloadRefreshResult =
event.context === 'user' &&
this.pendingManualDownloadRefreshes > 0 &&
(event.type === 'available' ||
event.type === 'not-available' ||
event.type === 'error' ||
event.type === 'unsupported');
if (isManualDownloadRefreshResult) {
this.pendingManualDownloadRefreshes -= 1;
}
const isUserCheck = event.context === 'user' && !isManualDownloadRefreshResult;
const isBackgroundOrFocusCheck = event.context === 'background' || event.context === 'focus';
const shouldSurfaceNativeDesktopUpdate = this.shouldSurfaceNativeDesktopUpdate();
const shouldShowImmediateUserResult = isUserCheck && !this.checkInProgress;
@@ -643,7 +655,7 @@ class Updater {
currentVersion: this.currentVersion,
version: this.updateInfo.native.version,
options: this.nativeManualDownloadOptions,
onDownload: (option) => this.downloadManualNativeUpdateOrOpen(option.url, option.suggestedName),
onDownload: (option) => this.downloadManualNativeUpdateOption(option),
});
return;
}
@@ -651,6 +663,42 @@ class Updater {
pushUpdateAvailableModal(this.updateInfo.native.version, () => this.downloadManualNativeUpdateOrOpen(url));
}
private async refreshManualNativeDownloadOption(option: UpdaterDownloadOption): Promise<UpdaterDownloadOption> {
if (this.checkInProgress) {
return option;
}
this.transition({type: 'check.started'});
let timeoutId: number | undefined;
const timedOut = new Promise<boolean>((resolve) => {
timeoutId = window.setTimeout(() => resolve(false), MANUAL_DOWNLOAD_REFRESH_TIMEOUT_MS);
});
try {
this.pendingManualDownloadRefreshes += 1;
const checked = await Promise.race([this.checkNativeUpdate('user'), timedOut]);
if (!checked) {
return option;
}
return this.nativeManualDownloadOptions.find((candidate) => candidate.format === option.format) ?? option;
} finally {
window.clearTimeout(timeoutId);
this.transition({type: 'check.finished', now: Date.now()});
}
}
private async downloadManualNativeUpdateOption(option: UpdaterDownloadOption): Promise<void> {
if (this.manualNativeDownloadInFlight) {
return;
}
this.transition({type: 'manualDownload.started'});
let currentOption = option;
try {
currentOption = await this.refreshManualNativeDownloadOption(option);
} finally {
this.transition({type: 'manualDownload.finished'});
}
await this.downloadManualNativeUpdateOrOpen(currentOption.url, currentOption.suggestedName);
}
private async downloadManualNativeUpdateOrOpen(url: string, suggestedName?: string): Promise<void> {
if (this.manualNativeDownloadInFlight) {
return;
@@ -92,7 +92,7 @@ import {CloudUpload} from '@app/features/messaging/upload/CloudUpload';
import {canAttachFilesInChannel} from '@app/features/messaging/utils/AttachmentPermissionUtils';
import {openFilePicker} from '@app/features/messaging/utils/FilePickerUtils';
import * as FileUploadUtils from '@app/features/messaging/utils/FileUploadUtils';
import {hasVisibleMessageContent} from '@app/features/messaging/utils/MessageRequestUtils';
import {getComposerMessageContent, hasVisibleMessageContent} from '@app/features/messaging/utils/MessageRequestUtils';
import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager';
import {
resolveTypedEmojiShortcodes,
@@ -267,6 +267,7 @@ export const LexicalChannelTextareaContent = observer(
const referencedMessage = MessageReply.getReferencedMessage(channel.id);
const editingMessage = editingMobileMessageId ? Messages.getMessage(channel.id, editingMobileMessageId) : null;
const editingMessageForComposer = editingMessage === undefined ? null : editingMessage;
const isEditingMessageOnMobile = editingMessageForComposer !== null && mobileLayout.enabled;
const maxMessageLength = Limits.getMaxMessageLength();
const premiumMaxLength = Limits.getStockValue('max_message_length', maxMessageLength);
const maxAttachments = Limits.getMaxAttachmentsPerMessage();
@@ -496,6 +497,7 @@ export const LexicalChannelTextareaContent = observer(
isSlotMenu,
onCursorMove,
handleSelect,
specialMentionsAllowed,
} = useLexicalAutocomplete({
channel,
handleRef,
@@ -526,7 +528,11 @@ export const LexicalChannelTextareaContent = observer(
() => resolveTypedEmojiContent(wireValue.trim()),
[resolveTypedEmojiContent, wireValue],
);
const hasMessageContent = useMemo(() => hasVisibleMessageContent(trimmedMessageContent), [trimmedMessageContent]);
const composerMessageContent = useMemo(
() => getComposerMessageContent(trimmedMessageContent, isEditingMessageOnMobile),
[isEditingMessageOnMobile, trimmedMessageContent],
);
const hasMessageContent = useMemo(() => hasVisibleMessageContent(composerMessageContent), [composerMessageContent]);
const isSubmissionBlockedBySlowmode = useMemo(() => {
if (!isSlowmodeActive || isEditingMessageInComposer) {
return false;
@@ -708,7 +714,7 @@ export const LexicalChannelTextareaContent = observer(
}, [channel.id, hasAttachments, hasPendingSticker]);
const showAttachments = hasAttachments;
const showStickers = hasPendingSticker;
const isOverCharacterLimit = trimmedMessageContent.length > maxMessageLength;
const isOverCharacterLimit = composerMessageContent.length > maxMessageLength;
const canSubmit =
!textareaInputDisabled &&
!isSubmissionBlockedBySlowmode &&
@@ -1343,9 +1349,11 @@ export const LexicalChannelTextareaContent = observer(
initialSegments={initialDraftRef.current.segments}
slotResolvers={slotResolvers}
emojiShortcodeResolver={composerEmojiResolver}
specialMentionsAllowed={specialMentionsAllowed}
channelId={channel.id}
guildId={channel.guildId}
submitOnEnter={!mobileLayout.enabled}
silentMessagePrefix={!isEditingMessageOnMobile}
focusRingTarget={containerRef}
focusRingEnabled={!textareaInputDisabled && Accessibility.showTextareaFocusRing}
className={lexicalStyles.composerEditable}
@@ -1406,7 +1414,7 @@ export const LexicalChannelTextareaContent = observer(
styles.inputSection,
)}
<MessageCharacterCounter
currentLength={trimmedMessageContent.length}
currentLength={composerMessageContent.length}
maxLength={maxMessageLength}
canUpgrade={maxMessageLength < premiumMaxLength}
premiumMaxLength={premiumMaxLength}
@@ -19,6 +19,7 @@ import Emoji from '@app/features/emoji/state/Emoji';
import {checkEmojiAvailability} from '@app/features/expressions/utils/ExpressionPermissionUtils';
import Guilds from '@app/features/guild/state/Guilds';
import {TRY_AGAIN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {dropTrailingEmptyBlockquoteLines} from '@app/features/lexical/composer/blockquoteLines';
import GuildMembers from '@app/features/member/state/GuildMembers';
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import {SafeMarkdown} from '@app/features/messaging/components/markdown';
@@ -193,7 +194,7 @@ export const UserMessage = observer(() => {
if (message.messageSnapshots) {
return;
}
const content = (actualContent ?? '').trim();
const content = dropTrailingEmptyBlockquoteLines(actualContent ?? '').trim();
if (!content) {
if (canSubmitEmptyMessageEdit(message)) {
if (message.content.length === 0) {
@@ -9,6 +9,7 @@ import type {Channel} from '@app/features/channel/models/Channel';
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet';
import {ExpressionPickerPopout} from '@app/features/expressions/components/popouts/ExpressionPickerPopout';
import {dropTrailingEmptyBlockquoteLines} from '@app/features/lexical/composer/blockquoteLines';
import {LexicalRichInput, type LexicalRichInputHandle} from '@app/features/lexical/composer/LexicalRichInput';
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
import {convertMarkdownToSegments} from '@app/features/messaging/utils/MarkdownToSegmentUtils';
@@ -145,7 +146,7 @@ export const ChannelOverviewTopicEditor = observer(
});
}, [actualTopic, form, isTopicInitialized]);
const handleTopicChange = useCallback((_display: string, _segments: Array<MentionSegment>, wire: string) => {
setActualTopic(wire);
setActualTopic(dropTrailingEmptyBlockquoteLines(wire));
}, []);
const handleTopicEmojiSelect = useCallback((emoji: FlatEmoji, shiftKey: boolean) => {
const composer = composerRef.current;
@@ -32,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 {COMMAND_PRIORITY_HIGH, PASTE_COMMAND} from 'lexical';
import {$addUpdateTag, COMMAND_PRIORITY_HIGH, PASTE_COMMAND, PASTE_TAG} from 'lexical';
import type React from 'react';
import {useCallback, useEffect} from 'react';
@@ -233,6 +233,7 @@ export function useChannelComposerPaste({
if (!insertPastedText(pastedText)) {
return false;
}
$addUpdateTag(PASTE_TAG);
event.preventDefault();
return true;
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,300 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type BlockquoteEdit,
type BlockquoteLine,
planBlockquoteArrowLeft,
planBlockquoteBackspace,
planBlockquoteLineBreak,
resolveBlockquoteCaret,
} from '@app/features/lexical/composer/blockquoteLines';
import {
$captureSelectionOffsets,
$getComposerBlockquoteState,
$replaceComposerRange,
$selectComposerOffset,
$selectComposerRange,
} from '@app/features/lexical/composer/composerOffsets';
import {
$createComposerBlockquoteLineNode,
$getComposerLineNodes,
$isComposerBlockquoteLineNode,
} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
import {$isComposerBlockquoteMarkerNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteMarkerNode';
import {$isComposerCommandNode} from '@app/features/lexical/composer/nodes/ComposerCommandNode';
import {$isComposerPlainSegmentNode} from '@app/features/lexical/composer/nodes/ComposerPlainSegmentNode';
import {isIMEComposing} from '@app/features/messaging/utils/IMECompositionUtils';
import {mergeRegister} from '@lexical/utils';
import {
$createTextNode,
$isLineBreakNode,
COMMAND_PRIORITY_CRITICAL,
COMMAND_PRIORITY_HIGH,
DELETE_CHARACTER_COMMAND,
type ElementNode,
INSERT_LINE_BREAK_COMMAND,
INSERT_PARAGRAPH_COMMAND,
KEY_ARROW_LEFT_COMMAND,
type LexicalEditor,
type LexicalNode,
type LineBreakNode,
SELECTION_CHANGE_COMMAND,
TextNode,
} from 'lexical';
export interface ComposerLine {
nodes: Array<LexicalNode>;
lineBreak: LineBreakNode | null;
}
type BlockquotePlanner = (text: string, lines: ReadonlyArray<BlockquoteLine>, caret: number) => BlockquoteEdit | null;
export function $splitComposerLines(paragraph: ElementNode): Array<ComposerLine> {
const lines: Array<ComposerLine> = [{nodes: [], lineBreak: null}];
for (const node of $getComposerLineNodes(paragraph)) {
if ($isLineBreakNode(node)) {
lines.push({nodes: [], lineBreak: node});
} else {
lines[lines.length - 1]!.nodes.push(node);
}
}
return lines;
}
function $applyBlockquotePlan(editor: LexicalEditor, planner: BlockquotePlanner): boolean {
if (editor.isComposing()) {
return false;
}
const {scanText, selection, lines} = $getComposerBlockquoteState();
if (selection == null || selection.anchor !== selection.focus || lines.length === 0) {
return false;
}
const edit = planner(scanText, lines, selection.anchor);
if (edit == null) {
return false;
}
$replaceComposerRange(edit.start, edit.end, {kind: 'text', text: edit.text}, {leading: false, trailing: false});
$selectComposerOffset(edit.caret);
return true;
}
export function $snapCaretOutOfBlockquoteMarker(): void {
const {selection, lines} = $getComposerBlockquoteState();
if (selection == null || selection.anchor !== selection.focus || lines.length === 0) {
return;
}
const caret = resolveBlockquoteCaret(lines, selection.anchor);
if (caret !== selection.anchor) {
$selectComposerOffset(caret);
}
}
function shouldWrapLine(line: ComposerLine, quoted: boolean | undefined): boolean {
return quoted === true && $isComposerBlockquoteMarkerNode(line.nodes[0]);
}
function $blockquoteStructureMatches(
paragraph: ElementNode,
lines: ReadonlyArray<ComposerLine>,
quoted: ReadonlyArray<boolean>,
): boolean {
const children = paragraph.getChildren();
let index = 0;
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
const line = lines[lineIndex]!;
if (line.lineBreak != null) {
if (!line.lineBreak.is(children[index])) {
return false;
}
index += 1;
}
if (shouldWrapLine(line, quoted[lineIndex])) {
const wrapper = children[index];
index += 1;
if (!$isComposerBlockquoteLineNode(wrapper)) {
return false;
}
const inner = wrapper.getChildren();
if (inner.length !== line.nodes.length || inner.some((node, position) => !node.is(line.nodes[position]))) {
return false;
}
continue;
}
for (const node of line.nodes) {
if (!node.is(children[index])) {
return false;
}
index += 1;
}
}
return index === children.length;
}
export function $syncComposerBlockquoteLines(paragraph: ElementNode, quoted: ReadonlyArray<boolean>): void {
if ($blockquoteStructureMatches(paragraph, $splitComposerLines(paragraph), quoted)) {
return;
}
const selection = $captureSelectionOffsets();
for (const child of paragraph.getChildren()) {
if ($isComposerBlockquoteLineNode(child)) {
for (const node of $getComposerLineNodes(child)) {
child.insertBefore(node);
}
child.remove();
}
}
$splitComposerLines(paragraph).forEach((line, index) => {
if (shouldWrapLine(line, quoted[index])) {
const wrapper = $createComposerBlockquoteLineNode();
line.nodes[0]!.insertBefore(wrapper);
wrapper.append(...line.nodes);
}
});
if (selection != null) {
$selectComposerRange(selection.anchor, selection.focus);
}
}
function isPlainTextLeaf(node: LexicalNode | undefined): node is TextNode {
return node instanceof TextNode && !$isComposerPlainSegmentNode(node) && !$isComposerCommandNode(node);
}
function $spliceLeadingText(
nodes: ReadonlyArray<LexicalNode>,
offset: number,
deleteCount: number,
text: string,
): boolean {
const covered: Array<TextNode> = [];
let length = 0;
for (const node of nodes) {
if (!isPlainTextLeaf(node) || length >= offset + deleteCount) {
break;
}
covered.push(node);
length += node.getTextContentSize();
}
const first = covered[0];
if (first == null || length < offset + deleteCount) {
return false;
}
const combined = covered.map((node) => node.getTextContent()).join('');
first.setTextContent(`${combined.slice(0, offset)}${text}${combined.slice(offset + deleteCount)}`);
for (const node of covered.slice(1)) {
node.remove();
}
return true;
}
function $prefixComposerLine(line: ComposerLine): void {
const first = line.nodes[0];
if (isPlainTextLeaf(first)) {
first.setTextContent(`> ${first.getTextContent()}`);
} else if (first != null) {
first.insertBefore($createTextNode('> '));
} else if (line.lineBreak != null) {
line.lineBreak.insertAfter($createTextNode('> '));
}
}
export function $rewriteMultilineBlockquoteMarker(
lines: ReadonlyArray<ComposerLine>,
lineSources: ReadonlyArray<string>,
markerEnds: ReadonlyArray<number>,
): boolean {
const index = markerEnds.findIndex(
(end, lineIndex) => end > 0 && lineSources[lineIndex]!.slice(0, end).trimStart().startsWith('>>> '),
);
if (index < 0) {
return false;
}
const indent = markerEnds[index]! - 4;
const lineStarts: Array<number> = [];
let offset = 0;
for (const line of lines) {
lineStarts.push(offset);
offset += line.nodes.reduce((sum, node) => sum + node.getTextContentSize(), 0) + 1;
}
const selection = $captureSelectionOffsets();
const markerStart = lineStarts[index]! + indent;
const typed =
selection != null &&
selection.anchor === selection.focus &&
selection.anchor > markerStart &&
selection.anchor <= markerStart + 4;
if (!$spliceLeadingText(lines[index]!.nodes, indent, 4, '> ')) {
return false;
}
const prefixedStarts = typed ? [] : lineStarts.slice(index + 1);
if (!typed) {
for (const line of lines.slice(index + 1)) {
$prefixComposerLine(line);
}
}
if (selection != null) {
const map = (value: number): number => {
if (value > markerStart && value < markerStart + 4) {
return markerStart + 2;
}
let next = value >= markerStart + 4 ? value - 2 : value;
for (const start of prefixedStarts) {
if (value >= start) {
next += 2;
}
}
return next;
};
$selectComposerRange(map(selection.anchor), map(selection.focus));
}
return true;
}
export function registerComposerBlockquote(editor: LexicalEditor): () => void {
return mergeRegister(
editor.registerCommand(
INSERT_LINE_BREAK_COMMAND,
(selectStart) => !selectStart && $applyBlockquotePlan(editor, planBlockquoteLineBreak),
COMMAND_PRIORITY_HIGH,
),
editor.registerCommand(
INSERT_PARAGRAPH_COMMAND,
() => $applyBlockquotePlan(editor, planBlockquoteLineBreak),
COMMAND_PRIORITY_HIGH,
),
editor.registerCommand(
DELETE_CHARACTER_COMMAND,
(isBackward) => isBackward && $applyBlockquotePlan(editor, planBlockquoteBackspace),
COMMAND_PRIORITY_CRITICAL,
),
editor.registerCommand(
KEY_ARROW_LEFT_COMMAND,
(event) => {
if (event.shiftKey || event.metaKey || isIMEComposing(event) || editor.isComposing()) {
return false;
}
const {selection, lines} = $getComposerBlockquoteState();
if (selection == null || selection.anchor !== selection.focus) {
return false;
}
const caret = planBlockquoteArrowLeft(lines, selection.anchor);
if (caret == null) {
return false;
}
event.preventDefault();
$selectComposerOffset(caret);
return true;
},
COMMAND_PRIORITY_HIGH,
),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
if (!editor.isComposing()) {
$snapCaretOutOfBlockquoteMarker();
}
return false;
},
COMMAND_PRIORITY_HIGH,
),
);
}
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {registerComposerCodeIndent} from '@app/features/lexical/composer/ComposerCodeIndent';
import {
$getComposerDisplayText,
$replaceComposerRange,
$selectComposerRange,
} from '@app/features/lexical/composer/composerOffsets';
import {ComposerMentionNode} from '@app/features/lexical/composer/nodes/ComposerMentionNode';
import {createEditor, KEY_TAB_COMMAND} from 'lexical';
import {describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/lexical/composer/nodes/ComposerMentionPill', () => ({ComposerMentionPill: () => null}));
vi.mock('@app/features/lexical/composer/nodes/ComposerCustomEmoji', () => ({ComposerCustomEmoji: () => null}));
vi.mock('@app/features/lexical/composer/nodes/ComposerStandardEmoji', () => ({ComposerStandardEmoji: () => null}));
describe('registerComposerCodeIndent', () => {
it('indents a block opened after a mention whose name holds a backtick', () => {
const editor = createEditor({
namespace: 'composer-code-indent-test',
nodes: [ComposerMentionNode],
onError: (error) => {
throw error;
},
});
registerComposerCodeIndent(editor, {current: false});
editor.update(
() => {
$replaceComposerRange(
0,
0,
{kind: 'mention', mentionType: 'user', id: '1', display: '@`bob', wire: '<@1>'},
{trailing: false},
);
$replaceComposerRange(5, 5, {kind: 'text', text: ' hi ```js\ncode'}, {leading: false, trailing: false});
$selectComposerRange(19, 19);
},
{discrete: true},
);
const event = Object.assign(new Event('keydown', {cancelable: true}), {shiftKey: false}) as KeyboardEvent;
expect(editor.dispatchCommand(KEY_TAB_COMMAND, event)).toBe(true);
expect(editor.read(() => $getComposerDisplayText())).toBe('@`bob hi ```js\ncode\t');
});
});
@@ -4,7 +4,7 @@ import type {ComposerTypeaheadActiveState} from '@app/features/lexical/composer/
import {analyzeCodeIndent} from '@app/features/lexical/composer/codeBlockIndent';
import {
$captureSelectionOffsets,
$getComposerDisplayText,
$getComposerScanText,
$replaceComposerRange,
$selectComposerRange,
} from '@app/features/lexical/composer/composerOffsets';
@@ -27,7 +27,7 @@ export function registerComposerCodeIndent(
const backward = offsets.anchor > offsets.focus;
const start = Math.min(offsets.anchor, offsets.focus);
const end = Math.max(offsets.anchor, offsets.focus);
const plan = analyzeCodeIndent($getComposerDisplayText(), start, end, event != null && event.shiftKey === true);
const plan = analyzeCodeIndent($getComposerScanText(), start, end, event != null && event.shiftKey === true);
if (plan == null) {
return false;
}
@@ -7,6 +7,7 @@ import {
$selectComposerNodeBoundary,
$selectComposerRange,
} from '@app/features/lexical/composer/composerOffsets';
import {$isComposerBlockquoteLineNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
import {$createComposerCustomEmojiNode} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode';
import {
$createComposerStandardEmojiNode,
@@ -72,7 +73,7 @@ export function $convertEmojiShortcode(node: TextNode, resolve: ComposerEmojiRes
return;
}
const parent = node.getParent();
if (parent == null || parent.getType() !== 'paragraph') {
if (parent == null || (parent.getType() !== 'paragraph' && !$isComposerBlockquoteLineNode(parent))) {
return;
}
const text = node.getTextContent();
@@ -0,0 +1,181 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {registerComposerEnter} from '@app/features/lexical/composer/ComposerEnter';
import {$replaceComposerRange, $selectComposerRange} from '@app/features/lexical/composer/composerOffsets';
import {ComposerMentionNode} from '@app/features/lexical/composer/nodes/ComposerMentionNode';
import {ComposerPlainSegmentNode} from '@app/features/lexical/composer/nodes/ComposerPlainSegmentNode';
import {$createSlashSlotNode, SlashSlotNode} from '@app/features/lexical/composer/nodes/SlashSlotNode';
import {SlashSlotPlaceholderNode} from '@app/features/lexical/composer/nodes/SlashSlotPlaceholderNode';
import {$getRoot, $setSelection, createEditor, type ElementNode, KEY_ENTER_COMMAND, type LexicalEditor} from 'lexical';
import {describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/lexical/composer/nodes/ComposerMentionPill', () => ({ComposerMentionPill: () => null}));
vi.mock('@app/features/lexical/composer/nodes/ComposerCustomEmoji', () => ({ComposerCustomEmoji: () => null}));
vi.mock('@app/features/lexical/composer/nodes/ComposerStandardEmoji', () => ({ComposerStandardEmoji: () => null}));
interface EnterOptions {
modifiers?: Partial<Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'>>;
submitOnEnter?: boolean;
typeaheadActive?: boolean;
}
const NEWLINE = {handled: false, prevented: false, sent: 0};
const SENT = {handled: true, prevented: true, sent: 1};
function createComposer(seed: () => void): LexicalEditor {
const editor = createEditor({
namespace: 'composer-enter-test',
nodes: [ComposerMentionNode, ComposerPlainSegmentNode, SlashSlotNode, SlashSlotPlaceholderNode],
onError: (error) => {
throw error;
},
});
editor.update(seed, {discrete: true});
return editor;
}
function typed(text: string, anchor: number, focus = anchor): LexicalEditor {
return createComposer(() => {
$replaceComposerRange(0, 0, {kind: 'text', text}, {leading: false, trailing: false});
$selectComposerRange(anchor, focus);
});
}
function typedWithRequiredSlot(text: string, offset: number): LexicalEditor {
return createComposer(() => {
$replaceComposerRange(0, 0, {kind: 'text', text}, {leading: false, trailing: false});
const slot = $createSlashSlotNode('value', 'string', true);
$getRoot().getFirstChildOrThrow<ElementNode>().append(slot);
slot.ensurePlaceholder();
$selectComposerRange(offset, offset);
});
}
function slotValidity(editor: LexicalEditor): string {
return editor.read(() =>
$getRoot().getFirstChildOrThrow<ElementNode>().getLastChildOrThrow<SlashSlotNode>().getValidity(),
);
}
function pressEnter(
editor: LexicalEditor,
{modifiers = {}, submitOnEnter = true, typeaheadActive = false}: EnterOptions = {},
): typeof SENT {
const onEnter = vi.fn();
registerComposerEnter(editor, {
typeaheadActiveState: {current: typeaheadActive},
getSubmitOnEnter: () => submitOnEnter,
getOnEnter: () => onEnter,
});
const event = Object.assign(
new Event('keydown', {cancelable: true}),
{altKey: false, ctrlKey: false, metaKey: false, shiftKey: false},
modifiers,
) as KeyboardEvent;
const handled = editor.dispatchCommand(KEY_ENTER_COMMAND, event);
return {handled, prevented: event.defaultPrevented, sent: onEnter.mock.calls.length};
}
describe('registerComposerEnter', () => {
it.each([
['after a lone opening fence', '```\ncode', 8],
['on the empty line after an opening fence', '```\n', 4],
['right before the closing fence', '```\ncode\n```', 9],
])('inserts a newline %s without preventing the key', (_label, text, offset) => {
expect(pressEnter(typed(text, offset))).toEqual(NEWLINE);
});
it('sends after the closing fence', () => {
expect(pressEnter(typed('```\ncode\n```', 12))).toEqual(SENT);
});
it('still sends plain text', () => {
expect(pressEnter(typed('hello', 5))).toEqual(SENT);
});
it('still sends on Ctrl+Enter inside a block', () => {
expect(pressEnter(typed('```\ncode', 8), {modifiers: {ctrlKey: true}})).toEqual(SENT);
});
it('still sends on Cmd+Enter inside a block', () => {
expect(pressEnter(typed('```\ncode', 8), {modifiers: {metaKey: true}})).toEqual(SENT);
});
it('inserts a newline on Alt+Enter inside a block', () => {
expect(pressEnter(typed('```\ncode', 8), {modifiers: {altKey: true}})).toEqual(NEWLINE);
});
it('still leaves Shift+Enter to the line break', () => {
expect(pressEnter(typed('hello', 5), {modifiers: {shiftKey: true}})).toEqual(NEWLINE);
});
it('still leaves Shift+Enter to the line break inside a block', () => {
expect(pressEnter(typed('```\ncode', 8), {modifiers: {shiftKey: true}})).toEqual(NEWLINE);
});
it('sends on a list item line that holds a fence', () => {
expect(pressEnter(typed('- ```', 5))).toEqual(SENT);
});
it('sends in prose that holds an unmatched fence', () => {
expect(pressEnter(typed('use ``` for code blocks', 23))).toEqual(SENT);
});
it('decides on the start of a backward selection inside a block', () => {
expect(pressEnter(typed('```\ncode', 7, 5))).toEqual(NEWLINE);
});
it('sends when the selection starts before the opening fence', () => {
expect(pressEnter(typed('```\ncode', 0, 8))).toEqual(SENT);
});
it('sends when nothing is selected', () => {
const editor = createComposer(() => {
$replaceComposerRange(0, 0, {kind: 'text', text: '```\ncode'}, {leading: false, trailing: false});
$setSelection(null);
});
expect(pressEnter(editor)).toEqual(SENT);
});
it('still lets an open typeahead take Enter inside a block', () => {
expect(pressEnter(typed('```\ncode', 8), {typeaheadActive: true})).toEqual(NEWLINE);
});
it('still inserts a newline on plain Enter when submit on Enter is off', () => {
expect(pressEnter(typed('```\ncode', 8), {submitOnEnter: false})).toEqual(NEWLINE);
});
it('still sends on Ctrl+Enter inside a block when submit on Enter is off', () => {
expect(pressEnter(typed('```\ncode', 8), {submitOnEnter: false, modifiers: {ctrlKey: true}})).toEqual(SENT);
});
it('inserts a newline inside a block without flagging an empty required slash slot', () => {
const editor = typedWithRequiredSlot('```\ncode', 8);
expect(pressEnter(editor)).toEqual(NEWLINE);
expect(slotValidity(editor)).toBe('neutral');
});
it('still flags an empty required slash slot instead of sending outside a block', () => {
const editor = typedWithRequiredSlot('hello', 5);
expect(pressEnter(editor)).toEqual({handled: true, prevented: true, sent: 0});
expect(slotValidity(editor)).toBe('invalid');
});
it.each([
['a mention', false],
['a plain text mention', true],
])('reads %s as filler so a backtick in its name cannot hide a fence', (_label, plainText) => {
const editor = createComposer(() => {
$replaceComposerRange(
0,
0,
{kind: 'mention', mentionType: 'user', id: '1', display: '@`bob', wire: '<@1>'},
{trailing: false},
plainText,
);
$replaceComposerRange(5, 5, {kind: 'text', text: ' hi ```js'}, {leading: false, trailing: false});
$selectComposerRange(14, 14);
});
expect(pressEnter(editor)).toEqual(NEWLINE);
});
});
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ComposerTypeaheadActiveState} from '@app/features/lexical/composer/ComposerTypeaheadModifierGuard';
import {isOffsetInsideCodeBlock} from '@app/features/lexical/composer/codeBlockIndent';
import {$getComposerScanText, $getComposerSelectionRange} from '@app/features/lexical/composer/composerOffsets';
import {$focusFirstInvalidSlashSlot} from '@app/features/lexical/composer/slashSlots';
import {COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, type LexicalEditor} from 'lexical';
interface ComposerEnterOptions {
typeaheadActiveState: ComposerTypeaheadActiveState;
getSubmitOnEnter: () => boolean;
getOnEnter: () => (() => void) | undefined;
}
function $isSelectionInCodeBlock(): boolean {
const range = $getComposerSelectionRange();
return range != null && isOffsetInsideCodeBlock($getComposerScanText(), range.start);
}
export function registerComposerEnter(
editor: LexicalEditor,
{typeaheadActiveState, getSubmitOnEnter, getOnEnter}: ComposerEnterOptions,
): () => void {
return editor.registerCommand(
KEY_ENTER_COMMAND,
(event: KeyboardEvent | null) => {
if (typeaheadActiveState.current || event == null) {
return false;
}
const onEnter = getOnEnter();
if (getSubmitOnEnter() && onEnter != null) {
if (!event.shiftKey) {
if (!event.ctrlKey && !event.metaKey && $isSelectionInCodeBlock()) {
return false;
}
event.preventDefault();
if ($focusFirstInvalidSlashSlot()) {
return true;
}
onEnter();
return true;
}
return false;
}
if ((event.metaKey || event.ctrlKey) && onEnter != null) {
event.preventDefault();
if ($focusFirstInvalidSlashSlot()) {
return true;
}
onEnter();
return true;
}
return false;
},
COMMAND_PRIORITY_HIGH,
);
}
@@ -1,12 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {findBlockquoteMarkerEnds} from '@app/features/lexical/composer/blockquoteLines';
import {
$rewriteMultilineBlockquoteMarker,
$snapCaretOutOfBlockquoteMarker,
$splitComposerLines,
$syncComposerBlockquoteLines,
} from '@app/features/lexical/composer/ComposerBlockquote';
import {$captureSelectionOffsets, $selectComposerRange} from '@app/features/lexical/composer/composerOffsets';
import {
computeMarkdownHighlightSpans,
MarkdownHl,
type MarkdownHlFormat,
type MarkdownSpan,
markSilentMessagePrefix,
} from '@app/features/lexical/composer/markdownSpans';
import {$isComposerBlockquoteLineNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
import {
$createComposerBlockquoteMarkerNode,
$isComposerBlockquoteMarkerNode,
} from '@app/features/lexical/composer/nodes/ComposerBlockquoteMarkerNode';
import {$isComposerCommandNode} from '@app/features/lexical/composer/nodes/ComposerCommandNode';
import {$isComposerCustomEmojiNode} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode';
import {
@@ -23,7 +36,6 @@ import {
$createTextNode,
$getNodeByKey,
$getSelection,
$isLineBreakNode,
$isNodeSelection,
$isRangeSelection,
$setSelection,
@@ -41,10 +53,10 @@ const STYLE_BY_BIT: ReadonlyArray<{bit: number; style: string}> = [
style: 'background-color:var(--background-modifier-active,rgba(0,0,0,0.15));border-radius:0.1875rem',
},
{bit: MarkdownHl.heading, style: 'font-weight:700'},
{bit: MarkdownHl.blockquote, style: 'color:var(--text-secondary)'},
{bit: MarkdownHl.subtext, style: 'font-size:0.85em;color:var(--text-muted,var(--text-secondary))'},
{bit: MarkdownHl.link, style: 'color:var(--text-link)'},
{bit: MarkdownHl.codeBlock, style: 'font-size:0.75em'},
{bit: MarkdownHl.silent, style: 'color:var(--markup-mention-text);font-weight:500'},
];
const LEXICAL_TEXT_FORMATS: ReadonlyArray<{bit: number; type: TextFormatType}> = [
@@ -77,14 +89,18 @@ function applyMarkdownFormat(node: TextNode, format: MarkdownHlFormat): void {
}
}
export function registerComposerMarkdownHighlight(editor: LexicalEditor, parserFlags?: number): () => void {
export function registerComposerMarkdownHighlight(
editor: LexicalEditor,
parserFlags?: number,
silentMessagePrefix = false,
): () => void {
return editor.registerNodeTransform(RootNode, (root) => {
if (editor.isComposing()) {
return;
}
for (const child of root.getChildren()) {
if (child instanceof ParagraphNode) {
$reconcileParagraph(child, parserFlags);
$reconcileParagraph(child, parserFlags, silentMessagePrefix && child.is(root.getFirstChild()));
}
}
});
@@ -92,6 +108,7 @@ export function registerComposerMarkdownHighlight(editor: LexicalEditor, parserF
type Desired =
| {role: 'marker'; text: string}
| {role: 'quote'; text: string}
| {role: 'content'; text: string; format: MarkdownHlFormat}
| {role: 'keep'; node: LexicalNode};
@@ -99,30 +116,26 @@ type BuildableDesired = Exclude<Desired, {role: 'keep'}>;
export function $reconcileLineOf(node: TextNode, parserFlags?: number): void {
const parent = node.getParent();
if (parent == null || parent.getType() !== 'paragraph') {
const block = $isComposerBlockquoteLineNode(parent) ? parent.getParent() : parent;
if (block == null || block.getType() !== 'paragraph') {
return;
}
$reconcileParagraph(parent as ParagraphNode, parserFlags);
$reconcileParagraph(block as ParagraphNode, parserFlags);
}
function $reconcileParagraph(paragraph: ParagraphNode, parserFlags?: number): void {
const lines: Array<Array<LexicalNode>> = [];
let line: Array<LexicalNode> = [];
for (const child of paragraph.getChildren()) {
if ($isLineBreakNode(child)) {
lines.push(line);
line = [];
} else {
line.push(child);
}
}
lines.push(line);
const lineSources = lines.map((nodes) => nodes.map($nodeWireText).join(''));
function $reconcileParagraph(paragraph: ParagraphNode, parserFlags?: number, silentMessagePrefix = false): void {
const lines = $splitComposerLines(paragraph);
const lineSources = lines.map((line) => line.nodes.map($nodeWireText).join(''));
const source = lineSources.join('\n');
const spans = computeMarkdownHighlightSpans(source, parserFlags);
const markdownSpans = computeMarkdownHighlightSpans(source, parserFlags);
const quoteMarkerEnds = findBlockquoteMarkerEnds(source, markdownSpans);
if ($rewriteMultilineBlockquoteMarker(lines, lineSources, quoteMarkerEnds)) {
return;
}
const spans = silentMessagePrefix ? markSilentMessagePrefix(markdownSpans, source) : markdownSpans;
let lineStart = 0;
for (let index = 0; index < lines.length; index += 1) {
const nodes = lines[index]!;
const nodes = lines[index]!.nodes;
const lineSource = lineSources[index]!;
const lineEnd = lineStart + lineSource.length;
if (nodes.length > 0) {
@@ -133,10 +146,17 @@ function $reconcileParagraph(paragraph: ParagraphNode, parserFlags?: number): vo
end: Math.min(span.end, lineEnd) - lineStart,
}))
.filter((span) => span.end > span.start);
$reconcileLine(nodes, parserFlags, lineSource, lineSpans);
$reconcileLine(nodes, parserFlags, lineSource, lineSpans, quoteMarkerEnds[index]);
}
lineStart = lineEnd + 1;
}
$syncComposerBlockquoteLines(
paragraph,
quoteMarkerEnds.map((end) => end > 0),
);
if (quoteMarkerEnds.some((end) => end > 0)) {
$snapCaretOutOfBlockquoteMarker();
}
}
function $isEscapedAtOffset(source: string, offset: number): boolean {
@@ -154,6 +174,7 @@ function $reconcileLine(
parserFlags?: number,
precomputedSource?: string,
precomputedSpans?: Array<MarkdownSpan>,
quoteMarkerEnd = 0,
): void {
const desired: Array<Desired> = [];
const source = precomputedSource == null ? line.map($nodeWireText).join('') : precomputedSource;
@@ -203,7 +224,16 @@ function $reconcileLine(
continue;
}
const text = source.slice(start, end);
desired.push(span.role === 'marker' ? {role: 'marker', text} : {role: 'content', text, format: span.format});
const previous = desired[desired.length - 1];
if (span.role === 'marker' && span.start < quoteMarkerEnd) {
if (previous != null && previous.role === 'quote') {
previous.text += text;
} else {
desired.push({role: 'quote', text});
}
} else {
desired.push(span.role === 'marker' ? {role: 'marker', text} : {role: 'content', text, format: span.format});
}
}
} else {
desired.push({role: 'keep', node});
@@ -265,7 +295,7 @@ function mentionPresentation(format: MarkdownHlFormat): ComposerMentionPresentat
return presentation;
}
function $nodeWireText(node: LexicalNode): string {
export function $nodeWireText(node: LexicalNode): string {
if (
$isComposerMentionNode(node) ||
$isComposerCustomEmojiNode(node) ||
@@ -302,8 +332,12 @@ function $descriptorsMatch(line: Array<LexicalNode>, desired: Array<Desired>): b
if (!(node instanceof TextNode) || node.getTextContent() !== want.text) {
return false;
}
if (want.role === 'marker') {
if (!$isSyntaxMarkerNode(node)) {
if (want.role === 'quote') {
if (!$isComposerBlockquoteMarkerNode(node)) {
return false;
}
} else if (want.role === 'marker') {
if (!$isSyntaxMarkerNode(node) || $isComposerBlockquoteMarkerNode(node)) {
return false;
}
} else if ($isSyntaxMarkerNode(node) || !$contentNodeMatchesFormat(node, want.format)) {
@@ -314,6 +348,9 @@ function $descriptorsMatch(line: Array<LexicalNode>, desired: Array<Desired>): b
}
function $buildDescriptorNode(desired: BuildableDesired): TextNode {
if (desired.role === 'quote') {
return $createComposerBlockquoteMarkerNode(desired.text);
}
if (desired.role === 'marker') {
return $createSyntaxMarkerNode(desired.text);
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {$getComposerLineNodes} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
import {
$createComposerCommandNode,
$isComposerCommandNode,
@@ -32,6 +33,7 @@ import {
parseSlashSlotStateSegment,
parseSlashSlotStateSegmentId,
} from '@app/features/lexical/composer/SlashSlotPersistence';
import {isSpecialMentionKind} from '@app/features/lexical/composer/specialMentions';
import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager';
import {
$createLineBreakNode,
@@ -92,9 +94,7 @@ function isValidSegmentWire(segment: MentionSegment): boolean {
}
case 'special':
return (
(segment.id === 'everyone' || segment.id === 'here') &&
segment.actualText === `@${segment.id}` &&
segment.displayText === segment.actualText
isSpecialMentionKind(segment.id) && segment.actualText === segment.id && segment.displayText === segment.id
);
}
}
@@ -228,7 +228,7 @@ export function $projectComposer(): ComposerProjection {
wire += block.getTextContent();
continue;
}
for (const child of block.getChildren()) {
for (const child of $getComposerLineNodes(block)) {
if ($isLineBreakNode(child)) {
display += '\n';
wire += '\n';
@@ -0,0 +1,306 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {registerComposerMarkdownHighlight} from '@app/features/lexical/composer/ComposerMarkdownHighlight';
import {$hydrateComposerFromDraft, $projectComposer} from '@app/features/lexical/composer/ComposerSerialization';
import {$getComposerSelectionRange, $selectComposerRange} from '@app/features/lexical/composer/composerOffsets';
import {
computeMarkdownHighlightSpans,
DEFAULT_COMPOSER_MARKDOWN_FLAGS,
MarkdownHl,
markSilentMessagePrefix,
} from '@app/features/lexical/composer/markdownSpans';
import {
$isComposerMentionNode,
ComposerMentionNode,
ComposerMentionPresentation,
} from '@app/features/lexical/composer/nodes/ComposerMentionNode';
import {SyntaxMarkerNode} from '@app/features/lexical/composer/nodes/SyntaxMarkerNode';
import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager';
import {
$createParagraphNode,
$createTextNode,
$getRoot,
$getSelection,
$isElementNode,
$isTextNode,
createEditor,
type ElementNode,
type LexicalEditor,
type RangeSelection,
} from 'lexical';
import {describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/lexical/composer/nodes/ComposerMentionPill', () => ({ComposerMentionPill: () => null}));
vi.mock('@app/features/lexical/composer/nodes/ComposerCustomEmoji', () => ({ComposerCustomEmoji: () => null}));
vi.mock('@app/features/lexical/composer/nodes/ComposerStandardEmoji', () => ({ComposerStandardEmoji: () => null}));
const SILENT_STYLE = 'color:var(--markup-mention-text);font-weight:500';
function createComposer(silentMessagePrefix: boolean): {editor: LexicalEditor; unregister: () => void} {
const editor = createEditor({
namespace: 'test',
nodes: [ComposerMentionNode, SyntaxMarkerNode],
onError: (error) => {
throw error;
},
});
const unregister = registerComposerMarkdownHighlight(editor, DEFAULT_COMPOSER_MARKDOWN_FLAGS, silentMessagePrefix);
return {editor, unregister};
}
function hydrate(editor: LexicalEditor, display: string, segments: Array<MentionSegment> = []): void {
editor.update(() => $hydrateComposerFromDraft(display, segments), {discrete: true});
}
function edit(editor: LexicalEditor, start: number, end: number, text: string): void {
editor.update(
() => {
$selectComposerRange(start, end);
($getSelection() as RangeSelection).insertText(text);
},
{discrete: true},
);
}
function readLines(editor: LexicalEditor): Array<Array<[string, string] | string>> {
return editor.getEditorState().read(
() =>
$getRoot()
.getChildren()
.map((block) =>
$isElementNode(block)
? block
.getChildren()
.map((node) => ($isTextNode(node) ? [node.getTextContent(), node.getStyle()] : node.getType()))
: [],
),
{editor},
);
}
function readSelection(editor: LexicalEditor): {start: number; end: number} | null {
return editor.getEditorState().read(() => $getComposerSelectionRange(), {editor});
}
describe('markSilentMessagePrefix', () => {
it('marks @silent at the start of the message', () => {
const source = '@silent hello';
expect(markSilentMessagePrefix(computeMarkdownHighlightSpans(source), source)).toEqual([
{start: 0, end: 7, role: 'content', format: MarkdownHl.silent},
{start: 7, end: 13, role: 'content', format: MarkdownHl.none},
]);
});
it('marks @silent on its own', () => {
const source = '@silent';
expect(markSilentMessagePrefix(computeMarkdownHighlightSpans(source), source)).toEqual([
{start: 0, end: 7, role: 'content', format: MarkdownHl.silent},
]);
});
it('marks @silent after leading whitespace', () => {
const source = ' @silent hi';
expect(markSilentMessagePrefix(computeMarkdownHighlightSpans(source), source)).toEqual([
{start: 0, end: 2, role: 'content', format: MarkdownHl.none},
{start: 2, end: 9, role: 'content', format: MarkdownHl.silent},
{start: 9, end: 12, role: 'content', format: MarkdownHl.none},
]);
});
it('marks @silent before a line break', () => {
const source = '@silent\nhello';
expect(markSilentMessagePrefix(computeMarkdownHighlightSpans(source), source)).toEqual([
{start: 0, end: 7, role: 'content', format: MarkdownHl.silent},
{start: 7, end: 13, role: 'content', format: MarkdownHl.none},
]);
});
it('keeps markdown formats after @silent', () => {
const source = '@silent **hi**';
expect(markSilentMessagePrefix(computeMarkdownHighlightSpans(source), source)).toEqual([
{start: 0, end: 7, role: 'content', format: MarkdownHl.silent},
{start: 7, end: 8, role: 'content', format: MarkdownHl.none},
{start: 8, end: 10, role: 'marker', format: MarkdownHl.bold},
{start: 10, end: 12, role: 'content', format: MarkdownHl.bold},
{start: 12, end: 14, role: 'marker', format: MarkdownHl.bold},
]);
});
it('adds the silent bit to every span the token overlaps', () => {
expect(
markSilentMessagePrefix(
[
{start: 0, end: 3, role: 'content', format: MarkdownHl.none},
{start: 3, end: 10, role: 'content', format: MarkdownHl.bold},
],
'@silent hi',
),
).toEqual([
{start: 0, end: 3, role: 'content', format: MarkdownHl.silent},
{start: 3, end: 7, role: 'content', format: MarkdownHl.bold | MarkdownHl.silent},
{start: 7, end: 10, role: 'content', format: MarkdownHl.bold},
]);
});
it.each(['@silently hello', '@Silent hello', 'hello @silent', ''])('returns the spans unchanged for %j', (source) => {
const spans = computeMarkdownHighlightSpans(source);
expect(markSilentMessagePrefix(spans, source)).toBe(spans);
});
});
describe('registerComposerMarkdownHighlight with silentMessagePrefix', () => {
it('styles @silent at the start of the message without changing what is sent', () => {
const {editor} = createComposer(true);
hydrate(editor, '@silent hello');
expect(readLines(editor)).toEqual([
[
['@silent', SILENT_STYLE],
[' hello', ''],
],
]);
expect(editor.getEditorState().read(() => $projectComposer(), {editor})).toEqual({
display: '@silent hello',
segments: [],
wire: '@silent hello',
});
});
it('styles @silent on its own', () => {
const {editor} = createComposer(true);
hydrate(editor, '@silent');
expect(readLines(editor)).toEqual([[['@silent', SILENT_STYLE]]]);
});
it('styles @silent before a line break', () => {
const {editor} = createComposer(true);
hydrate(editor, '@silent\nhello');
expect(readLines(editor)).toEqual([[['@silent', SILENT_STYLE], 'linebreak', ['hello', '']]]);
});
it('styles @silent after leading whitespace', () => {
const {editor} = createComposer(true);
hydrate(editor, ' @silent hello');
expect(readLines(editor)).toEqual([
[
[' ', ''],
['@silent', SILENT_STYLE],
[' hello', ''],
],
]);
});
it('styles @silent after a leading line break', () => {
const {editor} = createComposer(true);
hydrate(editor, '\n@silent hello');
expect(readLines(editor)).toEqual([['linebreak', ['@silent', SILENT_STYLE], [' hello', '']]]);
});
it('leaves @silent unstyled when the prefix is off', () => {
const {editor} = createComposer(false);
hydrate(editor, '@silent hello');
expect(readLines(editor)).toEqual([[['@silent hello', '']]]);
});
it('leaves @silent unstyled when it is not at the start', () => {
const {editor} = createComposer(true);
hydrate(editor, 'hello @silent');
expect(readLines(editor)).toEqual([[['hello @silent', '']]]);
});
it('styles only the first paragraph', () => {
const {editor} = createComposer(true);
editor.update(
() => {
$getRoot()
.clear()
.append(
$createParagraphNode().append($createTextNode('@silent hi')),
$createParagraphNode().append($createTextNode('@silent hi')),
);
},
{discrete: true},
);
expect(readLines(editor)).toEqual([
[
['@silent', SILENT_STYLE],
[' hi', ''],
],
[['@silent hi', '']],
]);
});
it('restyles the token as it is typed, broken and repaired', () => {
const {editor} = createComposer(true);
hydrate(editor, '@silen');
edit(editor, 6, 6, 't');
expect(readLines(editor)).toEqual([[['@silent', SILENT_STYLE]]]);
expect(readSelection(editor)).toEqual({start: 7, end: 7});
edit(editor, 7, 7, 'l');
expect(readLines(editor)).toEqual([[['@silentl', '']]]);
expect(readSelection(editor)).toEqual({start: 8, end: 8});
edit(editor, 7, 8, '');
expect(readLines(editor)).toEqual([[['@silent', SILENT_STYLE]]]);
expect(readSelection(editor)).toEqual({start: 7, end: 7});
});
it('styles @silent typed in front of existing text', () => {
const {editor} = createComposer(true);
hydrate(editor, 'hello');
edit(editor, 0, 0, '@silent ');
expect(readLines(editor)).toEqual([
[
['@silent', SILENT_STYLE],
[' hello', ''],
],
]);
expect(readSelection(editor)).toEqual({start: 8, end: 8});
});
it('keeps an autocompleted @everyone mention after @silent', () => {
const segment: MentionSegment = {
type: 'special',
id: '@everyone',
displayText: '@everyone',
actualText: '@everyone',
start: 8,
end: 17,
};
const {editor} = createComposer(true);
hydrate(editor, '@silent @everyone', [segment]);
expect(readLines(editor)).toEqual([[['@silent', SILENT_STYLE], [' ', ''], 'composer-mention']]);
expect(editor.getEditorState().read(() => $projectComposer(), {editor})).toEqual({
display: '@silent @everyone',
segments: [segment],
wire: '@silent @everyone',
});
});
it('keeps a user mention after @silent as a plain pill', () => {
const {editor} = createComposer(true);
hydrate(editor, '@silent @name', [
{type: 'user', id: '123', displayText: '@name', actualText: '<@123>', start: 8, end: 13},
]);
expect(readLines(editor)).toEqual([[['@silent', SILENT_STYLE], [' ', ''], 'composer-mention']]);
const mention = editor.getEditorState().read(() => {
const pill = $getRoot().getFirstChildOrThrow<ElementNode>().getLastChild();
return $isComposerMentionNode(pill) ? {literal: pill.isLiteral(), presentation: pill.getPresentation()} : null;
});
expect(mention).toEqual({literal: false, presentation: ComposerMentionPresentation.none});
});
it('removes the style when the prefix is switched off', () => {
const {editor, unregister} = createComposer(true);
hydrate(editor, '@silent hello');
unregister();
registerComposerMarkdownHighlight(editor, DEFAULT_COMPOSER_MARKDOWN_FLAGS, false);
editor.update(
() => {
for (const node of $getRoot().getAllTextNodes()) {
node.markDirty();
}
},
{discrete: true},
);
expect(readLines(editor)).toEqual([[['@silent hello', '']]]);
});
});
@@ -0,0 +1,672 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
$getComposerClipboardSelection,
$insertComposerClipboardSlice,
} from '@app/features/lexical/composer/ComposerClipboard';
import {resetComposerHistory} from '@app/features/lexical/composer/ComposerHistory';
import {registerComposerMarkdownHighlight} from '@app/features/lexical/composer/ComposerMarkdownHighlight';
import {registerComposerPlainText} from '@app/features/lexical/composer/ComposerPlainText';
import {
$hydrateComposerFromDraft,
$projectComposer,
isValidComposerSegment,
} from '@app/features/lexical/composer/ComposerSerialization';
import {
COMPOSER_RESCAN_TAG,
registerComposerSpecialMention,
} from '@app/features/lexical/composer/ComposerSpecialMention';
import {
$captureSelectionOffsets,
$replaceComposerRange,
$selectComposerOffset,
$selectComposerRange,
} from '@app/features/lexical/composer/composerOffsets';
import {
$createComposerCommandNode,
ComposerCommandNode,
} from '@app/features/lexical/composer/nodes/ComposerCommandNode';
import {ComposerCustomEmojiNode} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode';
import {$isComposerMentionNode, ComposerMentionNode} from '@app/features/lexical/composer/nodes/ComposerMentionNode';
import {ComposerPlainSegmentNode} from '@app/features/lexical/composer/nodes/ComposerPlainSegmentNode';
import {$createSlashSlotNode, SlashSlotNode} from '@app/features/lexical/composer/nodes/SlashSlotNode';
import {SlashSlotPlaceholderNode} from '@app/features/lexical/composer/nodes/SlashSlotPlaceholderNode';
import {SyntaxMarkerNode} from '@app/features/lexical/composer/nodes/SyntaxMarkerNode';
import {createSpecialMentionPayload, type SpecialMentionKind} from '@app/features/lexical/composer/specialMentions';
import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager';
import {createEmptyHistoryState, registerHistory} from '@lexical/history';
import {
$addUpdateTag,
$createParagraphNode,
$createRangeSelection,
$createTextNode,
$getRoot,
$getSelection,
$isRangeSelection,
$setCompositionKey,
$setSelection,
createEditor,
type ElementNode,
HISTORY_MERGE_TAG,
type LexicalEditor,
type LexicalNode,
PASTE_TAG,
REDO_COMMAND,
UNDO_COMMAND,
} from 'lexical';
import {describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/lexical/composer/nodes/ComposerMentionPill', () => ({ComposerMentionPill: () => null}));
vi.mock('@app/features/lexical/composer/nodes/ComposerCustomEmoji', () => ({ComposerCustomEmoji: () => null}));
vi.mock('@app/features/lexical/composer/nodes/ComposerStandardEmoji', () => ({ComposerStandardEmoji: () => null}));
const ALICE: MentionSegment = {type: 'user', id: '1', displayText: '@Alice', actualText: '<@1>', start: 0, end: 6};
function specialSegment(kind: SpecialMentionKind, start: number): MentionSegment {
return {type: 'special', id: kind, displayText: kind, actualText: kind, start, end: start + kind.length};
}
function update(editor: LexicalEditor, fn: () => void, tag?: string): void {
editor.update(fn, {discrete: true, tag});
}
function createComposer(
display: string,
segments: ReadonlyArray<MentionSegment> = [],
plainText = false,
): LexicalEditor {
const editor = createEditor({
namespace: 'test',
nodes: [
ComposerMentionNode,
ComposerCustomEmojiNode,
ComposerPlainSegmentNode,
ComposerCommandNode,
SlashSlotNode,
SlashSlotPlaceholderNode,
SyntaxMarkerNode,
],
onError: (error) => {
throw error;
},
});
update(editor, () => {
$hydrateComposerFromDraft(display, segments, plainText);
$selectComposerOffset(display.length);
});
return editor;
}
function register(editor: LexicalEditor, allowed: boolean, plainText = false): () => void {
const unregister = registerComposerSpecialMention(editor, allowed, plainText);
editor.read(() => undefined);
return unregister;
}
function type(editor: LexicalEditor, text: string): void {
for (const character of text) {
update(editor, () => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertText(character);
}
});
}
}
function insertLineBreak(editor: LexicalEditor): void {
update(editor, () => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertLineBreak();
}
});
}
function paste(editor: LexicalEditor, insert: () => void): void {
update(editor, () => {
insert();
$addUpdateTag(PASTE_TAG);
});
}
function insertRawText(text: string): void {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertRawText(text);
}
}
function describeNode(node: LexicalNode): string {
return `${node.getType()}:${node.getTextContent()}`;
}
function $paragraph(): ElementNode {
return $getRoot().getFirstChildOrThrow<ElementNode>();
}
function children(editor: LexicalEditor): Array<string> {
return editor.read(() => $paragraph().getChildren().map(describeNode));
}
function snapshot(editor: LexicalEditor) {
return editor.read(() => ({
children: $paragraph().getChildren().map(describeNode),
projection: $projectComposer(),
selection: $captureSelectionOffsets(),
}));
}
function childrenJSON(editor: LexicalEditor) {
return editor.read(() =>
$paragraph()
.getChildren()
.map((node) => node.exportJSON()),
);
}
describe('typed special mentions', () => {
it('converts @everyone once a space follows it', () => {
const editor = createComposer('');
register(editor, true);
type(editor, 'hi @everyone ');
expect(snapshot(editor)).toEqual({
children: ['text:hi ', 'composer-mention:@everyone', 'text: '],
projection: {display: 'hi @everyone ', segments: [specialSegment('@everyone', 3)], wire: 'hi @everyone '},
selection: {anchor: 13, focus: 13},
});
});
it('produces exactly the nodes and projection the autocomplete inserts', () => {
const typed = createComposer('');
register(typed, true);
type(typed, 'hi @everyone ');
const picked = createComposer('hi @every');
register(picked, true);
update(picked, () => $replaceComposerRange(3, 9, createSpecialMentionPayload('@everyone')));
expect(childrenJSON(typed)).toEqual(childrenJSON(picked));
expect(snapshot(typed)).toEqual(snapshot(picked));
});
it('keeps a token at the caret as text, so @herenow never converts', () => {
const editor = createComposer('');
register(editor, true);
type(editor, 'hi @here');
expect(snapshot(editor)).toEqual({
children: ['text:hi @here'],
projection: {display: 'hi @here', segments: [], wire: 'hi @here'},
selection: {anchor: 8, focus: 8},
});
type(editor, 'now ');
expect(snapshot(editor)).toEqual({
children: ['text:hi @herenow '],
projection: {display: 'hi @herenow ', segments: [], wire: 'hi @herenow '},
selection: {anchor: 12, focus: 12},
});
});
it('converts a finished token when a line break follows it', () => {
const editor = createComposer('');
register(editor, true);
type(editor, 'hi @everyone');
insertLineBreak(editor);
expect(snapshot(editor)).toEqual({
children: ['text:hi ', 'composer-mention:@everyone', 'linebreak:\n'],
projection: {display: 'hi @everyone\n', segments: [specialSegment('@everyone', 3)], wire: 'hi @everyone\n'},
selection: {anchor: 13, focus: 13},
});
});
it('does not convert when only the caret moves away, and converts on the next edit', () => {
const editor = createComposer('');
register(editor, true);
type(editor, '@here');
update(editor, () => $selectComposerOffset(0));
expect(children(editor)).toEqual(['text:@here']);
type(editor, ' ');
expect(snapshot(editor)).toEqual({
children: ['text: ', 'composer-mention:@here'],
projection: {display: ' @here', segments: [specialSegment('@here', 1)], wire: ' @here'},
selection: {anchor: 1, focus: 1},
});
});
it('converts a token completed with the caret inside it and moves the caret past the mention', () => {
const editor = createComposer('@hre');
register(editor, true);
update(editor, () => $selectComposerOffset(2));
type(editor, 'e');
expect(snapshot(editor)).toEqual({
children: ['composer-mention:@here'],
projection: {display: '@here', segments: [specialSegment('@here', 0)], wire: '@here'},
selection: {anchor: 5, focus: 5},
});
});
it('treats a preceding or following mention or emoji as a boundary', () => {
const emoji: MentionSegment = {
type: 'emoji',
id: '2',
displayText: ':wave:',
actualText: '<:wave:2>',
start: 0,
end: 6,
};
const alice = {...ALICE, start: 6, end: 12};
const editor = createComposer(':wave:@Alice', [emoji, alice]);
register(editor, true);
type(editor, '@here ');
expect(snapshot(editor).projection).toEqual({
display: ':wave:@Alice@here ',
segments: [emoji, alice, specialSegment('@here', 12)],
wire: '<:wave:2><@1>@here ',
});
const before = createComposer('@here@Alice', [{...ALICE, start: 5, end: 11}]);
register(before, true);
expect(children(before)).toEqual(['composer-mention:@here', 'composer-mention:@Alice']);
});
it('treats a preceding special mention pill or slash command as a boundary', () => {
const editor = createComposer('@here', [specialSegment('@here', 0)]);
register(editor, true);
type(editor, '@everyone ');
expect(snapshot(editor).projection).toEqual({
display: '@here@everyone ',
segments: [specialSegment('@here', 0), specialSegment('@everyone', 5)],
wire: '@here@everyone ',
});
const command = createComposer('');
update(command, () => {
$paragraph().append($createComposerCommandNode('/giphy'), $createTextNode('@here '));
});
register(command, true);
expect(children(command)).toEqual(['composer-command:/giphy', 'composer-mention:@here', 'text: ']);
});
it('converts a token in a second paragraph', () => {
const editor = createComposer('a');
register(editor, true);
update(editor, () => {
const paragraph = $createParagraphNode();
paragraph.append($createTextNode('@everyone x'));
$getRoot().append(paragraph);
});
expect(snapshot(editor).projection).toEqual({
display: 'a\n@everyone x',
segments: [specialSegment('@everyone', 2)],
wire: 'a\n@everyone x',
});
});
it('keeps the caret after a later pill when a conversion splits the text before it', () => {
const editor = createComposer('@here @Alice', [{...ALICE, start: 6, end: 12}]);
register(editor, true);
expect(snapshot(editor)).toEqual({
children: ['composer-mention:@here', 'text: ', 'composer-mention:@Alice'],
projection: {
display: '@here @Alice',
segments: [specialSegment('@here', 0), {...ALICE, start: 6, end: 12}],
wire: '@here <@1>',
},
selection: {anchor: 12, focus: 12},
});
});
it('keeps an element caret between a pill and the text a conversion splits', () => {
const editor = createComposer('@Alice@here x', [ALICE]);
update(editor, () => {
const selection = $createRangeSelection();
selection.anchor.set($paragraph().getKey(), 1, 'element');
selection.focus.set($paragraph().getKey(), 1, 'element');
$setSelection(selection);
});
register(editor, true);
expect(snapshot(editor)).toEqual({
children: ['composer-mention:@Alice', 'composer-mention:@here', 'text: x'],
projection: {
display: '@Alice@here x',
segments: [ALICE, specialSegment('@here', 6)],
wire: '<@1>@here x',
},
selection: {anchor: 6, focus: 6},
});
});
it('gives the conversion one undo entry that restores the text before the keystroke, and redo restores it', () => {
const editor = createComposer('');
const history = createEmptyHistoryState();
registerHistory(editor, history, 1000);
register(editor, true);
type(editor, 'hi @everyone');
const depth = history.undoStack.length;
type(editor, ' ');
expect(history.undoStack).toHaveLength(depth + 1);
editor.dispatchCommand(UNDO_COMMAND, undefined);
expect(snapshot(editor)).toEqual({
children: ['text:hi @everyone'],
projection: {display: 'hi @everyone', segments: [], wire: 'hi @everyone'},
selection: {anchor: 12, focus: 12},
});
editor.dispatchCommand(REDO_COMMAND, undefined);
expect(snapshot(editor)).toEqual({
children: ['text:hi ', 'composer-mention:@everyone', 'text: '],
projection: {display: 'hi @everyone ', segments: [specialSegment('@everyone', 3)], wire: 'hi @everyone '},
selection: {anchor: 13, focus: 13},
});
});
it('leaves a token at the caret alone when the mention menu opens or closes', () => {
const editor = createComposer('');
const history = createEmptyHistoryState();
registerHistory(editor, history, 1000);
register(editor, true);
type(editor, 'hi @here');
const depth = history.undoStack.length;
update(editor, () => $getRoot().markDirty(), HISTORY_MERGE_TAG);
expect(children(editor)).toEqual(['text:hi @here']);
expect(history.undoStack).toHaveLength(depth);
});
it('converts a token at the caret when the transform is registered, without adding an undo entry', () => {
const editor = createComposer('hi @here');
const history = createEmptyHistoryState();
registerHistory(editor, history, 1000);
const depth = history.undoStack.length;
register(editor, true);
expect(children(editor)).toEqual(['text:hi ', 'composer-mention:@here']);
expect(history.undoStack).toHaveLength(depth);
});
it('converts a hydrated draft whose token ends at the caret without resetting the history', () => {
const editor = createComposer('');
register(editor, true);
update(
editor,
() => {
$hydrateComposerFromDraft('hi @everyone', []);
$selectComposerOffset(12);
},
COMPOSER_RESCAN_TAG,
);
expect(snapshot(editor)).toEqual({
children: ['text:hi ', 'composer-mention:@everyone'],
projection: {display: 'hi @everyone', segments: [specialSegment('@everyone', 3)], wire: 'hi @everyone'},
selection: {anchor: 12, focus: 12},
});
});
it('converts a token at the caret after a render mode switch', () => {
const editor = createComposer('');
const unregister = register(editor, true);
type(editor, '@here');
unregister();
update(
editor,
() => {
const selection = $captureSelectionOffsets()!;
const projection = $projectComposer();
$hydrateComposerFromDraft(projection.display, projection.segments, true);
$selectComposerRange(selection.anchor, selection.focus);
},
'composer-render-mode',
);
resetComposerHistory(editor);
registerComposerPlainText(editor);
register(editor, true, true);
expect(snapshot(editor)).toEqual({
children: ['composer-plain-segment:@here'],
projection: {display: '@here', segments: [specialSegment('@here', 0)], wire: '@here'},
selection: {anchor: 5, focus: 5},
});
});
it('converts to the plain segment the autocomplete inserts in plain text mode', () => {
const typed = createComposer('', [], true);
registerComposerPlainText(typed);
register(typed, true, true);
type(typed, 'hi @everyone ');
const picked = createComposer('hi @every', [], true);
registerComposerPlainText(picked);
register(picked, true, true);
update(picked, () => $replaceComposerRange(3, 9, createSpecialMentionPayload('@everyone'), undefined, true));
expect(childrenJSON(typed)).toEqual(childrenJSON(picked));
expect(snapshot(typed)).toEqual({
children: ['text:hi ', 'composer-plain-segment:@everyone', 'text: '],
projection: {display: 'hi @everyone ', segments: [specialSegment('@everyone', 3)], wire: 'hi @everyone '},
selection: {anchor: 13, focus: 13},
});
});
it('converts a pasted token at the caret and several pasted tokens at once', () => {
const tail = createComposer('ping ');
register(tail, true);
paste(tail, () => insertRawText('@here'));
expect(snapshot(tail)).toEqual({
children: ['text:ping ', 'composer-mention:@here'],
projection: {display: 'ping @here', segments: [specialSegment('@here', 5)], wire: 'ping @here'},
selection: {anchor: 10, focus: 10},
});
const several = createComposer('');
register(several, true);
paste(several, () => insertRawText('@everyone and @here'));
expect(snapshot(several)).toEqual({
children: ['composer-mention:@everyone', 'text: and ', 'composer-mention:@here'],
projection: {
display: '@everyone and @here',
segments: [specialSegment('@everyone', 0), specialSegment('@here', 14)],
wire: '@everyone and @here',
},
selection: {anchor: 19, focus: 19},
});
});
it('converts typed tokens beside wire mentions in a channel paste slice', () => {
const editor = createComposer('hi ');
register(editor, true);
paste(editor, () => $insertComposerClipboardSlice({display: '@Alice @everyone', segments: [ALICE]}, false));
expect(snapshot(editor)).toEqual({
children: ['text:hi ', 'composer-mention:@Alice', 'text: ', 'composer-mention:@everyone'],
projection: {
display: 'hi @Alice @everyone',
segments: [{...ALICE, start: 3, end: 9}, specialSegment('@everyone', 10)],
wire: 'hi <@1> @everyone',
},
selection: {anchor: 19, focus: 19},
});
});
it('gives a channel paste its own undo entry once it carries the paste tag', () => {
const editor = createComposer('');
const history = createEmptyHistoryState();
registerHistory(editor, history, 1000);
register(editor, true);
type(editor, 'a');
paste(editor, () => $insertComposerClipboardSlice({display: 'b', segments: []}, false));
type(editor, 'c');
editor.dispatchCommand(UNDO_COMMAND, undefined);
expect(editor.read(() => $projectComposer().display)).toBe('ab');
editor.dispatchCommand(UNDO_COMMAND, undefined);
expect(editor.read(() => $projectComposer().display)).toBe('a');
});
it('waits for IME composition to end before converting', () => {
const editor = createComposer('hi ');
register(editor, true);
update(editor, () => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertText('@everyone ');
$setCompositionKey(selection.anchor.key);
}
});
expect(children(editor)).toEqual(['text:hi @everyone ']);
update(editor, () => $setCompositionKey(null));
expect(children(editor)).toEqual(['text:hi ', 'composer-mention:@everyone', 'text: ']);
});
it('leaves code formatted text and slash command slot text alone', () => {
const editor = createComposer('');
register(editor, true);
update(editor, () => {
const code = $createTextNode('@everyone ').toggleFormat('code');
const slot = $createSlashSlotNode('message', 'string', true).append($createTextNode('@here '));
$paragraph().append(code, slot);
code.select(10, 10);
});
expect(editor.read(() => $paragraph().getChildren().map(describeNode))).toEqual([
'text:@everyone ',
'slash-slot:@here ',
]);
expect(editor.read(() => $paragraph().getLastChildOrThrow<ElementNode>().getChildren().map(describeNode))).toEqual([
'text:@here ',
]);
});
it('restores a draft special mention and projects it back unchanged', () => {
const segments = [specialSegment('@everyone', 0)];
const editor = createComposer('@everyone hi', segments);
expect(snapshot(editor)).toEqual({
children: ['composer-mention:@everyone', 'text: hi'],
projection: {display: '@everyone hi', segments, wire: '@everyone hi'},
selection: {anchor: 12, focus: 12},
});
});
it('includes the special segment in the clipboard selection', () => {
const editor = createComposer('');
register(editor, true);
type(editor, 'hi @everyone ');
update(editor, () => $selectComposerRange(0, 13));
expect(editor.read(() => $getComposerClipboardSelection())).toEqual({
display: 'hi @everyone ',
segments: [specialSegment('@everyone', 3)],
textPlain: 'hi @everyone ',
});
});
});
describe('typed special mentions with the markdown highlight', () => {
function createHighlightedComposer(): LexicalEditor {
const editor = createComposer('');
registerComposerMarkdownHighlight(editor);
register(editor, true);
return editor;
}
it.each([
['an escaped token', '\\@everyone '],
['closed inline code', '`@everyone` '],
['open inline code', '`@everyone '],
['a url', 'https://fluxer.app/@everyone '],
])('leaves %s as text', (_name, text) => {
const editor = createHighlightedComposer();
type(editor, text);
expect(snapshot(editor).projection).toEqual({display: text, segments: [], wire: text});
});
it('leaves a token inside a fenced code block as text and converts one after it', () => {
const editor = createHighlightedComposer();
type(editor, '```');
insertLineBreak(editor);
type(editor, '@everyone ');
insertLineBreak(editor);
type(editor, '```');
insertLineBreak(editor);
type(editor, '@here ');
expect(snapshot(editor).projection).toEqual({
display: '```\n@everyone \n```\n@here ',
segments: [specialSegment('@here', 19)],
wire: '```\n@everyone \n```\n@here ',
});
});
it('converts a bold token into a pill that is not literal', () => {
const editor = createHighlightedComposer();
type(editor, '**@everyone** ');
expect(snapshot(editor).projection).toEqual({
display: '**@everyone** ',
segments: [specialSegment('@everyone', 2)],
wire: '**@everyone** ',
});
expect(
editor.read(() =>
$paragraph()
.getChildren()
.filter($isComposerMentionNode)
.map((node) => node.isLiteral()),
),
).toEqual([false]);
});
});
describe('special mentions where they are not allowed', () => {
it('turns a restored special mention into text', () => {
const editor = createComposer('@everyone hi', [specialSegment('@everyone', 0)]);
register(editor, false);
expect(snapshot(editor)).toEqual({
children: ['text:@everyone hi'],
projection: {display: '@everyone hi', segments: [], wire: '@everyone hi'},
selection: {anchor: 12, focus: 12},
});
});
it('turns a pasted special mention into text', () => {
const editor = createComposer('hi ');
register(editor, false);
paste(editor, () =>
$insertComposerClipboardSlice({display: '@here', segments: [specialSegment('@here', 0)]}, false),
);
expect(snapshot(editor)).toEqual({
children: ['text:hi @here'],
projection: {display: 'hi @here', segments: [], wire: 'hi @here'},
selection: {anchor: 8, focus: 8},
});
});
it('turns a restored special mention into text in plain text mode', () => {
const editor = createComposer('@everyone hi', [specialSegment('@everyone', 0)], true);
registerComposerPlainText(editor);
register(editor, false, true);
expect(snapshot(editor)).toEqual({
children: ['text:@everyone hi'],
projection: {display: '@everyone hi', segments: [], wire: '@everyone hi'},
selection: {anchor: 12, focus: 12},
});
});
it('turns a pasted special mention into text in plain text mode', () => {
const editor = createComposer('hi ', [], true);
registerComposerPlainText(editor);
register(editor, false, true);
paste(editor, () =>
$insertComposerClipboardSlice({display: '@here', segments: [specialSegment('@here', 0)]}, true),
);
expect(snapshot(editor)).toEqual({
children: ['text:hi @here'],
projection: {display: 'hi @here', segments: [], wire: 'hi @here'},
selection: {anchor: 8, focus: 8},
});
});
it('leaves typed tokens as text, then converts them once special mentions become allowed', () => {
const editor = createComposer('');
const unregister = register(editor, false);
type(editor, '@here ');
expect(children(editor)).toEqual(['text:@here ']);
unregister();
register(editor, true);
expect(children(editor)).toEqual(['composer-mention:@here', 'text: ']);
});
});
describe('isValidComposerSegment', () => {
it('accepts the special mention ids the autocomplete writes', () => {
expect(isValidComposerSegment('@everyone hi', specialSegment('@everyone', 0))).toBe(true);
expect(isValidComposerSegment('hi @here', specialSegment('@here', 3))).toBe(true);
});
it('rejects the bare id form and a mismatched id', () => {
expect(isValidComposerSegment('@everyone hi', {...specialSegment('@everyone', 0), id: 'everyone'})).toBe(false);
expect(isValidComposerSegment('@everyone hi', {...specialSegment('@everyone', 0), id: '@here'})).toBe(false);
});
});
@@ -0,0 +1,142 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {$nodeWireText} from '@app/features/lexical/composer/ComposerMarkdownHighlight';
import {
$captureSelectionOffsets,
$createComposerInsertNode,
$selectComposerRange,
} from '@app/features/lexical/composer/composerOffsets';
import {$getComposerLineNodes} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
import {ComposerMentionNode} from '@app/features/lexical/composer/nodes/ComposerMentionNode';
import {ComposerPlainSegmentNode} from '@app/features/lexical/composer/nodes/ComposerPlainSegmentNode';
import {$isSyntaxMarkerNode} from '@app/features/lexical/composer/nodes/SyntaxMarkerNode';
import {
createSpecialMentionPayload,
findTypedSpecialMention,
isSpecialMentionKind,
SPECIAL_MENTION_PATTERN,
} from '@app/features/lexical/composer/specialMentions';
import {mergeRegister} from '@lexical/utils';
import {
$addUpdateTag,
$createTextNode,
$getRoot,
$hasUpdateTag,
$isElementNode,
$isLineBreakNode,
$isTextNode,
HISTORY_MERGE_TAG,
HISTORY_PUSH_TAG,
type LexicalEditor,
type LexicalNode,
PASTE_TAG,
RootNode,
type TextNode,
} from 'lexical';
export const COMPOSER_RESCAN_TAG = 'composer-rescan';
interface SpecialMentionCandidate {
node: TextNode;
sourceStart: number;
displayStart: number;
}
export function registerComposerSpecialMention(
editor: LexicalEditor,
allowed: boolean,
plainText: boolean,
): () => void {
if (!allowed) {
return mergeRegister(
editor.registerNodeTransform(ComposerMentionNode, (node) => {
if (node.getSegmentType() === 'special') {
node.replace($createTextNode(node.getTextContent()));
}
}),
editor.registerNodeTransform(ComposerPlainSegmentNode, (node) => {
if (node.getSegmentType() === 'special' && isSpecialMentionKind(node.getSegmentId())) {
node.replace($createTextNode(node.getTextContent()));
}
}),
);
}
const unregister = editor.registerNodeTransform(RootNode, (root) => {
if (!editor.isComposing()) {
$convertSpecialMention(root, plainText);
}
});
editor.update(
() => {
$getRoot().markDirty();
},
{discrete: true, tag: [COMPOSER_RESCAN_TAG, HISTORY_MERGE_TAG]},
);
return unregister;
}
function $sourceText(node: LexicalNode): string {
const wire = $nodeWireText(node);
if ($isLineBreakNode(node) || $isSyntaxMarkerNode(node) || ($isTextNode(node) && node.isSimpleText())) {
return wire;
}
return ' '.repeat(wire.length);
}
function $convertSpecialMention(root: RootNode, plainText: boolean): void {
let source = '';
let displayLength = 0;
const candidates: Array<SpecialMentionCandidate> = [];
const blocks = root.getChildren();
for (let index = 0; index < blocks.length; index += 1) {
const block = blocks[index]!;
if (index > 0) {
source += '\n';
displayLength += 1;
}
for (const child of $isElementNode(block) ? $getComposerLineNodes(block) : [block]) {
if (
$isTextNode(child) &&
child.isSimpleText() &&
!child.hasFormat('code') &&
SPECIAL_MENTION_PATTERN.test(child.getTextContent())
) {
candidates.push({node: child, sourceStart: source.length, displayStart: displayLength});
}
source += $sourceText(child);
displayLength += child.getTextContentSize();
}
}
if (candidates.length === 0) {
return;
}
const selection = $captureSelectionOffsets();
const caret =
selection == null ||
selection.anchor !== selection.focus ||
$hasUpdateTag(PASTE_TAG) ||
$hasUpdateTag(COMPOSER_RESCAN_TAG)
? null
: selection.anchor;
for (const {node, sourceStart, displayStart} of candidates) {
const match = findTypedSpecialMention(
source,
sourceStart,
sourceStart + node.getTextContentSize(),
caret == null ? null : caret - displayStart + sourceStart,
);
if (match != null) {
const parts = node.splitText(match.start - sourceStart, match.end - sourceStart);
parts[match.start > sourceStart ? 1 : 0]!.replace(
$createComposerInsertNode(createSpecialMentionPayload(match.kind), plainText),
);
if (selection != null) {
$selectComposerRange(selection.anchor, selection.focus);
}
if (!$hasUpdateTag(HISTORY_MERGE_TAG)) {
$addUpdateTag(HISTORY_PUSH_TAG);
}
return;
}
}
}
@@ -1,12 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AutocompleteOption, AutocompleteType} from '@app/features/channel/components/AutocompleteTypes';
import {registerComposerBlockquote} from '@app/features/lexical/composer/ComposerBlockquote';
import {registerComposerClipboardCommands} from '@app/features/lexical/composer/ComposerClipboard';
import {registerComposerCodeIndent} from '@app/features/lexical/composer/ComposerCodeIndent';
import {
type ComposerEmojiResolver,
registerComposerEmojiShortcode,
} from '@app/features/lexical/composer/ComposerEmojiShortcode';
import {registerComposerEnter} from '@app/features/lexical/composer/ComposerEnter';
import type {ComposerHandle, ComposerSelectionRange} from '@app/features/lexical/composer/ComposerHandle';
import {resetComposerHistory} from '@app/features/lexical/composer/ComposerHistory';
import {registerComposerIMECommandGuard} from '@app/features/lexical/composer/ComposerIME';
@@ -16,6 +18,10 @@ import {ComposerMentionContext} from '@app/features/lexical/composer/ComposerMen
import {registerComposerPlainText} from '@app/features/lexical/composer/ComposerPlainText';
import {$hydrateComposerFromDraft, $projectComposer} from '@app/features/lexical/composer/ComposerSerialization';
import {registerComposerSoftWrapDeletion} from '@app/features/lexical/composer/ComposerSoftWrapDeletion';
import {
COMPOSER_RESCAN_TAG,
registerComposerSpecialMention,
} from '@app/features/lexical/composer/ComposerSpecialMention';
import {
type ComposerTypeaheadActiveState,
registerComposerTypeaheadModifierGuard,
@@ -35,6 +41,8 @@ import {
} 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 {ComposerBlockquoteLineNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
import {ComposerBlockquoteMarkerNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteMarkerNode';
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';
@@ -51,7 +59,6 @@ import {
$applyOptionalChoice,
$applySlotChoice,
$applySlotPayload,
$focusFirstInvalidSlashSlot,
$getActiveOptionalContext,
$getActiveSlotAutocompleteContext,
$getActiveSlotChoiceContext,
@@ -93,7 +100,6 @@ import {
FOCUS_COMMAND,
HISTORY_MERGE_TAG,
KEY_ARROW_UP_COMMAND,
KEY_ENTER_COMMAND,
} from 'lexical';
import {observer} from 'mobx-react-lite';
import type React from 'react';
@@ -102,6 +108,8 @@ import {useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, u
const THEME: InitialConfigType['theme'] = {
paragraph: styles.paragraph,
syntaxMarker: styles.marker,
composerBlockquoteLine: styles.blockquoteLine,
composerBlockquoteMarker: styles.blockquoteMarker,
composerMention: clsx(styles.mentionHost, markupStyles.inlineFormat),
composerCustomEmoji: styles.emojiHost,
composerCommand: styles.command,
@@ -125,7 +133,9 @@ export interface LexicalComposerInputProps {
slotResolvers?: SlashSlotResolvers;
markdown?: boolean;
markdownParserFlags?: number;
silentMessagePrefix?: boolean;
emojiShortcodeResolver?: ComposerEmojiResolver;
specialMentionsAllowed: boolean;
channelId?: string;
guildId?: string;
selectionToolbar?: boolean;
@@ -204,6 +214,8 @@ export const LexicalComposerInput = observer((props: LexicalComposerInputProps)
SlashSeparatorNode,
SlashOptionalHintNode,
SyntaxMarkerNode,
ComposerBlockquoteLineNode,
ComposerBlockquoteMarkerNode,
],
theme: THEME,
};
@@ -230,7 +242,9 @@ const ComposerInner = ({
slotResolvers,
markdown = true,
markdownParserFlags,
silentMessagePrefix = false,
emojiShortcodeResolver,
specialMentionsAllowed,
selectionToolbar = true,
submitOnEnter = true,
focusRingTarget,
@@ -479,7 +493,7 @@ const ComposerInner = ({
$hydrateComposerFromDraft(display, segments, plainTextRef.current);
$selectComposerOffset(display.length);
},
{discrete: true},
{discrete: true, tag: COMPOSER_RESCAN_TAG},
);
resetComposerHistory(editor);
},
@@ -509,7 +523,8 @@ const ComposerInner = ({
cleanups.push(registerSlashSlotPlugin(editor, () => slotResolversRef.current, typeaheadActiveState));
cleanups.push(registerSlashSlotFocus(editor, () => onSlashCommandStateChangeRef.current));
if (markdown) {
cleanups.push(registerComposerMarkdownHighlight(editor, markdownParserFlags));
cleanups.push(registerComposerMarkdownHighlight(editor, markdownParserFlags, silentMessagePrefix));
cleanups.push(registerComposerBlockquote(editor));
}
cleanups.push(
registerComposerEmojiShortcode(editor, (shortcodeName) => {
@@ -543,7 +558,12 @@ const ComposerInner = ({
{discrete: true, tag: HISTORY_MERGE_TAG},
);
return mergeRegister(...cleanups);
}, [editor, markdown, markdownParserFlags, plainText]);
}, [editor, markdown, markdownParserFlags, plainText, silentMessagePrefix]);
useLayoutEffect(
() => registerComposerSpecialMention(editor, specialMentionsAllowed, plainText),
[editor, plainText, specialMentionsAllowed],
);
useEffect(() => {
return mergeRegister(
@@ -576,35 +596,11 @@ const ComposerInner = ({
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_ENTER_COMMAND,
(event: KeyboardEvent | null) => {
if (typeaheadActiveState.current || event == null) {
return false;
}
if (submitOnEnterRef.current && cb.current.onEnter != null) {
if (!event.shiftKey) {
event.preventDefault();
if ($focusFirstInvalidSlashSlot()) {
return true;
}
cb.current.onEnter();
return true;
}
return false;
}
if ((event.metaKey || event.ctrlKey) && cb.current.onEnter != null) {
event.preventDefault();
if ($focusFirstInvalidSlashSlot()) {
return true;
}
cb.current.onEnter();
return true;
}
return false;
},
COMMAND_PRIORITY_HIGH,
),
registerComposerEnter(editor, {
typeaheadActiveState,
getSubmitOnEnter: () => submitOnEnterRef.current,
getOnEnter: () => cb.current.onEnter,
}),
editor.registerCommand(
KEY_ARROW_UP_COMMAND,
(event: KeyboardEvent | null) => {
@@ -54,6 +54,37 @@
opacity: 0.45;
}
.blockquoteLine {
position: relative;
display: inline-block;
box-sizing: border-box;
width: 100%;
min-height: var(--composer-line-height, var(--textarea-line-height, 1.375rem));
vertical-align: top;
padding-inline-start: 1rem;
color: var(--text-chat-muted);
}
.blockquoteLine::before {
content: '';
position: absolute;
inset-block: 0;
inset-inline-start: 0;
width: 0.25rem;
border-radius: 0.25rem;
background-color: var(--interactive-muted);
}
.marker.blockquoteMarker {
position: absolute;
inset-block-start: 0;
inset-inline-end: calc(100% - 1rem);
white-space: pre;
color: transparent;
opacity: 1;
pointer-events: none;
}
.bold {
font-weight: 700;
}
@@ -128,17 +128,24 @@ export const LexicalRichInput = ({
[onChange, previousValueRef, rememberSegmentsForValue, segmentManagerRef],
);
const {autocompleteQuery, autocompleteOptions, autocompleteType, isSlotMenu, onCursorMove, handleSelect} =
useLexicalAutocomplete({
channel,
handleRef,
allowedTriggers: allowedTriggers ?? (channel == null ? SAFE_CONTEXT_FREE_TRIGGERS : SAFE_CHANNEL_TRIGGERS),
allowSpecialMentions,
allowMediaOptions: false,
maxActualLength: maxLength,
onExceedMaxLength,
i18n,
});
const {
autocompleteQuery,
autocompleteOptions,
autocompleteType,
isSlotMenu,
onCursorMove,
handleSelect,
specialMentionsAllowed,
} = useLexicalAutocomplete({
channel,
handleRef,
allowedTriggers: allowedTriggers ?? (channel == null ? SAFE_CONTEXT_FREE_TRIGGERS : SAFE_CHANNEL_TRIGGERS),
allowSpecialMentions,
allowMediaOptions: false,
maxActualLength: maxLength,
onExceedMaxLength,
i18n,
});
const insertEmoji = useCallback(
(emoji: FlatEmoji) =>
@@ -224,6 +231,7 @@ export const LexicalRichInput = ({
markdown={markdown}
markdownParserFlags={markdownParserFlags}
emojiShortcodeResolver={emojiShortcodeResolver}
specialMentionsAllowed={specialMentionsAllowed}
channelId={channel == null ? undefined : channel.id}
guildId={channel == null ? undefined : channel.guildId}
submitOnEnter={submitOnEnter ?? singleLine}
@@ -0,0 +1,185 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type BlockquoteEdit,
type BlockquoteLine,
dropTrailingEmptyBlockquoteLines,
findBlockquoteMarkerEnds,
planBlockquoteArrowLeft,
planBlockquoteBackspace,
planBlockquoteLineBreak,
resolveBlockquoteCaret,
} from '@app/features/lexical/composer/blockquoteLines';
import {computeMarkdownHighlightSpans} from '@app/features/lexical/composer/markdownSpans';
import {describe, expect, it} from 'vitest';
const QUOTE_PREFIX_RE = /^[ \t]*> /;
function quoteLines(text: string): Array<BlockquoteLine> {
const lines: Array<BlockquoteLine> = [];
let start = 0;
for (const line of text.split('\n')) {
const prefix = QUOTE_PREFIX_RE.exec(line);
if (prefix != null) {
lines.push({start, contentStart: start + prefix[0].length, end: start + line.length});
}
start += line.length + 1;
}
return lines;
}
function applyEdit(text: string, edit: BlockquoteEdit | null): string | null {
return edit == null ? null : `${text.slice(0, edit.start)}${edit.text}${text.slice(edit.end)}`;
}
describe('planBlockquoteLineBreak', () => {
const ROWS: Array<[string, number, string, number]> = [
['> test', 6, '> test\n> ', 9],
['> test', 4, '> te\n> st', 7],
['> test', 2, '> \n> test', 5],
['> ', 2, '> \n> ', 5],
['x\n> ', 4, 'x\n> \n> ', 7],
['> test\n> ', 9, '> test\n', 7],
['> \n> ', 5, '', 0],
['> \n> \n> ', 8, '', 0],
['> a\n> \n> ', 9, '> a\n', 4],
['> a\n> \n> b', 6, '> a\n\n> b', 4],
['x\n> \n> ', 7, 'x\n', 2],
['> ', 3, '> \n> ', 6],
[' > a', 5, ' > a\n> ', 8],
];
it.each(ROWS)('on %j at %i gives %j with the caret at %i', (text, caret, expected, expectedCaret) => {
const edit = planBlockquoteLineBreak(text, quoteLines(text), caret);
expect(applyEdit(text, edit)).toBe(expected);
expect(edit?.caret).toBe(expectedCaret);
});
it.each<[string, number]>([
['abc', 3],
['', 0],
['> test', 1],
['x\n> a', 1],
])('declines on %j at %i because the caret is not on a quote line', (text, caret) => {
expect(planBlockquoteLineBreak(text, quoteLines(text), caret)).toBeNull();
});
it.each<[string, number]>([
['```\n> a', 7],
['> ```', 5],
['```\n> a\n```', 6],
])('declines on %j at %i because the caret is inside a code block', (text, caret) => {
expect(planBlockquoteLineBreak(text, quoteLines(text), caret)).toBeNull();
});
});
describe('planBlockquoteBackspace', () => {
const ROWS: Array<[string, number, string, number]> = [
['> test', 2, 'test', 0],
['> ', 2, '', 0],
['> \n> ', 5, '> \n', 3],
['> \n', 3, '', 0],
['> \nabc', 3, 'abc', 0],
['a\n> \nb', 5, 'a\nb', 2],
[' > a', 4, 'a', 0],
['x\n> test', 4, 'x\ntest', 2],
];
it.each(ROWS)('on %j at %i gives %j with the caret at %i', (text, caret, expected, expectedCaret) => {
const edit = planBlockquoteBackspace(text, quoteLines(text), caret);
expect(applyEdit(text, edit)).toBe(expected);
expect(edit?.caret).toBe(expectedCaret);
});
it.each<[string, number]>([
['> a\nb', 4],
['> test', 4],
['> ', 3],
['abc', 3],
['> \n> ', 3],
['```\n> a', 6],
])('declines on %j at %i', (text, caret) => {
expect(planBlockquoteBackspace(text, quoteLines(text), caret)).toBeNull();
});
});
describe('resolveBlockquoteCaret', () => {
it.each<[string, number, number]>([
['> test', 0, 2],
['> test', 1, 2],
['> test', 2, 2],
['> test', 5, 5],
[' > a', 3, 4],
['ab\n> cd', 3, 5],
['ab\n> cd', 4, 5],
['ab\n> cd', 1, 1],
])('maps %j at %i to %i', (text, caret, expected) => {
expect(resolveBlockquoteCaret(quoteLines(text), caret)).toBe(expected);
});
});
describe('planBlockquoteArrowLeft', () => {
it('moves to the end of the line above from a later quote line', () => {
expect(planBlockquoteArrowLeft(quoteLines('ab\n> cd'), 5)).toBe(2);
});
it('keeps the caret at content start on the first line', () => {
expect(planBlockquoteArrowLeft(quoteLines('> test'), 2)).toBe(2);
});
it.each<[string, number]>([
['> test', 4],
['> test', 0],
['abc', 2],
])('declines on %j at %i', (text, caret) => {
expect(planBlockquoteArrowLeft(quoteLines(text), caret)).toBeNull();
});
});
describe('findBlockquoteMarkerEnds', () => {
it.each<[string, Array<number>]>([
['> a', [2]],
[' > a', [4]],
['> ', [2]],
['>>> a\nb', [4, 0]],
['>>> [!NOTE]\nbody', [0, 0]],
[' >>> [!tip] x\nbody', [0, 0]],
['> > a', [2]],
['> >>> a', [2]],
['> [!NOTE]\n> body', [2, 2]],
['> a\n> b', [2, 2]],
['>a', [0]],
['\\> a', [0]],
['`> a`', [0]],
['```\n> a', [0, 0]],
['```\n> a\n```', [0, 0, 0]],
['```\nx\n```\n> a', [0, 0, 0, 2]],
])('gives %j the marker ends %j', (text, ends) => {
expect(findBlockquoteMarkerEnds(text, computeMarkdownHighlightSpans(text))).toEqual(ends);
});
it('treats whitespace after the prefix as content rather than marker', () => {
expect(computeMarkdownHighlightSpans('> ').find((span) => span.start === 2)).toMatchObject({
role: 'content',
end: 3,
});
});
});
describe('dropTrailingEmptyBlockquoteLines', () => {
it.each<[string, string]>([
['> test\n> ', '> test'],
['> ', ''],
['> test\n> \n', '> test'],
['> test\n', '> test'],
['> \n> ', ''],
['a\n > ', 'a'],
['> a\n> ', '> a'],
['> a\n>', '> a\n>'],
['```\n> ', '```\n> '],
['> a\n> b', '> a\n> b'],
['', ''],
])('turns %j into %j', (content, expected) => {
expect(dropTrailingEmptyBlockquoteLines(content)).toBe(expected);
});
});
@@ -0,0 +1,121 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {isOffsetInsideCodeBlock, scanCodeBlocks} from '@app/features/lexical/composer/codeBlockIndent';
import {MarkdownHl, type MarkdownSpan} from '@app/features/lexical/composer/markdownSpans';
export interface BlockquoteLine {
start: number;
contentStart: number;
end: number;
}
export interface BlockquoteEdit {
start: number;
end: number;
text: string;
caret: number;
}
const CONTINUATION = '\n> ';
const DROPPABLE_TRAILING_LINE_RE = /^[ \t]*(?:> [ \t]*)?$/;
const MULTILINE_ALERT_RE = /^[ \t]*>>> \[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i;
function isEmptyLine(line: BlockquoteLine): boolean {
return line.contentStart === line.end;
}
function isDirectlyAbove(upper: BlockquoteLine, lower: BlockquoteLine): boolean {
return upper.end + 1 === lower.start;
}
export function findBlockquoteMarkerEnds(source: string, spans: ReadonlyArray<MarkdownSpan>): Array<number> {
const markerEndByStart = new Map<number, number>();
for (const span of spans) {
if (span.role === 'marker' && (span.format & MarkdownHl.blockquoteMarker) !== 0) {
markerEndByStart.set(span.start, span.end);
}
}
const blocks = scanCodeBlocks(source);
const ends: Array<number> = [];
let lineStart = 0;
for (const line of source.split('\n')) {
const markerEnd = markerEndByStart.get(lineStart);
const insideCode = blocks.some((block) => block.start <= lineStart && lineStart <= block.end);
const suppressed = insideCode || MULTILINE_ALERT_RE.test(line);
ends.push(markerEnd == null || suppressed ? 0 : Math.min(markerEnd, lineStart + line.length) - lineStart);
lineStart += line.length + 1;
}
return ends;
}
export function planBlockquoteLineBreak(
text: string,
lines: ReadonlyArray<BlockquoteLine>,
caret: number,
): BlockquoteEdit | null {
if (isOffsetInsideCodeBlock(text, caret)) {
return null;
}
const index = lines.findIndex((line) => line.contentStart <= caret && caret <= line.end);
if (index < 0) {
return null;
}
const line = lines[index]!;
const above = index > 0 ? lines[index - 1]! : null;
if (!isEmptyLine(line) || above == null || !isDirectlyAbove(above, line)) {
return {start: caret, end: caret, text: CONTINUATION, caret: caret + CONTINUATION.length};
}
let top = index;
while (top > 0 && isDirectlyAbove(lines[top - 1]!, lines[top]!) && isEmptyLine(lines[top - 1]!)) {
top -= 1;
}
return {start: lines[top]!.start, end: line.contentStart, text: '', caret: lines[top]!.start};
}
export function planBlockquoteBackspace(
text: string,
lines: ReadonlyArray<BlockquoteLine>,
caret: number,
): BlockquoteEdit | null {
if (isOffsetInsideCodeBlock(text, caret)) {
return null;
}
const line = lines.find((candidate) => candidate.contentStart === caret);
if (line != null) {
return {start: line.start, end: line.contentStart, text: '', caret: line.start};
}
const above = lines.find((candidate) => candidate.end + 1 === caret && isEmptyLine(candidate));
if (above == null || lines.some((candidate) => candidate.start === caret)) {
return null;
}
return {start: above.start, end: caret, text: '', caret: above.start};
}
export function resolveBlockquoteCaret(lines: ReadonlyArray<BlockquoteLine>, caret: number): number {
const line = lines.find((candidate) => candidate.start <= caret && caret < candidate.contentStart);
return line == null ? caret : line.contentStart;
}
export function planBlockquoteArrowLeft(lines: ReadonlyArray<BlockquoteLine>, caret: number): number | null {
const line = lines.find((candidate) => candidate.contentStart === caret);
if (line == null) {
return null;
}
return line.start > 0 ? line.start - 1 : line.contentStart;
}
export function dropTrailingEmptyBlockquoteLines(content: string): string {
const blocks = scanCodeBlocks(content);
let end = content.length;
while (end > 0) {
const lineStart = content.lastIndexOf('\n', end - 1) + 1;
if (
!DROPPABLE_TRAILING_LINE_RE.test(content.slice(lineStart, end)) ||
blocks.some((block) => block.start <= lineStart && lineStart <= block.end)
) {
break;
}
end = Math.max(0, lineStart - 1);
}
return content.slice(0, end);
}
@@ -0,0 +1,369 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
analyzeCodeIndent,
type CodeIndentPlan,
isOffsetInsideCodeBlock,
scanCodeBlocks,
} from '@app/features/lexical/composer/codeBlockIndent';
import {
computeMarkdownHighlightSpans,
DEFAULT_COMPOSER_MARKDOWN_FLAGS,
MarkdownHl,
} from '@app/features/lexical/composer/markdownSpans';
import {parseMarkdownAstWithWasm} from '@app/features/messaging/utils/markdown/parser/MarkdownParserWasm';
import type {CodeBlockNode, Node} from '@app/features/messaging/utils/markdown/parser/Nodes';
import {describe, expect, it} from 'vitest';
type OffsetRow = [label: string, text: string, offset: number, expected: boolean];
const OFFSET_ROWS: Array<OffsetRow> = [
['a caret right after a lone opening fence is inside', '```', 3, true],
['a caret before the opening fence is outside', '```', 0, false],
['a caret within the opening backtick run is outside', '```', 2, false],
['two backticks do not open a block', '``', 2, false],
['a caret after the language of an unclosed fence is inside', '```js', 5, true],
['a caret between the fence and its language is inside', '```js', 3, true],
['the empty line after an opening fence is inside', '```\n', 4, true],
['the end of an unclosed block is inside', '```js\nconst a = 1', 17, true],
['a fence indented by four spaces opens a block', ' ```\ncode', 12, true],
['a tab-indented fence opens a block', '\t```\ncode', 9, true],
['a caret after the language on the opening line of a closed block is inside', '```js\ncode\n```', 5, true],
['a caret in the body of a closed block is inside', '```\ncode\n```', 6, true],
['the start of the closing fence line is inside', '```\ncode\n```', 9, true],
['a caret within the closing backtick run is outside', '```\ncode\n```', 10, false],
['a caret after the closing fence is outside', '```\ncode\n```', 12, false],
['trailing spaces after the closing fence stay outside', '```\ncode\n``` ', 14, false],
['the indent before an indented closing fence is inside', '```\ncode\n ```', 11, true],
['a caret after an indented closing fence is outside', '```\ncode\n ```', 14, false],
['content before a closing fence on the same line is inside', '```\ncode ```', 9, true],
['a caret after a closing fence that follows content is outside', '```\ncode ```', 12, false],
['a fence followed by text does not close the block', '```\ncode\n```x', 13, true],
['a longer backtick run closes the block', '```\ncode\n````', 13, false],
['a caret right after a closing fence with text after it is outside', '```\na\n``` x ```', 9, false],
['text after a closing fence can open a new block', '```\na\n``` x ```', 15, true],
['a caret inside a one-line block is inside', '```hello```', 5, true],
['a caret after a one-line block is outside', '```hello```', 11, false],
['the line after a one-line block is outside', '```hello```\nmore', 16, false],
['a one-line block inside prose holds the caret', 'use ```foo``` here', 9, true],
['prose after a one-line block is outside', 'use ```foo``` here', 18, false],
['a backtick left over after a one-line block is outside', '```a````', 8, false],
['a caret after a midline opening fence is inside', 'label```js\ncode', 15, true],
['a caret before a midline opening fence is outside', 'label```js\ncode', 5, false],
['a caret before the midline fence of a closed block is outside', 'label```js\ncode\n```', 5, false],
['an unclosed fence after text on the same line is inside', 'hello ```', 9, true],
['a sentence that mentions a fence stays outside', 'type ``` to start a block', 25, false],
['prose that holds an unmatched fence stays outside', 'use ``` for code blocks', 23, false],
['an unclosed fence whose info is not a language opens a block at the start of a line', '```js hello', 11, true],
['a lone fence followed by a space is inside', '``` ', 4, true],
['a midline fence after a preceding line holds its body', 'intro line\nlabel```rust\nfn main() {}\n```', 29, true],
[
'a caret after a midline block that follows a line is outside',
'intro line\nlabel```rust\nfn main() {}\n```',
40,
false,
],
['an escaped fence does not open a block', '\\```js\ncode', 11, false],
['an escaped backslash leaves the fence live', '\\\\```js\ncode', 12, true],
['a one-line block after an escaped backslash holds the caret', '\\\\```hello```', 7, true],
['a caret after a one-line block that follows an escaped backslash is outside', '\\\\```hello```', 13, false],
['a longer escaped fence stays text', '\\````hello````', 7, false],
['the end of a longer escaped fence stays outside', '\\````hello````', 14, false],
['an escaped midline fence stays text', 'label\\```rust\nfn main() {}\n```', 19, false],
['a fence inside inline code does not open a block', '`` ``` ``\ncode', 14, false],
['a fence inside inline code in prose does not open a block', 'use `` ``` `` to open a block', 29, false],
['a caret on a fence inside inline code is outside', 'use `` ``` `` to open a block', 8, false],
['a fence after closed inline code opens a block', '`code` ```js\nx', 14, true],
['a block opened after closed inline code holds its body', '`code` ```js\nxy\n```', 14, true],
['a caret after a block opened after closed inline code is outside', '`code` ```js\nxy\n```', 19, false],
['three backticks do not close a four-backtick fence', '````\ncode\n```\nmore', 18, true],
['a four-backtick block holds a three-backtick line', '````\ncode\n```\nmore\n````', 16, true],
['a caret after a four-backtick block is outside', '````\ncode\n```\nmore\n````', 23, false],
[
'an unclosed longer outer fence wins over a closed inner block because the user is still writing the outer block',
'````\ncode\n```\nmore\n```',
22,
true,
],
['the end of the first closing fence line is outside', '```\na\n```\ntext\n```\nb\n```', 9, false],
['text between two blocks is outside', '```\na\n```\ntext\n```\nb\n```', 12, false],
['the end of the text between two blocks is outside', '```\na\n```\ntext\n```\nb\n```', 14, false],
['the body of the second of two blocks is inside', '```\na\n```\ntext\n```\nb\n```', 20, true],
['a caret after the second of two blocks is outside', '```\na\n```\ntext\n```\nb\n```', 24, false],
['a block that opens after a closed block is inside', '```\na\n```\n```\n', 14, true],
['the caret after the opening fence of an empty block is inside', '```\n```', 3, true],
['the body of an empty block is inside', '```\n```', 4, true],
['the closing fence of an empty block reopens a block, as the parser reads it', '```\n```', 7, true],
['the empty middle line of an empty block is inside', '```\n\n```', 4, true],
['a closed block inside a block spoiler holds the caret', '||```\ncode\n```||', 10, true],
['a caret after a block spoiler around a closed block is outside', '||```\ncode\n```||', 16, false],
['a fence in an unclosed block spoiler opens a block', '||```\ncode', 10, true],
['an unclosed quoted fence ends with its quote', '> ```\ncode\n```', 10, false],
['an unquoted fence after a quoted fence opens another block', '> ```\ncode\n```', 14, true],
['a block closed on a quoted line holds the caret', '> ```\n> code\n> ```', 12, true],
['a caret after a block closed on a quoted line is outside', '> ```\n> code\n> ```', 18, false],
['a multiline quote holds a closed block', '>>> ```\ncode\n```', 12, true],
['a caret after a closed block in a multiline quote is outside', '>>> ```\ncode\n```', 16, false],
['an unclosed quoted fence does not hold the text after the quote', '> ```\ncode\nnormal text', 22, false],
['an unclosed quoted fence does not hold a later unquoted line', '> ```\n> code\nplain', 18, false],
['an unclosed spoilered fence does not hold the text after the spoiler', '||```\ncode|| after', 18, false],
['a fence on a list item line does not open a block', '- ```', 5, false],
['a fence on a numbered list item line does not open a block', '1. ```\ncode', 11, false],
['a closed block written on a list item line does not open a block', '- ```\ncode\n```', 8, false],
['a fence in prose on a list item line does not open a block', '- use ``` for code', 18, false],
['a fence on the line after a list item opens a block', '- a\n```\ncode\n```', 9, true],
['empty text is outside', '', 0, false],
['plain text is outside', 'hello', 5, false],
];
type BodyRow = [label: string, text: string, bodies: ReturnType<typeof scanCodeBlocks>];
const BODY_ROWS: Array<BodyRow> = [
['a closed block', '```\ncode\n```', [{start: 3, end: 9, rendered: true}]],
['an unclosed block runs to the end of the text', '```js\ncode', [{start: 3, end: 10, rendered: false}]],
[
'an empty block is not rendered and its closing fence opens another block',
'```\n\n```',
[
{start: 3, end: 5, rendered: false},
{start: 8, end: 8, rendered: false},
],
],
[
'an unclosed outer fence and the closed block inside it',
'````\ncode\n```\nmore\n```',
[
{start: 4, end: 22, rendered: false},
{start: 13, end: 19, rendered: true},
],
],
[
'a closed block inside an unclosed block spoiler is not rendered',
'||```\ncode\n```',
[{start: 5, end: 11, rendered: false}],
],
['a closed block inside a block spoiler', '||```\ncode\n```||', [{start: 5, end: 11, rendered: true}]],
['a fence written on a list item line', '- ```\ncode\n```', [{start: 14, end: 14, rendered: false}]],
['an unclosed quoted fence bounded by its quote', '> ```\ncode\nnormal text', [{start: 5, end: 5, rendered: false}]],
['an unclosed spoilered fence bounded by its spoiler', '||```\ncode|| after', [{start: 5, end: 10, rendered: false}]],
['prose that holds an unmatched fence', 'use ``` for code blocks', []],
];
type IndentRow = [
label: string,
text: string,
selectionStart: number,
selectionEnd: number,
unindent: boolean,
expected: CodeIndentPlan | null,
];
function tabAt(offset: number): CodeIndentPlan {
return {edits: [{start: offset, end: offset, text: '\t'}], selectionStart: offset + 1, selectionEnd: offset + 1};
}
const INDENT_ROWS: Array<IndentRow> = [
['still indents the caret in the body of a closed block', '```\ncode\n```', 4, 4, false, tabAt(4)],
['still ignores a caret on the closing fence line', '```\ncode\n```', 12, 12, false, null],
['still indents the caret at the end of an unclosed block', '```\ncode', 8, 8, false, tabAt(8)],
[
'still unindents a tab in the body',
'```\n\tcode\n```',
5,
5,
true,
{edits: [{start: 4, end: 5, text: ''}], selectionStart: 4, selectionEnd: 4},
],
[
'still indents every selected body line',
'```\na\nb\n```',
4,
7,
false,
{
edits: [
{start: 4, end: 4, text: '\t'},
{start: 6, end: 6, text: '\t'},
],
selectionStart: 5,
selectionEnd: 9,
},
],
['still ignores an indented closing fence line', '```\ncode\n ```', 14, 14, false, null],
['still treats content before a closing fence on its line as code', '```\ncode ```', 8, 8, false, tabAt(8)],
['no longer treats the line after a one-line block as code', '```hello```\nmore', 16, 16, false, null],
[
'no longer lets three backticks close a four-backtick fence',
'````\ncode\n```\nmore\n````',
18,
18,
false,
tabAt(18),
],
['recognises a midline opening fence', 'label```js\ncode\n```', 15, 15, false, tabAt(15)],
['recognises a fence indented by four spaces', ' ```\ncode', 12, 12, false, tabAt(12)],
['no longer indents the line after an unclosed quoted fence', '> ```\ncode\nnormal text', 22, 22, false, null],
['no longer indents the line after a fence on a list item line', '- ```\ncode', 10, 10, false, null],
];
const HIGHLIGHT_ROWS: Array<[text: string, offset: number, expected: boolean]> = [
['```\ncode\n```', 6, true],
['```hello```', 5, true],
['use ```foo``` here', 9, true],
['intro line\nlabel```rust\nfn main() {}\n```', 29, true],
['\\\\```hello```', 7, true],
['`code` ```js\nxy\n```', 14, true],
['````\ncode\n```\nmore\n````', 16, true],
['```\na\n```\ntext\n```\nb\n```', 20, true],
['```\na\n```\ntext\n```\nb\n```', 12, false],
['\\````hello````', 7, false],
['label\\```rust\nfn main() {}\n```', 19, false],
['use `` ``` `` to open a block', 8, false],
];
const CONTAINER_FIXTURES = [
'hi\n||```\ncode\n```||',
'||a||\n```\ncode\n```',
'||\n```\ncode\n```\n||',
'> > ```\n> code\n> ```',
'>>> a\n> ```\n> code\n> ```',
'> [!NOTE]\n> ```\n> code\n> ```',
'- a\n```\ncode\n```',
'```\n```\ncode\n```',
'```js title\ncode\n```',
];
const INVISIBLE_CONTENT_FIXTURES = [
'```\n\u200b\n```',
'```\n\u00a0\u3164\n```',
'```\u034f```',
'```\na\u034f\n```',
'```\n\ufe0f\n```\ncode\n```',
'||\u200b\n```\n||',
];
const CORPUS_PIECES = [
'```',
'```',
'`',
'\\',
' ',
' ',
'\t',
'\n',
'\n',
'\n',
'> ',
'>>> ',
'||',
'|',
'>',
'- ',
'* ',
'1. ',
'a',
'x',
];
const CORPUS_SIZE = 5000;
const CORPUS_MAX_PIECES = 24;
function createCorpus(): Array<string> {
let state = 0x2545f491;
const nextIndex = (bound: number): number => {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
return (state >>> 0) % bound;
};
return Array.from({length: CORPUS_SIZE}, () =>
Array.from({length: 1 + nextIndex(CORPUS_MAX_PIECES)}, () => CORPUS_PIECES[nextIndex(CORPUS_PIECES.length)]).join(
'',
),
);
}
function collectCodeBlocks(nodes: ReadonlyArray<Node>): Array<CodeBlockNode> {
return nodes.flatMap((node) => {
if (node.type === 'CodeBlock') {
return [node];
}
if (node.type === 'List') {
return node.items.flatMap((item) => collectCodeBlocks(item.children));
}
return 'children' in node ? collectCodeBlocks(node.children) : [];
});
}
function withoutQuoteMarkersOrWhitespace(value: string): string {
return value.replace(/[\s>]/g, '');
}
function parserCodeBlocks(text: string): Array<string> {
return collectCodeBlocks(parseMarkdownAstWithWasm(text, DEFAULT_COMPOSER_MARKDOWN_FLAGS).nodes).map((node) =>
withoutQuoteMarkersOrWhitespace(`${node.language ?? ''}${node.content}`),
);
}
function scannedCodeBlocks(text: string): Array<string> {
return scanCodeBlocks(text)
.filter((body) => body.rendered)
.map((body) => withoutQuoteMarkersOrWhitespace(text.slice(body.start, body.end)));
}
describe('isOffsetInsideCodeBlock', () => {
it.each(OFFSET_ROWS)('%s', (_label, text, offset, expected) => {
expect(isOffsetInsideCodeBlock(text, offset)).toBe(expected);
});
});
describe('scanCodeBlocks', () => {
it.each(BODY_ROWS)('reports %s', (_label, text, bodies) => {
expect(scanCodeBlocks(text)).toEqual(bodies);
});
});
describe('scanCodeBlocks against the markdown parser', () => {
const fixtures = new Set([
...OFFSET_ROWS.map(([, text]) => text),
...BODY_ROWS.map(([, text]) => text),
...INDENT_ROWS.map(([, text]) => text),
...CONTAINER_FIXTURES,
...INVISIBLE_CONTENT_FIXTURES,
]);
it.each([...fixtures])('closes the same blocks as the parser in %j', (text) => {
expect(scannedCodeBlocks(text)).toEqual(parserCodeBlocks(text));
});
it('closes the same blocks as the parser across a seeded generated corpus', () => {
const containers = new Set<string>();
for (const text of createCorpus()) {
for (const node of parseMarkdownAstWithWasm(text, DEFAULT_COMPOSER_MARKDOWN_FLAGS).nodes) {
if (collectCodeBlocks([node]).length > 0) {
containers.add(node.type);
}
}
expect({text, blocks: scannedCodeBlocks(text)}).toEqual({text, blocks: parserCodeBlocks(text)});
}
expect([...containers.keys()].sort()).toEqual(['Blockquote', 'CodeBlock', 'List', 'Spoiler']);
});
it.each(HIGHLIGHT_ROWS)('agrees with the composer highlight in %j at %i', (text, offset, expected) => {
expect(isOffsetInsideCodeBlock(text, offset)).toBe(expected);
expect(
computeMarkdownHighlightSpans(text).some(
(span) =>
span.role === 'content' &&
(span.format & MarkdownHl.codeBlock) !== 0 &&
span.start < offset &&
offset < span.end,
),
).toBe(expected);
});
});
describe('analyzeCodeIndent', () => {
it.each(INDENT_ROWS)('%s', (_label, text, selectionStart, selectionEnd, unindent, expected) => {
expect(analyzeCodeIndent(text, selectionStart, selectionEnd, unindent)).toEqual(expected);
});
});
@@ -1,8 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {findCodeBlockClosing} from '@app/features/lexical/composer/markdownSpans';
const INDENT = '\t';
const SPACE_INDENT_WIDTH = 4;
const FENCE_RE = /^ {0,3}`{3,}/;
const FENCE = '```';
const LEADING_SPACE_RE = /^[ \t\r]+/;
const TRAILING_SPACE_RE = /[ \t\r]+$/;
export const FENCE_LANGUAGE_RE = /^[\w+.#/-]+[ \t\r]*$/;
const LIST_ITEM_RE = /^( *)(?:[-*]|\d+\.) /;
const INVISIBLE_RE =
/[\p{White_Space}\0\u00ad\u061c\u115f\u1160\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2069\u2800\u3164\ufeff\uffa0]|\u034f|\u17b4|\u17b5|[\ufe00-\ufe0f]|[\u{e0100}-\u{e01ef}]/gu;
export interface CodeIndentEdit {
start: number;
@@ -16,6 +24,40 @@ export interface CodeIndentPlan {
selectionEnd: number;
}
export interface CodeBlockBody {
start: number;
end: number;
rendered: boolean;
}
interface SourceLine {
text: string;
offset: number;
}
interface LinePoint {
index: number;
column: number;
}
interface ListItem {
level: number;
content: string;
}
interface CodeBlockParse {
body: CodeBlockBody | null;
resume: LinePoint | null;
}
interface ScanScope {
bodies: Array<CodeBlockBody>;
end: number;
quotes: boolean;
multilineQuotes: boolean;
rendered: boolean;
}
interface PhysicalLine {
start: number;
end: number;
@@ -23,23 +65,329 @@ interface PhysicalLine {
isCodeContent: boolean;
}
function scanLines(text: string): Array<PhysicalLine> {
const lines: Array<PhysicalLine> = [];
let open = false;
let pos = 0;
for (const content of text.split('\n')) {
const fenceMatch = FENCE_RE.exec(content);
const rest = fenceMatch == null ? '' : content.slice(fenceMatch[0].length);
const isFence = fenceMatch != null && (!open || /^\s*$/.test(rest) || rest.includes('```'));
lines.push({start: pos, end: pos + content.length, text: content, isCodeContent: !isFence && open});
if (isFence) {
open = !open;
function trimStart(text: string): string {
return text.replace(LEADING_SPACE_RE, '');
}
function trimEnd(text: string): string {
return text.replace(TRAILING_SPACE_RE, '');
}
export function hasVisibleContent(text: string): boolean {
return text.replace(INVISIBLE_RE, '') !== '';
}
function hasOpenInlineCode(text: string): boolean {
let open = 0;
for (const [run] of text.matchAll(/`+/g)) {
if (open === 0) {
open = run.length;
} else if (open === run.length) {
open = 0;
}
pos += content.length + 1;
}
return open > 0;
}
function countLeadingSpaces(text: string): number {
let spaces = 0;
while (text[spaces] === ' ') {
spaces += 1;
}
return spaces;
}
function matchListItem(text: string): ListItem | null {
const match = LIST_ITEM_RE.exec(text);
if (match == null || match[1]!.length === 1) {
return null;
}
return {level: match[1]!.length >> 1, content: text.slice(match[0]!.length)};
}
function isBulletPointText(text: string): boolean {
return matchListItem(text) == null && trimStart(text).startsWith('- ') && !text.startsWith(' ');
}
function startsBlockquote(trimmed: string, scope: ScanScope): boolean {
return (scope.multilineQuotes && trimmed.startsWith('>>> ')) || (scope.quotes && trimmed.startsWith('> '));
}
function findOpeningFence(text: string): number {
for (let fence = text.indexOf(FENCE); fence >= 0; fence = text.indexOf(FENCE, fence + 1)) {
let backslashes = 0;
while (text[fence - backslashes - 1] === '\\') {
backslashes += 1;
}
if (backslashes % 2 === 0) {
return hasOpenInlineCode(text.slice(0, fence)) ? -1 : fence;
}
}
return -1;
}
function splitLines(text: string): Array<SourceLine> {
const lines: Array<SourceLine> = [];
let offset = 0;
for (const line of text.split('\n')) {
lines.push({text: line, offset});
offset += line.length + 1;
}
return lines;
}
function linesEnd(lines: Array<SourceLine>): number {
const last = lines[lines.length - 1]!;
return last.offset + last.text.length;
}
function trimLineWindow(lines: Array<SourceLine>): Array<SourceLine> {
let start = 0;
let end = lines.length;
while (start < end && trimStart(lines[start]!.text) === '') {
start += 1;
}
while (end > start && trimStart(lines[end - 1]!.text) === '') {
end -= 1;
}
const window = lines.slice(start, end);
if (window.length === 0) {
return window;
}
const first = window[0]!;
const trimmed = trimStart(first.text);
window[0] = {text: trimmed, offset: first.offset + first.text.length - trimmed.length};
const last = window[window.length - 1]!;
window[window.length - 1] = {text: trimEnd(last.text), offset: last.offset};
return window;
}
function opensUnclosedBlock(line: SourceLine, column: number, info: string): boolean {
return (
column === line.text.length - trimStart(line.text).length || FENCE_LANGUAGE_RE.test(info) || trimStart(info) === ''
);
}
function resumeAt(lines: Array<SourceLine>, {index, column}: LinePoint): number {
const line = lines[index]!;
if (column >= line.text.length) {
return index + 1;
}
lines[index] = {text: line.text.slice(column), offset: line.offset + column};
return index;
}
function resumeIndex(lines: Array<SourceLine>, {index, column}: LinePoint): number {
return column >= lines[index]!.text.length ? index + 1 : index;
}
function parseCodeBlock(lines: Array<SourceLine>, index: number, column: number, scope: ScanScope): CodeBlockParse {
const line = lines[index]!;
let infoStart = column;
while (line.text[infoStart] === '`') {
infoStart += 1;
}
const fence = line.text.slice(column, infoStart);
const start = line.offset + infoStart;
const info = line.text.slice(infoStart);
const inlineEnd = info.indexOf(fence);
if (inlineEnd >= 0) {
const visible = hasVisibleContent(info.slice(0, inlineEnd));
return {
body: {start, end: start + inlineEnd, rendered: visible && scope.rendered},
resume: visible ? {index, column: infoStart + inlineEnd + fence.length} : null,
};
}
let content = FENCE_LANGUAGE_RE.test(info) ? '' : info;
for (let next = index + 1; next < lines.length; next += 1) {
const text = lines[next]!.text;
const closing = findCodeBlockClosing(text, fence, fence.length);
if (closing == null) {
content += text;
continue;
}
const visible = hasVisibleContent(content + text.slice(0, closing.fenceIndex));
const trailingStart = closing.fenceIndex + (closing.trailing.length > 0 ? closing.runLength : fence.length);
return {
body: {start, end: lines[next]!.offset + closing.fenceIndex, rendered: visible && scope.rendered},
resume: visible ? {index: next, column: trailingStart} : null,
};
}
if (!opensUnclosedBlock(line, column, info)) {
return {body: null, resume: null};
}
return {body: {start, end: scope.end, rendered: false}, resume: null};
}
function scanParagraph(lines: Array<SourceLine>, index: number, scope: ScanScope): number {
let next = index + 1;
while (next < lines.length) {
const trimmed = trimStart(lines[next]!.text);
if (
trimmed === '' ||
trimmed.startsWith(FENCE) ||
matchListItem(trimmed) != null ||
startsBlockquote(trimmed, scope)
) {
return next;
}
const fence = findOpeningFence(lines[next]!.text);
if (fence >= 0) {
const parsed = parseCodeBlock(lines, next, fence, scope);
if (parsed.resume != null) {
return next;
}
if (parsed.body != null) {
scope.bodies.push(parsed.body);
}
}
next += 1;
}
return next;
}
function scanQuote(lines: Array<SourceLine>, index: number, scope: ScanScope): number {
const quoted: Array<SourceLine> = [];
let next = index;
while (next < lines.length) {
const line = lines[next]!;
const trimmed = trimStart(line.text);
if (!trimmed.startsWith('> ')) {
break;
}
quoted.push({text: trimmed.slice(2), offset: line.offset + line.text.length - trimmed.length + 2});
next += 1;
}
scanBlocks(quoted, {...scope, quotes: false, end: linesEnd(quoted)});
return next;
}
function scanSpoiler(lines: Array<SourceLine>, index: number, scope: ScanScope): number {
const line = lines[index]!;
const open = line.text.indexOf('||') + 2;
const children: Array<SourceLine> = [{text: line.text.slice(open), offset: line.offset + open}];
for (let next = index + 1; next < lines.length; next += 1) {
const close = lines[next]!.text.indexOf('||');
if (close < 0) {
children.push(lines[next]!);
continue;
}
children.push({text: lines[next]!.text.slice(0, close), offset: lines[next]!.offset});
if (!children.some((child) => hasVisibleContent(child.text))) {
return next + 1;
}
const window = trimLineWindow(children);
scanBlocks(window, {...scope, end: linesEnd(window)});
return resumeAt(lines, {index: next, column: close + 2});
}
scanBlocks(children, {...scope, rendered: false, end: linesEnd(children)});
return lines.length;
}
function scanListFence(lines: Array<SourceLine>, index: number, scope: ScanScope): number {
if (index >= lines.length) {
return index;
}
const line = lines[index]!;
const trimmed = trimStart(line.text);
if (!trimmed.startsWith(FENCE)) {
return index;
}
const parsed = parseCodeBlock(lines, index, line.text.length - trimmed.length, scope);
if (parsed.body == null || parsed.resume == null) {
return index;
}
scope.bodies.push(parsed.body);
return resumeIndex(lines, parsed.resume);
}
function scanList(lines: Array<SourceLine>, index: number, scope: ScanScope): number {
const level = matchListItem(lines[index]!.text)?.level ?? 0;
let next = index;
while (next < lines.length) {
const text = lines[next]!.text;
const trimmed = trimStart(text);
if (trimmed.startsWith('#') || startsBlockquote(trimmed, scope)) {
return next;
}
const item = matchListItem(text);
if (item != null) {
next = matchListItem(item.content) == null ? scanListFence(lines, next + 1, scope) : next + 1;
continue;
}
if (!isBulletPointText(text) && countLeadingSpaces(text) <= level * 2) {
return next;
}
next += 1;
}
return next;
}
function scanBlock(lines: Array<SourceLine>, index: number, scope: ScanScope): number {
const line = lines[index]!;
const trimmed = trimStart(line.text);
if (trimmed.startsWith('>>> ')) {
if (!scope.multilineQuotes) {
return index + 1;
}
const quoted = {text: trimmed.slice(4), offset: line.offset + line.text.length - trimmed.length + 4};
scanBlocks([quoted, ...lines.slice(index + 1)], {...scope, quotes: true, multilineQuotes: false});
return lines.length;
}
if (trimmed.startsWith('> ')) {
return scope.quotes ? scanQuote(lines, index, scope) : scanParagraph(lines, index, scope);
}
if (matchListItem(line.text) != null) {
return scanList(lines, index, scope);
}
if (trimmed.startsWith('||') && !trimmed.includes('||', 2)) {
return scanSpoiler(lines, index, scope);
}
const fence = findOpeningFence(line.text);
if (fence >= 0) {
const parsed = parseCodeBlock(lines, index, fence, scope);
if (parsed.body != null) {
scope.bodies.push(parsed.body);
}
if (parsed.resume != null) {
return resumeAt(lines, parsed.resume);
}
}
return scanParagraph(lines, index, scope);
}
function scanBlocks(lines: Array<SourceLine>, scope: ScanScope): void {
let index = 0;
while (index < lines.length) {
index = trimStart(lines[index]!.text) === '' ? index + 1 : scanBlock(lines, index, scope);
}
}
export function scanCodeBlocks(text: string): Array<CodeBlockBody> {
const scope: ScanScope = {bodies: [], end: text.length, quotes: true, multilineQuotes: true, rendered: true};
scanBlocks(splitLines(text), scope);
return scope.bodies;
}
export function isOffsetInsideCodeBlock(text: string, offset: number): boolean {
return scanCodeBlocks(text).some((body) => body.start <= offset && offset <= body.end);
}
function scanLines(text: string): Array<PhysicalLine> {
const bodies = scanCodeBlocks(text);
return splitLines(text).map(({text: content, offset: start}) => {
const end = start + content.length;
return {
start,
end,
text: content,
isCodeContent: bodies.some(
(body) => start >= body.start && (end <= body.end || trimStart(text.slice(start, body.end)) !== ''),
),
};
});
}
function lineAt(lines: Array<PhysicalLine>, offset: number): number {
for (let i = 0; i < lines.length; i += 1) {
if (offset >= lines[i]!.start && offset <= lines[i]!.end) {
@@ -0,0 +1,616 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {registerComposerMarkdownHighlight} from '@app/features/lexical/composer/ComposerMarkdownHighlight';
import {type CodeBlockWrapPlan, planCodeBlockWrap} from '@app/features/lexical/composer/codeBlockWrap';
import {
$captureSelectionOffsets,
$getComposerDisplayText,
$isComposerSelectionWrapped,
$replaceComposerRange,
$selectComposerRange,
$wrapComposerSelection,
} from '@app/features/lexical/composer/composerOffsets';
import {DEFAULT_COMPOSER_MARKDOWN_FLAGS} from '@app/features/lexical/composer/markdownSpans';
import {ComposerBlockquoteLineNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
import {ComposerBlockquoteMarkerNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteMarkerNode';
import {
$isComposerCustomEmojiNode,
ComposerCustomEmojiNode,
} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode';
import {$isComposerMentionNode, ComposerMentionNode} from '@app/features/lexical/composer/nodes/ComposerMentionNode';
import {$isSyntaxMarkerNode, SyntaxMarkerNode} from '@app/features/lexical/composer/nodes/SyntaxMarkerNode';
import {parseMarkdownAstWithWasm} from '@app/features/messaging/utils/markdown/parser/MarkdownParserWasm';
import type {CodeBlockNode, Node} from '@app/features/messaging/utils/markdown/parser/Nodes';
import {createEmptyHistoryState, registerHistory} from '@lexical/history';
import {
$getNodeByKey,
$getRoot,
createEditor,
type ElementNode,
type LexicalEditor,
type NodeKey,
REDO_COMMAND,
UNDO_COMMAND,
} from 'lexical';
import {describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/lexical/composer/nodes/ComposerMentionPill', () => ({ComposerMentionPill: () => null}));
vi.mock('@app/features/lexical/composer/nodes/ComposerCustomEmoji', () => ({ComposerCustomEmoji: () => null}));
vi.mock('@app/features/lexical/composer/nodes/ComposerStandardEmoji', () => ({ComposerStandardEmoji: () => null}));
interface PlanResult {
wrapped: boolean;
text: string;
selection: [number, number];
}
type PlanRow = [label: string, text: string, start: number, end: number, expected: PlanResult | null];
function applyPlan(
text: string,
{wrapped, opening, closing, selectionStart, selectionEnd}: CodeBlockWrapPlan,
): PlanResult {
const head = text.slice(0, opening.start);
const middle = text.slice(opening.end, closing.start);
return {
wrapped,
text: `${head}${opening.text}${middle}${closing.text}${text.slice(closing.end)}`,
selection: [selectionStart, selectionEnd],
};
}
function planned(text: string, start: number, end: number): PlanResult | null {
const plan = planCodeBlockWrap(text, start, end);
return plan == null ? null : applyPlan(text, plan);
}
const PLAN_ROWS: Array<PlanRow> = [
['keeps inline code for a single-line selection', 'say hello there', 4, 9, null],
['keeps inline code for a single-line selection beside a block', '```\na\n```\nhello there', 10, 15, null],
['keeps inline code for a collapsed caret', 'a\nb', 1, 1, null],
['keeps inline code for one line inside a block', '```\na\nb\n```', 4, 5, null],
['declines a selection of newlines only', 'a\n\nb', 1, 3, null],
['declines part of a block body', '```\na\nb\nc\n```', 4, 7, null],
['declines a selection that ends inside a block', 'x\n```\na\nb\n```', 0, 7, null],
['declines a block whose opening line holds content rather than a language', '```a b\nc\nd\n```', 7, 10, null],
['declines a fenced block inside a quote', '> ```\n> a\n> b\n> ```', 6, 13, null],
['declines a block inside a spoiler that never closed', '||\n```\na\nb\n```', 7, 10, null],
['declines whitespace-only lines the parser would not render as a block', ' \n ', 0, 3, null],
['declines a selection across a block, text and another block', '```\na\n```\nmid\n```\nb\n```', 4, 19, null],
[
'wraps a multiline selection inside a block that is still open',
'```\na\nb',
4,
7,
{wrapped: false, text: '```\n```\na\nb\n```', selection: [8, 11]},
],
['wraps a whole two-line message', 'a\nb', 0, 3, {wrapped: false, text: '```\na\nb\n```', selection: [4, 7]}],
[
'wraps whole lines in the middle without inserting breaks',
'x\na\nb\ny',
2,
5,
{wrapped: false, text: 'x\n```\na\nb\n```\ny', selection: [6, 9]},
],
[
'inserts a break before the opening fence when the selection starts mid-line',
'hi a\nb',
3,
6,
{wrapped: false, text: 'hi \n```\na\nb\n```', selection: [8, 11]},
],
[
'inserts a break after the closing fence when the selection ends mid-line',
'a\nb bye',
0,
3,
{wrapped: false, text: '```\na\nb\n```\n bye', selection: [4, 7]},
],
[
'inserts a break at both edges when the selection starts and ends mid-line',
'hi a\nb bye',
3,
6,
{wrapped: false, text: 'hi \n```\na\nb\n```\n bye', selection: [8, 11]},
],
[
'keeps a selected trailing newline outside the block',
'a\nb\nc',
0,
4,
{wrapped: false, text: '```\na\nb\n```\nc', selection: [4, 7]},
],
[
'keeps a selected leading newline outside the block',
'x\na\nb',
1,
5,
{wrapped: false, text: 'x\n```\na\nb\n```', selection: [6, 9]},
],
[
'keeps several selected edge newlines outside the block',
'x\n\na\nb\n\ny',
1,
8,
{wrapped: false, text: 'x\n\n```\na\nb\n```\n\ny', selection: [7, 10]},
],
[
'treats one line with its selected line break as a block',
'foo\nbar',
0,
4,
{wrapped: false, text: '```\nfoo\n```\nbar', selection: [4, 7]},
],
[
'wraps the lines between two blocks',
'```\nA\n```\nB\nC\n```\nD\n```',
10,
13,
{wrapped: false, text: '```\nA\n```\n```\nB\nC\n```\n```\nD\n```', selection: [14, 17]},
],
[
'wraps a multiline selection of inline code that never closed',
'`a\nb`',
0,
5,
{wrapped: false, text: '```\n`a\nb`\n```', selection: [4, 9]},
],
[
'wraps two whole blocks in a longer fence',
'```\na\n```\n```\nb\n```',
0,
19,
{wrapped: false, text: '````\n```\na\n```\n```\nb\n```\n````', selection: [5, 24]},
],
[
'wraps content that already holds a fence in a longer fence',
'x\n```\ny\n```\nz',
0,
13,
{wrapped: false, text: '````\nx\n```\ny\n```\nz\n````', selection: [5, 18]},
],
[
'unwraps when the block content is selected',
'```\na\nb\n```',
4,
7,
{wrapped: true, text: 'a\nb', selection: [0, 3]},
],
[
'unwraps a block and leaves the text around it alone',
'x\n```\na\nb\n```\ny',
6,
9,
{wrapped: true, text: 'x\na\nb\ny', selection: [2, 5]},
],
[
'unwraps a block that is followed by a line the wrap kept outside',
'```\na\nb\n```\nc',
4,
7,
{wrapped: true, text: 'a\nb\nc', selection: [0, 3]},
],
[
'unwraps a block whose content is a single line',
'```\nfoo\n```\nbar',
4,
7,
{wrapped: true, text: 'foo\nbar', selection: [0, 3]},
],
[
'unwraps when the fences are selected too',
'```\na\nb\n```',
0,
11,
{wrapped: true, text: 'a\nb', selection: [0, 3]},
],
[
'unwraps when the fences and the newlines around them are selected',
'x\n```\na\nb\n```\ny',
1,
14,
{wrapped: true, text: 'x\na\nb\ny', selection: [2, 5]},
],
['unwraps a block with a language fence', '```js\na\nb\n```', 6, 9, {wrapped: true, text: 'a\nb', selection: [0, 3]}],
[
'unwraps a block that follows an escaped fence',
'\\```\n```\na\nb\n```',
9,
12,
{wrapped: true, text: '\\```\na\nb', selection: [5, 8]},
],
[
'unwraps a block written with a longer fence',
'````\nx\n```\ny\n```\nz\n````',
5,
18,
{wrapped: true, text: 'x\n```\ny\n```\nz', selection: [0, 13]},
],
[
'unwraps a block whose closing fence has trailing spaces',
'```\na\nb\n``` ',
4,
7,
{wrapped: true, text: 'a\nb', selection: [0, 3]},
],
['declines a block whose closing fence is longer than its opening fence', '```\na\nb\n`````', 4, 7, null],
['declines a block whose opening fence is followed by spaces', '``` \na\nb\n```', 6, 9, null],
];
const ROUND_TRIP_ROWS: Array<[text: string, start: number, end: number]> = [
['a\nb', 0, 3],
['x\na\nb\ny', 2, 5],
['a\nb\nc', 0, 4],
['x\na\nb', 1, 5],
['x\n\na\nb\n\ny', 1, 8],
['foo\nbar', 0, 4],
['```\nA\n```\nB\nC\n```\nD\n```', 10, 13],
['`a\nb`', 0, 5],
['```\na\n```\n```\nb\n```', 0, 19],
['x\n```\ny\n```\nz', 0, 13],
['```\na\nb', 4, 7],
['> a\n> b', 0, 7],
];
const PARSER_ROWS: Array<[text: string, start: number, end: number, content: string]> = [
['a\nb', 0, 3, 'a\nb\n'],
['hi a\nb bye', 3, 6, 'a\nb\n'],
['```\na\nb', 4, 7, 'a\nb\n'],
['x\n```\ny\n```\nz', 0, 13, 'x\n```\ny\n```\nz\n'],
['> a\n> b', 0, 7, '> a\n> b\n'],
];
function createComposer(): LexicalEditor {
const editor = createEditor({
namespace: 'code-block-wrap-test',
nodes: [
ComposerBlockquoteLineNode,
ComposerBlockquoteMarkerNode,
ComposerCustomEmojiNode,
ComposerMentionNode,
SyntaxMarkerNode,
],
onError: (error) => {
throw error;
},
});
registerComposerMarkdownHighlight(editor);
return editor;
}
function seeded(text: string, anchor: number, focus: number): LexicalEditor {
const editor = createComposer();
editor.update(
() => {
$replaceComposerRange(0, 0, {kind: 'text', text}, {leading: false, trailing: false});
$selectComposerRange(anchor, focus);
},
{discrete: true},
);
return editor;
}
function clickFormat(editor: LexicalEditor, wrapper: string): void {
editor.update(() => $wrapComposerSelection(wrapper, wrapper), {discrete: true});
}
function composerState(editor: LexicalEditor): {
text: string;
selection: {anchor: number; focus: number} | null;
pressed: boolean;
} {
return editor.read(() => ({
text: $getComposerDisplayText(),
selection: $captureSelectionOffsets(),
pressed: $isComposerSelectionWrapped('`', '`'),
}));
}
function atomicKeys(editor: LexicalEditor): Array<NodeKey> {
return editor.read(() =>
$getRoot()
.getFirstChildOrThrow<ElementNode>()
.getChildren()
.filter((node) => $isComposerMentionNode(node) || $isComposerCustomEmojiNode(node))
.map((node) => node.getKey()),
);
}
function atomicState(editor: LexicalEditor, keys: Array<NodeKey>): Array<{attached: boolean; literal: boolean} | null> {
return editor.read(() =>
keys.map((key) => {
const node = $getNodeByKey(key);
if (!$isComposerMentionNode(node) && !$isComposerCustomEmojiNode(node)) {
return null;
}
return {attached: node.isAttached(), literal: node.isLiteral()};
}),
);
}
function markerTexts(editor: LexicalEditor): Array<string> {
return editor.read(() =>
$getRoot()
.getAllTextNodes()
.filter($isSyntaxMarkerNode)
.map((node) => node.getTextContent()),
);
}
describe('planCodeBlockWrap', () => {
it.each(PLAN_ROWS)('%s', (_label, text, start, end, expected) => {
expect(planned(text, start, end)).toEqual(expected);
});
it.each(ROUND_TRIP_ROWS)('round trips %j selected from %i to %i', (text, start, end) => {
const wrapped = planned(text, start, end);
const unwrapped = wrapped == null ? null : planned(wrapped.text, ...wrapped.selection);
expect({wrapped: unwrapped?.wrapped, text: unwrapped?.text}).toEqual({wrapped: true, text});
});
it('keeps the breaks a mid-line wrap inserted when the block is unwrapped again', () => {
const wrapped = planned('hi a\nb bye', 3, 6);
expect(wrapped).toEqual({wrapped: false, text: 'hi \n```\na\nb\n```\n bye', selection: [8, 11]});
expect(wrapped == null ? null : planned(wrapped.text, ...wrapped.selection)).toEqual({
wrapped: true,
text: 'hi \na\nb\n bye',
selection: [4, 7],
});
});
});
function collectCodeBlocks(nodes: ReadonlyArray<Node>): Array<CodeBlockNode> {
return nodes.flatMap((node) => {
if (node.type === 'CodeBlock') {
return [node];
}
if (node.type === 'List') {
return node.items.flatMap((item) => collectCodeBlocks(item.children));
}
return 'children' in node ? collectCodeBlocks(node.children) : [];
});
}
function parsedCodeBlocks(text: string): Array<string> {
return collectCodeBlocks(parseMarkdownAstWithWasm(text, DEFAULT_COMPOSER_MARKDOWN_FLAGS).nodes).map(
(node) => node.content,
);
}
describe('planCodeBlockWrap against the markdown parser', () => {
it.each(PARSER_ROWS)('wraps %j from %i to %i into one block the parser renders', (text, start, end, content) => {
expect(parsedCodeBlocks(planned(text, start, end)?.text ?? '')).toEqual([content]);
});
});
describe('the composer code option', () => {
it('wraps a mid-line multiline selection in fence lines', () => {
const editor = seeded('hi a\nb bye', 3, 6);
clickFormat(editor, '`');
expect(composerState(editor)).toEqual({
text: 'hi \n```\na\nb\n```\n bye',
selection: {anchor: 8, focus: 11},
pressed: true,
});
expect(markerTexts(editor)).toEqual(['```', '```']);
});
it('restores the text when it is used twice on whole lines', () => {
const editor = seeded('x\na\nb\ny', 2, 5);
clickFormat(editor, '`');
expect(composerState(editor)).toEqual({
text: 'x\n```\na\nb\n```\ny',
selection: {anchor: 6, focus: 9},
pressed: true,
});
clickFormat(editor, '`');
expect(composerState(editor)).toEqual({text: 'x\na\nb\ny', selection: {anchor: 2, focus: 5}, pressed: false});
});
it('keeps a backward selection backward through both clicks', () => {
const editor = seeded('a\nb', 3, 0);
clickFormat(editor, '`');
expect(composerState(editor)).toEqual({
text: '```\na\nb\n```',
selection: {anchor: 7, focus: 4},
pressed: true,
});
clickFormat(editor, '`');
expect(composerState(editor)).toEqual({text: 'a\nb', selection: {anchor: 3, focus: 0}, pressed: false});
});
it('unwraps a block whose fences are selected as well', () => {
const editor = seeded('```\na\nb\n```', 0, 11);
expect(composerState(editor).pressed).toBe(true);
clickFormat(editor, '`');
expect(composerState(editor)).toEqual({text: 'a\nb', selection: {anchor: 0, focus: 3}, pressed: false});
});
it('unwraps a block with a language fence', () => {
const editor = seeded('```js\na\nb\n```', 6, 9);
expect(composerState(editor).pressed).toBe(true);
clickFormat(editor, '`');
expect(composerState(editor)).toEqual({text: 'a\nb', selection: {anchor: 0, focus: 3}, pressed: false});
});
it('still writes inline code for a single-line selection', () => {
const editor = seeded('say hello there', 4, 9);
clickFormat(editor, '`');
expect(composerState(editor)).toEqual({
text: 'say `hello` there',
selection: {anchor: 5, focus: 10},
pressed: true,
});
});
it('still leaves other wrappers symmetric on a multiline selection', () => {
const editor = seeded('a\nb', 0, 3);
clickFormat(editor, '**');
expect(composerState(editor)).toEqual({text: '**a\nb**', selection: {anchor: 2, focus: 5}, pressed: false});
});
it('wraps a multiline selection inside a block that is still open', () => {
const editor = seeded('```\na\nb', 4, 7);
clickFormat(editor, '`');
expect(composerState(editor)).toEqual({
text: '```\n```\na\nb\n```',
selection: {anchor: 8, focus: 11},
pressed: true,
});
});
it('makes no change for a multiline selection inside a block', () => {
const editor = seeded('```\na\nb\nc\n```', 4, 7);
clickFormat(editor, '`');
expect(composerState(editor)).toEqual({
text: '```\na\nb\nc\n```',
selection: {anchor: 4, focus: 7},
pressed: false,
});
});
it('makes no change for a multiline selection inside a quoted block', () => {
const editor = seeded('> ```\n> a\n> b\n> ```', 6, 13);
clickFormat(editor, '`');
expect(composerState(editor)).toEqual({
text: '> ```\n> a\n> b\n> ```',
selection: {anchor: 6, focus: 13},
pressed: false,
});
});
it('makes no change for whitespace-only lines however often it is used', () => {
const editor = seeded(' \n ', 0, 3);
clickFormat(editor, '`');
clickFormat(editor, '`');
expect(composerState(editor)).toEqual({text: ' \n ', selection: {anchor: 0, focus: 3}, pressed: false});
});
it('removes single backticks left around a multiline selection', () => {
const editor = seeded('`a\nb`', 1, 4);
expect(composerState(editor).pressed).toBe(true);
clickFormat(editor, '`');
expect(composerState(editor)).toEqual({text: 'a\nb', selection: {anchor: 0, focus: 3}, pressed: false});
});
it('undoes the unwrap in one step', async () => {
const editor = createComposer();
const history = createEmptyHistoryState();
registerHistory(editor, history, 300);
editor.update(
() => {
$replaceComposerRange(0, 0, {kind: 'text', text: '```\na\nb\n```'}, {leading: false, trailing: false});
$selectComposerRange(4, 7);
},
{discrete: true},
);
clickFormat(editor, '`');
expect(composerState(editor)).toEqual({text: 'a\nb', selection: {anchor: 0, focus: 3}, pressed: false});
expect(history.undoStack).toHaveLength(1);
editor.dispatchCommand(UNDO_COMMAND, undefined);
await Promise.resolve();
expect(composerState(editor)).toEqual({
text: '```\na\nb\n```',
selection: {anchor: 4, focus: 7},
pressed: true,
});
});
it('keeps mention and emoji nodes when it wraps and unwraps them', () => {
const editor = createComposer();
editor.update(
() => {
$replaceComposerRange(
0,
0,
{kind: 'mention', mentionType: 'user', id: '1', display: '@alice', wire: '<@1>'},
{trailing: false},
);
$replaceComposerRange(6, 6, {kind: 'text', text: '\n'}, {leading: false, trailing: false});
$replaceComposerRange(
7,
7,
{kind: 'customEmoji', emojiId: '2', animated: false, display: ':blob:', wire: '<:blob:2>'},
{trailing: false},
);
$selectComposerRange(0, 13);
},
{discrete: true},
);
const keys = atomicKeys(editor);
expect(atomicState(editor, keys)).toEqual([
{attached: true, literal: false},
{attached: true, literal: false},
]);
clickFormat(editor, '`');
expect(composerState(editor)).toEqual({
text: '```\n@alice\n:blob:\n```',
selection: {anchor: 4, focus: 17},
pressed: true,
});
expect(atomicState(editor, keys)).toEqual([
{attached: true, literal: true},
{attached: true, literal: true},
]);
clickFormat(editor, '`');
expect(composerState(editor)).toEqual({
text: '@alice\n:blob:',
selection: {anchor: 0, focus: 13},
pressed: false,
});
expect(atomicState(editor, keys)).toEqual([
{attached: true, literal: false},
{attached: true, literal: false},
]);
});
it('reads a mention as filler so backticks in its name cannot hide the block', () => {
const editor = createComposer();
editor.update(
() => {
$replaceComposerRange(0, 0, {kind: 'text', text: '```\n'}, {leading: false, trailing: false});
$replaceComposerRange(
4,
4,
{kind: 'mention', mentionType: 'user', id: '1', display: '@```', wire: '<@1>'},
{trailing: false},
);
$replaceComposerRange(8, 8, {kind: 'text', text: '\n```'}, {leading: false, trailing: false});
$selectComposerRange(4, 8);
},
{discrete: true},
);
expect(composerState(editor).pressed).toBe(true);
clickFormat(editor, '`');
expect(composerState(editor)).toEqual({text: '@```', selection: {anchor: 0, focus: 4}, pressed: false});
});
it('undoes the wrap in one step and redoes it', async () => {
const editor = createComposer();
const history = createEmptyHistoryState();
registerHistory(editor, history, 300);
editor.update(
() => {
$replaceComposerRange(0, 0, {kind: 'text', text: 'hi a\nb bye'}, {leading: false, trailing: false});
$selectComposerRange(3, 6);
},
{discrete: true},
);
clickFormat(editor, '`');
expect(history.undoStack).toHaveLength(1);
editor.dispatchCommand(UNDO_COMMAND, undefined);
await Promise.resolve();
expect(composerState(editor)).toEqual({
text: 'hi a\nb bye',
selection: {anchor: 3, focus: 6},
pressed: false,
});
editor.dispatchCommand(REDO_COMMAND, undefined);
await Promise.resolve();
expect(composerState(editor)).toEqual({
text: 'hi \n```\na\nb\n```\n bye',
selection: {anchor: 8, focus: 11},
pressed: true,
});
});
});
@@ -0,0 +1,138 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type CodeBlockBody,
FENCE_LANGUAGE_RE,
hasVisibleContent,
scanCodeBlocks,
} from '@app/features/lexical/composer/codeBlockIndent';
const MIN_FENCE_LENGTH = 3;
interface CodeBlockWrapEdit {
start: number;
end: number;
text: string;
}
export interface CodeBlockWrapPlan {
wrapped: boolean;
opening: CodeBlockWrapEdit;
closing: CodeBlockWrapEdit;
selectionStart: number;
selectionEnd: number;
}
interface FencedBlock {
start: number;
contentStart: number;
contentEnd: number;
end: number;
}
function isLineStart(text: string, offset: number): boolean {
return offset === 0 || text[offset - 1] === '\n';
}
function isLineEnd(text: string, offset: number): boolean {
return offset === text.length || text[offset] === '\n';
}
function openingFenceFor(text: string, offset: number): string {
let start = offset;
while (text[start - 1] === '`') {
start -= 1;
}
return text.slice(start, offset);
}
function closingFenceEnd(text: string, offset: number, fence: string): number {
let end = offset + fence.length;
if (text.slice(offset, end) !== fence) {
return -1;
}
while (text[end] === ' ' || text[end] === '\t' || text[end] === '\r') {
end += 1;
}
return isLineEnd(text, end) ? end : -1;
}
function isInsideClosedBlock(text: string, bodies: ReadonlyArray<CodeBlockBody>, offset: number): boolean {
return bodies.some(
(body) =>
body.start <= offset && offset <= body.end && text.startsWith(openingFenceFor(text, body.start), body.end),
);
}
function findFencedBlocks(text: string, bodies: ReadonlyArray<CodeBlockBody>): Array<FencedBlock> {
return bodies.flatMap((body) => {
const fence = openingFenceFor(text, body.start);
const start = body.start - fence.length;
const openingEnd = text.indexOf('\n', body.start);
const info = text.slice(body.start, openingEnd);
const end = closingFenceEnd(text, body.end, fence);
const fenced =
body.rendered &&
end >= 0 &&
isLineStart(text, start) &&
text[body.end - 1] === '\n' &&
(info === '' || FENCE_LANGUAGE_RE.test(info));
return fenced ? [{start, contentStart: openingEnd + 1, contentEnd: body.end - 1, end}] : [];
});
}
function fenceFor(content: string): string {
let length = MIN_FENCE_LENGTH;
for (const [run] of content.matchAll(/`+/g)) {
length = Math.max(length, run.length + 1);
}
return '`'.repeat(length);
}
export function planCodeBlockWrap(text: string, start: number, end: number): CodeBlockWrapPlan | null {
let contentStart = start;
while (contentStart < end && text[contentStart] === '\n') {
contentStart += 1;
}
let contentEnd = end;
while (contentEnd > contentStart && text[contentEnd - 1] === '\n') {
contentEnd -= 1;
}
if (contentStart === contentEnd) {
return null;
}
const bodies = scanCodeBlocks(text);
const block = findFencedBlocks(text, bodies).find(
(candidate) =>
(candidate.contentStart === contentStart && candidate.contentEnd === contentEnd) ||
(candidate.start === contentStart && candidate.end === contentEnd),
);
if (block != null) {
return {
wrapped: true,
opening: {start: block.start, end: block.contentStart, text: ''},
closing: {start: block.contentEnd, end: block.end, text: ''},
selectionStart: block.start,
selectionEnd: block.start + block.contentEnd - block.contentStart,
};
}
const content = text.slice(contentStart, contentEnd);
if (
!text.slice(start, end).includes('\n') ||
!hasVisibleContent(content) ||
isInsideClosedBlock(text, bodies, contentStart) ||
isInsideClosedBlock(text, bodies, contentEnd)
) {
return null;
}
const fence = fenceFor(content);
const opening = `${isLineStart(text, contentStart) ? '' : '\n'}${fence}\n`;
const closing = `\n${fence}${isLineEnd(text, contentEnd) ? '' : '\n'}`;
return {
wrapped: false,
opening: {start: contentStart, end: contentStart, text: opening},
closing: {start: contentEnd, end: contentEnd, text: closing},
selectionStart: contentStart + opening.length,
selectionEnd: contentEnd + opening.length,
};
}
@@ -1,12 +1,26 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {$createComposerCustomEmojiNode} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode';
import type {BlockquoteLine} from '@app/features/lexical/composer/blockquoteLines';
import {type CodeBlockWrapPlan, planCodeBlockWrap} from '@app/features/lexical/composer/codeBlockWrap';
import {$isComposerBlockquoteLineNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
import {$isComposerBlockquoteMarkerNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteMarkerNode';
import {
$createComposerCustomEmojiNode,
$isComposerCustomEmojiNode,
} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode';
import {
$createComposerMentionNode,
$isComposerMentionNode,
type ComposerMentionType,
} from '@app/features/lexical/composer/nodes/ComposerMentionNode';
import {$createComposerPlainSegmentNode} from '@app/features/lexical/composer/nodes/ComposerPlainSegmentNode';
import {$createComposerStandardEmojiNode} from '@app/features/lexical/composer/nodes/ComposerStandardEmojiNode';
import {
$createComposerPlainSegmentNode,
$isComposerPlainSegmentNode,
} from '@app/features/lexical/composer/nodes/ComposerPlainSegmentNode';
import {
$createComposerStandardEmojiNode,
$isComposerStandardEmojiNode,
} from '@app/features/lexical/composer/nodes/ComposerStandardEmojiNode';
import {$isSyntaxMarkerNode} from '@app/features/lexical/composer/nodes/SyntaxMarkerNode';
import {
$createParagraphNode,
@@ -188,6 +202,41 @@ function $captureSelectionOffsetsFromLayout(layout: DisplayLayout): ComposerSele
};
}
export interface ComposerBlockquoteState {
scanText: string;
selection: ComposerSelectionOffsets | null;
lines: Array<BlockquoteLine>;
}
function blockquoteLinesFromLayout(layout: DisplayLayout): Array<BlockquoteLine> {
const lines: Array<BlockquoteLine> = [];
for (const leaf of layout.leaves) {
const parent = leaf.node.getParent();
if (
!$isComposerBlockquoteMarkerNode(leaf.node) ||
!$isComposerBlockquoteLineNode(parent) ||
!leaf.node.is(parent.getFirstChild())
) {
continue;
}
const boundaries = layout.elementBoundaries.get(parent.getKey());
if (boundaries == null) {
continue;
}
lines.push({start: leaf.start, contentStart: leaf.end, end: boundaries[boundaries.length - 1]!});
}
return lines;
}
export function $getComposerBlockquoteState(): ComposerBlockquoteState {
const layout = $buildDisplayLayout();
return {
scanText: scanTextFromLayout(layout),
selection: $captureSelectionOffsetsFromLayout(layout),
lines: blockquoteLinesFromLayout(layout),
};
}
export function $getComposerSelectionRange(): {start: number; end: number} | null {
const offsets = $captureSelectionOffsets();
if (offsets == null) {
@@ -209,6 +258,27 @@ export function $getComposerDisplayText(): string {
return $buildDisplayLayout().text;
}
function scanTextFromLayout({text, leaves}: DisplayLayout): string {
let scanText = '';
let offset = 0;
for (const {node, start, end} of leaves) {
if (
$isComposerMentionNode(node) ||
$isComposerCustomEmojiNode(node) ||
$isComposerStandardEmojiNode(node) ||
$isComposerPlainSegmentNode(node)
) {
scanText += text.slice(offset, start) + 'x'.repeat(end - start);
offset = end;
}
}
return scanText + text.slice(offset);
}
export function $getComposerScanText(): string {
return scanTextFromLayout($buildDisplayLayout());
}
function $pointAtDisplayOffset(layout: DisplayLayout, offset: number): DisplayPoint {
const clampedOffset = Math.min(Math.max(0, offset), layout.text.length);
for (const leaf of layout.leaves) {
@@ -400,6 +470,23 @@ function classifyWrap(display: string, start: number, end: number, prefix: strin
return 'none';
}
function codeBlockPlanFor(
layout: DisplayLayout,
start: number,
end: number,
prefix: string,
suffix: string,
): CodeBlockWrapPlan | null {
if (prefix !== '`' || suffix !== '`') {
return null;
}
const plan = planCodeBlockWrap(scanTextFromLayout(layout), start, end);
if (plan != null && !plan.wrapped && classifyWrap(layout.text, start, end, prefix, suffix) === 'flank') {
return null;
}
return plan;
}
export function $isComposerSelectionWrapped(prefix: string, suffix: string): boolean {
const wrapped = $queryComposerSelectionWrappers([{prefix, suffix}]).wrapped[0];
return wrapped == null ? false : wrapped;
@@ -420,7 +507,10 @@ export function $queryComposerSelectionWrappers(
}
return {
offsets,
wrapped: queries.map(({prefix, suffix}) => classifyWrap(layout.text, start, end, prefix, suffix) !== 'none'),
wrapped: queries.map(({prefix, suffix}) => {
const plan = codeBlockPlanFor(layout, start, end, prefix, suffix);
return plan == null ? classifyWrap(layout.text, start, end, prefix, suffix) !== 'none' : plan.wrapped;
}),
};
}
@@ -441,6 +531,13 @@ export function $wrapComposerSelection(prefix: string, suffix: string): void {
const restoreSelection = (nextStart: number, nextEnd: number) => {
$selectComposerRange(backward ? nextEnd : nextStart, backward ? nextStart : nextEnd);
};
const plan = codeBlockPlanFor(layout, start, end, prefix, suffix);
if (plan != null) {
replaceTextRange(plan.closing.start, plan.closing.end, plan.closing.text);
replaceTextRange(plan.opening.start, plan.opening.end, plan.opening.text);
restoreSelection(plan.selectionStart, plan.selectionEnd);
return;
}
switch (classifyWrap(display, start, end, prefix, suffix)) {
case 'inside': {
replaceTextRange(end - suffix.length, end, '');
@@ -455,6 +552,9 @@ export function $wrapComposerSelection(prefix: string, suffix: string): void {
return;
}
default: {
if (prefix === '`' && suffix === '`' && selected.includes('\n')) {
return;
}
replaceTextRange(end, end, suffix);
replaceTextRange(start, start, prefix);
selected.length === 0
@@ -6,6 +6,7 @@ import {parseMarkdownAstWithWasm} from '@app/features/messaging/utils/markdown/p
import type {Node} from '@app/features/messaging/utils/markdown/parser/Nodes';
import {normalizeUrl} from '@app/features/messaging/utils/markdown/parser/UrlUtils';
import {findUrlEnd} from '@app/features/messaging/utils/markdown/UrlSpanUtils';
import {parseSilentMessagePrefix} from '@app/features/messaging/utils/SilentMessagePrefix';
export const MarkdownHl = {
none: 0,
@@ -20,6 +21,8 @@ export const MarkdownHl = {
subtext: 1 << 8,
link: 1 << 9,
codeBlock: 1 << 10,
silent: 1 << 11,
blockquoteMarker: 1 << 12,
} as const;
export type MarkdownHlFormat = number;
@@ -123,6 +126,24 @@ export function computeMarkdownHighlightResult(
return {spans: coalesce(spans), recovered};
}
export function markSilentMessagePrefix(spans: Array<MarkdownSpan>, source: string): Array<MarkdownSpan> {
const prefix = parseSilentMessagePrefix(source);
if (prefix == null) {
return spans;
}
const pieces: Array<MarkdownSpan> = [];
for (const span of spans) {
const tokenStart = Math.min(Math.max(prefix.tokenStart, span.start), span.end);
const tokenEnd = Math.min(Math.max(prefix.tokenEnd, span.start), span.end);
pieces.push(
{...span, end: tokenStart},
{...span, start: tokenStart, end: tokenEnd, format: span.format | MarkdownHl.silent},
{...span, start: tokenEnd},
);
}
return coalesce(pieces);
}
function recoverRange(
spans: Array<MarkdownSpan>,
recovered: Array<MarkdownRecoveredRange>,
@@ -638,7 +659,7 @@ function trimCodeFenceInfo(value: string): string {
return value.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, '');
}
function findCodeBlockClosing(
export function findCodeBlockClosing(
line: string,
fence: string,
fenceLength: number,
@@ -773,20 +794,21 @@ function alignBlockquote(children: Array<Node>, format: MarkdownHlFormat, ctx: A
while (ctx.source[trimmedStart] === ' ' || ctx.source[trimmedStart] === '\t' || ctx.source[trimmedStart] === '\r') {
trimmedStart += 1;
}
const markerRanges: Array<{start: number; end: number}> = [];
const markerRanges: Array<{start: number; end: number; quote: boolean}> = [];
const blankRanges: Array<{start: number; end: number}> = [];
const sourceOffsets: Array<number> = [];
let virtualSource = '';
let blockEnd = start;
if (ctx.source.startsWith('>>> ', trimmedStart)) {
const contentStart = trimmedStart + 4;
markerRanges.push({start, end: contentStart});
markerRanges.push({start, end: contentStart, quote: true});
virtualSource = ctx.source.slice(contentStart);
for (let position = contentStart; position < ctx.source.length; position += 1) {
sourceOffsets.push(position);
}
blockEnd = ctx.source.length;
} else {
const lines: Array<{start: number; end: number; contentStart: number}> = [];
const lines: Array<{start: number; end: number; prefixEnd: number; contentStart: number}> = [];
let lineStart = start;
while (lineStart <= ctx.source.length) {
const newline = ctx.source.indexOf('\n', lineStart);
@@ -800,7 +822,7 @@ function alignBlockquote(children: Array<Node>, format: MarkdownHlFormat, ctx: A
}
const rest = ctx.source.slice(quoteStart + 2, lineEnd);
const contentStart = /^[ \t\r]*$/.test(rest) ? lineEnd : quoteStart + 2;
lines.push({start: lineStart, end: lineEnd, contentStart});
lines.push({start: lineStart, end: lineEnd, prefixEnd: quoteStart + 2, contentStart});
if (newline < 0) {
break;
}
@@ -812,10 +834,13 @@ function alignBlockquote(children: Array<Node>, format: MarkdownHlFormat, ctx: A
}
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index]!;
markerRanges.push({start: line.start, end: line.contentStart});
markerRanges.push({start: line.start, end: line.prefixEnd, quote: true});
if (line.contentStart > line.prefixEnd) {
blankRanges.push({start: line.prefixEnd, end: line.contentStart});
}
let repeatedPrefix = line.contentStart;
while (ctx.source.startsWith('> ', repeatedPrefix) && repeatedPrefix < line.end) {
markerRanges.push({start: repeatedPrefix, end: repeatedPrefix + 2});
markerRanges.push({start: repeatedPrefix, end: repeatedPrefix + 2, quote: false});
repeatedPrefix += 2;
}
for (let position = line.contentStart; position < line.end; position += 1) {
@@ -870,12 +895,18 @@ function alignBlockquote(children: Array<Node>, format: MarkdownHlFormat, ctx: A
return;
}
roles[sourceOffset - start] = span.role;
formats[sourceOffset - start] = span.format;
formats[sourceOffset - start] = span.format & ~MarkdownHl.blockquoteMarker;
}
}
for (const range of markerRanges) {
for (let position = range.start; position < range.end; position += 1) {
roles[position - start] = 'marker';
formats[position - start] = range.quote ? format | MarkdownHl.blockquoteMarker : format;
}
}
for (const range of blankRanges) {
for (let position = range.start; position < range.end; position += 1) {
roles[position - start] = 'content';
formats[position - start] = format;
}
}
@@ -913,7 +944,7 @@ function alignAlert(format: MarkdownHlFormat, ctx: AlignContext): void {
}
const newline = ctx.source.indexOf('\n', ctx.pos);
const lineEnd = newline < 0 ? ctx.source.length : newline;
pushMarker(ctx, quoteStart + 2 - ctx.pos, bodyFormat);
pushMarker(ctx, quoteStart + 2 - ctx.pos, bodyFormat | MarkdownHl.blockquoteMarker);
if (firstLine) {
const close = ctx.source.indexOf(']', ctx.pos);
if (!ctx.source.startsWith('[!', ctx.pos) || close < 0 || close >= lineEnd) {
@@ -0,0 +1,125 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
$createComposerBlockquoteLineNode,
$getComposerLineNodes,
$isComposerBlockquoteLineNode,
ComposerBlockquoteLineNode,
} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
import {
$createParagraphNode,
$createRangeSelection,
$createTextNode,
$getRoot,
createEditor,
type EditorConfig,
type LexicalEditor,
} from 'lexical';
import {describe, expect, it} from 'vitest';
const CONFIG: EditorConfig = {namespace: 'blockquote-line-test', theme: {composerBlockquoteLine: 'quote-line'}};
function createTestEditor(): LexicalEditor {
return createEditor({
namespace: 'blockquote-line-test',
nodes: [ComposerBlockquoteLineNode],
onError: (error) => {
throw error;
},
});
}
describe('ComposerBlockquoteLineNode', () => {
it('creates a span carrying the theme class', () => {
const editor = createTestEditor();
let dom: HTMLElement | null = null;
editor.update(
() => {
dom = $createComposerBlockquoteLineNode().createDOM(CONFIG);
},
{discrete: true},
);
expect(dom).not.toBeNull();
expect(dom!.tagName).toBe('SPAN');
expect(dom!.className).toBe('quote-line');
});
it('is an inline node that cannot be left empty', () => {
const editor = createTestEditor();
let shape: {inline: boolean; canBeEmpty: boolean} | null = null;
editor.update(
() => {
const wrapper = $createComposerBlockquoteLineNode();
shape = {inline: wrapper.isInline(), canBeEmpty: wrapper.canBeEmpty()};
},
{discrete: true},
);
expect(shape).toEqual({inline: true, canBeEmpty: false});
});
it('inserts an attached empty sibling wrapper on insertNewAfter', () => {
const editor = createTestEditor();
let result: {isWrapper: boolean; attached: boolean; children: number; isNextSibling: boolean} | null = null;
editor.update(
() => {
const paragraph = $createParagraphNode();
const wrapper = $createComposerBlockquoteLineNode();
wrapper.append($createTextNode('a'));
paragraph.append(wrapper);
$getRoot().clear().append(paragraph);
const next = wrapper.insertNewAfter($createRangeSelection(), false);
result = {
isWrapper: $isComposerBlockquoteLineNode(next),
attached: next.isAttached(),
children: next.getChildrenSize(),
isNextSibling: next.is(wrapper.getNextSibling()),
};
},
{discrete: true},
);
expect(result).toEqual({isWrapper: true, attached: true, children: 0, isNextSibling: true});
});
});
describe('$getComposerLineNodes', () => {
it('expands wrappers in document order and leaves other children alone', () => {
const editor = createTestEditor();
let texts: Array<string> = [];
let keysMatch = false;
editor.update(
() => {
const paragraph = $createParagraphNode();
const lead = $createTextNode('a');
const wrapper = $createComposerBlockquoteLineNode();
const marker = $createTextNode('> ');
const body = $createTextNode('b');
wrapper.append(marker, body);
const tail = $createTextNode('c');
paragraph.append(lead, wrapper, tail);
$getRoot().clear().append(paragraph);
const nodes = $getComposerLineNodes(paragraph);
texts = nodes.map((node) => node.getTextContent());
keysMatch = nodes.every((node, index) => node.is([lead, marker, body, tail][index]));
},
{discrete: true},
);
expect(texts).toEqual(['a', '> ', 'b', 'c']);
expect(keysMatch).toBe(true);
});
it('returns the children unchanged when there is no wrapper', () => {
const editor = createTestEditor();
let texts: Array<string> = [];
editor.update(
() => {
const paragraph = $createParagraphNode();
paragraph.append($createTextNode('a'), $createTextNode('b'));
$getRoot().clear().append(paragraph);
texts = $getComposerLineNodes(paragraph).map((node) => node.getTextContent());
},
{discrete: true},
);
expect(texts).toEqual(['a', 'b']);
});
});
@@ -0,0 +1,73 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
$applyNodeReplacement,
type EditorConfig,
ElementNode,
type LexicalNode,
type RangeSelection,
type SerializedElementNode,
} from 'lexical';
export class ComposerBlockquoteLineNode extends ElementNode {
static override getType(): string {
return 'composer-blockquote-line';
}
static override clone(node: ComposerBlockquoteLineNode): ComposerBlockquoteLineNode {
return new ComposerBlockquoteLineNode(node.__key);
}
static override importJSON(serializedNode: SerializedElementNode): ComposerBlockquoteLineNode {
return $createComposerBlockquoteLineNode().updateFromJSON(serializedNode);
}
override createDOM(config: EditorConfig): HTMLElement {
const span = document.createElement('span');
const className = config.theme.composerBlockquoteLine;
if (typeof className === 'string') {
span.className = className;
}
return span;
}
override updateDOM(): boolean {
return false;
}
override isInline(): true {
return true;
}
override canBeEmpty(): false {
return false;
}
override insertNewAfter(_selection: RangeSelection, restoreSelection = true): ComposerBlockquoteLineNode {
const next = $createComposerBlockquoteLineNode();
this.insertAfter(next, restoreSelection);
return next;
}
}
export function $createComposerBlockquoteLineNode(): ComposerBlockquoteLineNode {
return $applyNodeReplacement(new ComposerBlockquoteLineNode());
}
export function $isComposerBlockquoteLineNode(
node: LexicalNode | null | undefined,
): node is ComposerBlockquoteLineNode {
return node instanceof ComposerBlockquoteLineNode;
}
export function $getComposerLineNodes(element: ElementNode): Array<LexicalNode> {
const nodes: Array<LexicalNode> = [];
for (const child of element.getChildren()) {
if ($isComposerBlockquoteLineNode(child)) {
nodes.push(...$getComposerLineNodes(child));
} else {
nodes.push(child);
}
}
return nodes;
}
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {SyntaxMarkerNode} from '@app/features/lexical/composer/nodes/SyntaxMarkerNode';
import type {EditorConfig, LexicalNode, SerializedTextNode} from 'lexical';
export class ComposerBlockquoteMarkerNode extends SyntaxMarkerNode {
static override getType(): string {
return 'composer-blockquote-marker';
}
static override clone(node: ComposerBlockquoteMarkerNode): ComposerBlockquoteMarkerNode {
return new ComposerBlockquoteMarkerNode(node.__text, node.__key);
}
static override importJSON(serializedNode: SerializedTextNode): ComposerBlockquoteMarkerNode {
return $createComposerBlockquoteMarkerNode(serializedNode.text).updateFromJSON(serializedNode);
}
override createDOM(config: EditorConfig): HTMLElement {
const dom = super.createDOM(config);
const className = config.theme.composerBlockquoteMarker;
if (typeof className === 'string') {
dom.classList.add(className);
}
return dom;
}
override canInsertTextBefore(): false {
return false;
}
override canInsertTextAfter(): false {
return false;
}
}
export function $createComposerBlockquoteMarkerNode(text: string): ComposerBlockquoteMarkerNode {
return new ComposerBlockquoteMarkerNode(text);
}
export function $isComposerBlockquoteMarkerNode(
node: LexicalNode | null | undefined,
): node is ComposerBlockquoteMarkerNode {
return node instanceof ComposerBlockquoteMarkerNode;
}
@@ -0,0 +1,173 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {DEFAULT_COMPOSER_MARKDOWN_FLAGS} from '@app/features/lexical/composer/markdownSpans';
import {
areSpecialMentionsAllowed,
createSpecialMentionPayload,
findTypedSpecialMention,
hasOpenCodeFence,
isSpecialMentionKind,
type SpecialMentionKind,
type TypedSpecialMention,
} from '@app/features/lexical/composer/specialMentions';
import {parseMarkdownAstWithWasm} from '@app/features/messaging/utils/markdown/parser/MarkdownParserWasm';
import {describe, expect, it} from 'vitest';
const GUILD_CHANNEL = {guildId: '1', isPersonalNotes: () => false};
const DM_CHANNEL = {guildId: undefined, isPersonalNotes: () => false};
const PERSONAL_NOTES = {guildId: undefined, isPersonalNotes: () => true};
function typedSpecialMentions(text: string): Array<SpecialMentionKind> {
const kinds: Array<SpecialMentionKind> = [];
let match = findTypedSpecialMention(text, 0, text.length, null);
while (match != null) {
kinds.push(match.kind);
match = findTypedSpecialMention(text, match.end, text.length, null);
}
return kinds;
}
function collectParsedSpecialMentions(value: unknown, kinds: Array<SpecialMentionKind>): Array<SpecialMentionKind> {
if (Array.isArray(value)) {
for (const item of value) {
collectParsedSpecialMentions(item, kinds);
}
} else if (typeof value === 'object' && value != null) {
if ('type' in value && value.type === 'Mention' && 'kind' in value) {
const mention = JSON.stringify(value.kind);
if (mention === '{"kind":"Everyone"}') {
kinds.push('@everyone');
} else if (mention === '{"kind":"Here"}') {
kinds.push('@here');
}
}
for (const child of Object.values(value)) {
collectParsedSpecialMentions(child, kinds);
}
}
return kinds;
}
describe('findTypedSpecialMention', () => {
it.each<[string, string, number | null, TypedSpecialMention]>([
['@everyone followed by a space', '@everyone ', 10, {start: 0, end: 9, kind: '@everyone'}],
['@here followed by a space', '@here ', 6, {start: 0, end: 5, kind: '@here'}],
['a finished token with no caret', 'hi @everyone', null, {start: 3, end: 12, kind: '@everyone'}],
['a finished token with the caret before it', 'hi @everyone', 0, {start: 3, end: 12, kind: '@everyone'}],
['a finished token with the caret inside it', '@here', 3, {start: 0, end: 5, kind: '@here'}],
['a token inside parentheses', '(@everyone)', null, {start: 1, end: 10, kind: '@everyone'}],
['a token inside bold markers', '**@everyone** ', 14, {start: 2, end: 11, kind: '@everyone'}],
['a token inside underline markers', '__@here__ ', 10, {start: 2, end: 7, kind: '@here'}],
['a token followed by an apostrophe', "@here's ", 8, {start: 0, end: 5, kind: '@here'}],
['a token followed by an underscore', '@here_now ', null, {start: 0, end: 5, kind: '@here'}],
['a token after a second @', '@@everyone ', null, {start: 1, end: 10, kind: '@everyone'}],
['a token after a mention wire', '<@1>@here ', null, {start: 4, end: 9, kind: '@here'}],
['a token after closed inline code', '`x` @everyone ', null, {start: 4, end: 13, kind: '@everyone'}],
['a token after a closed fence', '```\ncode\n```\n@everyone ', null, {start: 13, end: 22, kind: '@everyone'}],
['a token on a new line after an inline code line', '`x`\n@here ', null, {start: 4, end: 9, kind: '@here'}],
])('converts %s', (_name, text, caret, expected) => {
expect(findTypedSpecialMention(text, 0, text.length, caret)).toEqual(expected);
});
it.each<[string, string, number | null]>([
['a token whose end is the caret', 'hi @everyone', 12],
['a token joined to a following word', '@herenow ', 9],
['a token with a trailing letter', '@everyones ', 11],
['a token with a leading word', 'foo@everyone ', 13],
['a token inside an email address', 'mail [email protected] ', null],
['a token touching a digit', '1@here 2', null],
['a token touching a non-latin letter', 'café@here ', null],
['a token after one backslash', '\\@everyone ', null],
['a token after two backslashes', '\\\\@everyone ', null],
['a token inside open inline code', '`@everyone ', null],
['a token inside double backtick code', '`` @everyone ``', null],
['a token inside an open fence', '```\n@everyone \n', null],
['a token inside a url', 'https://fluxer.app/@everyone ', null],
['a spaced @ and word', '@ everyone ', null],
])('leaves %s alone', (_name, text, caret) => {
expect(findTypedSpecialMention(text, 0, text.length, caret)).toBeNull();
});
it('only considers tokens that start at from and end by to', () => {
expect(findTypedSpecialMention('@everyone and @here ', 10, 20, 20)).toEqual({start: 14, end: 19, kind: '@here'});
expect(findTypedSpecialMention('@everyone ', 0, 5, null)).toBeNull();
});
});
describe('findTypedSpecialMention against the markdown parser', () => {
it.each<[string, Array<SpecialMentionKind>, Array<SpecialMentionKind>]>([
['@everyone ', ['@everyone'], ['@everyone']],
['@here ', ['@here'], ['@here']],
['(@everyone)', ['@everyone'], ['@everyone']],
['**@everyone** ', ['@everyone'], ['@everyone']],
['__@here__ ', ['@here'], ['@here']],
["@here's ", ['@here'], ['@here']],
['@here_now ', ['@here'], ['@here']],
['@@everyone ', ['@everyone'], ['@everyone']],
['<@1>@here ', ['@here'], ['@here']],
['`x` @everyone ', ['@everyone'], ['@everyone']],
['```\ncode\n```\n@everyone ', ['@everyone'], ['@everyone']],
['\\@everyone ', [], []],
['\\\\@everyone ', [], []],
['`` @everyone ``', [], []],
['https://fluxer.app/@everyone ', [], []],
['@ everyone ', [], []],
['@herenow ', [], ['@here']],
['@everyones ', [], ['@everyone']],
['foo@everyone ', [], ['@everyone']],
['mail [email protected] ', [], ['@here']],
['café@here ', [], ['@here']],
['@everyone@here', ['@everyone'], ['@everyone', '@here']],
['`@everyone ', [], ['@everyone']],
['```\n@everyone \n', [], ['@everyone']],
])('%j converts %j where the parser pings %j', (text, typed, parsed) => {
expect(typedSpecialMentions(text)).toEqual(typed);
expect(
collectParsedSpecialMentions(parseMarkdownAstWithWasm(text, DEFAULT_COMPOSER_MARKDOWN_FLAGS).nodes, []),
).toEqual(parsed);
});
});
describe('areSpecialMentionsAllowed', () => {
it.each<[string, Parameters<typeof areSpecialMentionsAllowed>, boolean]>([
['a guild channel with permission', [GUILD_CHANNEL, undefined, undefined, true], true],
['a guild channel without permission', [GUILD_CHANNEL, undefined, undefined, false], false],
['a direct message', [DM_CHANNEL, undefined, undefined, false], true],
['personal notes', [PERSONAL_NOTES, undefined, undefined, true], false],
['a composer with no channel', [null, true, ['emoji'], true], false],
['a guild channel with special mentions switched off', [GUILD_CHANNEL, false, undefined, true], false],
['a direct message with special mentions switched off', [DM_CHANNEL, false, undefined, false], false],
['a guild channel without the mention trigger', [GUILD_CHANNEL, true, ['emoji'], true], false],
[
'a guild channel with the rich input triggers',
[GUILD_CHANNEL, true, ['emoji', 'mention', 'channel'], true],
true,
],
])('answers for %s', (_name, args, expected) => {
expect(areSpecialMentionsAllowed(...args)).toBe(expected);
});
});
describe('createSpecialMentionPayload', () => {
it('builds the autocomplete mention payload', () => {
expect(createSpecialMentionPayload('@everyone')).toEqual({
kind: 'mention',
mentionType: 'special',
id: '@everyone',
display: '@everyone',
wire: '@everyone',
});
});
});
describe('isSpecialMentionKind', () => {
it('recognises only @everyone and @here', () => {
expect(['@everyone', '@here', 'here', '@someone'].map(isSpecialMentionKind)).toEqual([true, true, false, false]);
});
});
describe('hasOpenCodeFence', () => {
it('reports an odd number of fences', () => {
expect(['```js\nconst a', '```a```', ''].map(hasOpenCodeFence)).toEqual([true, false, false]);
});
});
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Channel} from '@app/features/channel/models/Channel';
import type {ComposerInsertPayload} from '@app/features/lexical/composer/composerOffsets';
import {isAutocompleteTriggerAllowed, type TriggerType} from '@app/features/messaging/utils/AutocompleteTriggerPolicy';
import {findUrlSpans, isInsideSpan, type TextSpan} from '@app/features/messaging/utils/markdown/UrlSpanUtils';
export type SpecialMentionKind = '@everyone' | '@here';
export interface TypedSpecialMention {
start: number;
end: number;
kind: SpecialMentionKind;
}
export const SPECIAL_MENTION_PATTERN = /@(?:everyone|here)/;
const WORD_CHARACTER = /[\p{L}\p{N}]/u;
export function isSpecialMentionKind(value: string): value is SpecialMentionKind {
return value === '@everyone' || value === '@here';
}
export function createSpecialMentionPayload(kind: SpecialMentionKind): ComposerInsertPayload {
return {kind: 'mention', mentionType: 'special', id: kind, display: kind, wire: kind};
}
export function areSpecialMentionsAllowed(
channel: Pick<Channel, 'guildId' | 'isPersonalNotes'> | null,
allowSpecialMentions: boolean | undefined,
allowedTriggers: ReadonlyArray<TriggerType> | undefined,
canMentionEveryone: boolean,
): boolean {
if (channel == null || allowSpecialMentions === false || !isAutocompleteTriggerAllowed('mention', allowedTriggers)) {
return false;
}
return channel.guildId == null ? !channel.isPersonalNotes() : canMentionEveryone;
}
export function hasOpenCodeFence(text: string): boolean {
const fences = text.match(/```/g);
return fences != null && fences.length % 2 === 1;
}
function hasOpenInlineCode(text: string, index: number): boolean {
let openRun = 0;
for (const run of text.slice(text.lastIndexOf('\n', index - 1) + 1, index).matchAll(/`+/g)) {
if (openRun === 0) {
openRun = run[0].length;
} else if (run[0].length === openRun) {
openRun = 0;
}
}
return openRun > 0;
}
export function findTypedSpecialMention(
text: string,
from: number,
to: number,
caret: number | null,
): TypedSpecialMention | null {
const pattern = new RegExp(SPECIAL_MENTION_PATTERN.source, 'g');
pattern.lastIndex = from;
let urlSpans: Array<TextSpan> | null = null;
let match = pattern.exec(text);
while (match != null && match.index + match[0].length <= to) {
const start = match.index;
const end = start + match[0].length;
const previous = text.charAt(start - 1);
if (
end !== caret &&
previous !== '\\' &&
!WORD_CHARACTER.test(previous) &&
!WORD_CHARACTER.test(text.charAt(end)) &&
!hasOpenCodeFence(text.slice(0, start)) &&
!hasOpenInlineCode(text, start)
) {
if (urlSpans === null) {
urlSpans = findUrlSpans(text);
}
if (!isInsideSpan(urlSpans, start)) {
return {start, end, kind: match[0] as SpecialMentionKind};
}
}
match = pattern.exec(text);
}
return null;
}
@@ -39,6 +39,11 @@ import {applyComposerReplacement, createComposerEmojiPayload} from '@app/feature
import type {ComposerInsertPayload} from '@app/features/lexical/composer/composerOffsets';
import {normalizeSlotAutocompleteQuery as normalizeSlotQuery} from '@app/features/lexical/composer/SlashSlotAutocompleteQuery';
import type {SlashOptionalContext, SlashSlotAutocompleteContext} from '@app/features/lexical/composer/slashSlots';
import {
areSpecialMentionsAllowed,
createSpecialMentionPayload,
hasOpenCodeFence,
} from '@app/features/lexical/composer/specialMentions';
import {
type GifAutocompleteSearchState,
selectAutocompleteGifResults,
@@ -256,10 +261,7 @@ export function useLexicalAutocomplete({
? `${autocompleteTrigger.type}:${autocompleteTrigger.match.index == null ? -1 : autocompleteTrigger.match.index}:${autocompleteTrigger.match[0]}:${autocompleteTrigger.matchedText}`
: '';
const autocompleteQuery = useMemo(() => getAutocompleteQuery(autocompleteTrigger), [autocompleteTrigger]);
const hasOpenCodeBlock = useMemo(() => {
const match = textUpToCursor.match(/```/g);
return match != null && match.length > 0 && match.length % 2 !== 0;
}, [textUpToCursor]);
const hasOpenCodeBlock = useMemo(() => hasOpenCodeFence(textUpToCursor), [textUpToCursor]);
const memberSearchRank = useMemo(() => buildMemberSearchRank(memberSearchResults), [memberSearchResults]);
const slotMemberSearchRank = useMemo(() => buildMemberSearchRank(slotMemberSearchResults), [slotMemberSearchResults]);
@@ -291,6 +293,12 @@ export function useLexicalAutocomplete({
const canMentionEveryone =
allowSpecialMentions !== false && channel != null && Permission.can(Permissions.MENTION_EVERYONE, channel);
const specialMentionsAllowed = areSpecialMentionsAllowed(
channel,
allowSpecialMentions,
allowedTriggers,
canMentionEveryone,
);
const canUseCommand = useCallback(
(command: Command) => {
if (command.type === 'simple') {
@@ -373,7 +381,7 @@ export function useLexicalAutocomplete({
.map((id) => Users.getUser(id))
.filter((user): user is User => user != null);
const userOptions = filterDMUsers(users, parsedQuery);
options = channel.isPersonalNotes() ? userOptions : [...userOptions, ...SPECIAL_MENTIONS];
options = specialMentionsAllowed ? [...userOptions, ...SPECIAL_MENTIONS] : userOptions;
} else {
const recentSpeakers =
matchedText.length === 0 ? buildRecentSpeakerOptions(channel, MENTION_RESULT_LIMIT) : [];
@@ -399,7 +407,7 @@ export function useLexicalAutocomplete({
kind: 'role' as const,
role,
}));
const specialMentions = canMentionEveryone
const specialMentions = specialMentionsAllowed
? SPECIAL_MENTIONS.filter((mention) => {
if (queryForMatching.length === 0) {
return true;
@@ -538,6 +546,7 @@ export function useLexicalAutocomplete({
memberSearchRank,
memberSearchResults,
permissionVersion,
specialMentionsAllowed,
]);
useEffect(() => {
@@ -751,6 +760,7 @@ export function useLexicalAutocomplete({
handleSelect,
autocompleteQuery: resolvedAutocompleteQuery,
isSlotMenu,
specialMentionsAllowed,
};
}
@@ -960,7 +970,7 @@ function optionToPayload(option: AutocompleteOption, channel: Channel | null): C
};
}
if (isSpecialMention(option)) {
return {kind: 'mention', mentionType: 'special', id: option.kind, display: option.kind, wire: option.kind};
return createSpecialMentionPayload(option.kind);
}
if (isChannel(option)) {
return {
@@ -12,6 +12,7 @@ import Channels from '@app/features/channel/state/Channels';
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet';
import {ExpressionPickerPopout} from '@app/features/expressions/components/popouts/ExpressionPickerPopout';
import {dropTrailingEmptyBlockquoteLines} from '@app/features/lexical/composer/blockquoteLines';
import {LexicalRichInput, type LexicalRichInputHandle} from '@app/features/lexical/composer/LexicalRichInput';
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import {MessageForwardFailedModal} from '@app/features/messaging/components/alerts/MessageForwardFailedModal';
@@ -225,7 +226,7 @@ export const ForwardModal = observer(
return selectedChannel == null ? null : selectedChannel;
}, [mostRecentlySelectedChannelId]);
const handleCommentChange = useCallback((_display: string, _segments: Array<MentionSegment>, wire: string) => {
setActualOptionalMessage(wire);
setActualOptionalMessage(dropTrailingEmptyBlockquoteLines(wire));
}, []);
const isCommentOverLimit = actualOptionalMessage.length > user.maxMessageLength;
const isSendBlockedBySlowmode = slowmodeActiveSelectedChannelOptions.length > 0;
@@ -0,0 +1,136 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Channel} from '@app/features/channel/models/Channel';
import {type SendMessageFunction, useMessageSubmission} from '@app/features/messaging/hooks/useMessageSubmission';
import {MessageFlags} from '@fluxer/constants/src/ChannelConstants';
import {act, createElement} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const messageCommands = vi.hoisted(() => ({
reserveSend: vi.fn(() => true),
send: vi.fn(() => Promise.resolve(null)),
createOptimistic: vi.fn(),
stopReply: vi.fn(),
}));
const draftCommands = vi.hoisted(() => ({deleteDraft: vi.fn()}));
const cloudUpload = vi.hoisted(() => ({
getTextareaAttachments: vi.fn((): Array<unknown> => []),
claimAttachmentsForMessage: vi.fn(() => []),
}));
vi.mock('@app/features/messaging/commands/MessageCommands', () => messageCommands);
vi.mock('@app/features/messaging/commands/DraftCommands', () => draftCommands);
vi.mock('@app/features/messaging/upload/CloudUpload', () => ({CloudUpload: cloudUpload}));
vi.mock('@app/features/messaging/models/MessagingMessage', () => ({
Message: class {
constructor(data: object) {
Object.assign(this, data);
}
toJSON(): object {
return {...this};
}
},
}));
vi.mock('@app/features/messaging/state/ChatInputSettings', () => ({default: {convertEmoticons: false}}));
vi.mock('@app/features/user/state/UserSettings', () => ({default: {getSanitizeUrls: () => false}}));
vi.mock('@app/features/messaging/utils/EmoticonConversionUtils', () => ({
convertEmoticonsToEmoji: (content: string) => content,
}));
vi.mock('@app/features/user/state/Users', () => ({default: {getCurrentUser: () => ({toJSON: () => ({id: 'me'})})}}));
vi.mock('@app/features/permissions/state/Permission', () => ({default: {can: () => true}}));
vi.mock('@app/features/slowmode/state/Slowmode', () => ({default: {getSlowmodeRemaining: () => 0}}));
vi.mock('@app/features/slowmode/commands/SlowmodeCommands', () => ({
prepareMessageSend: vi.fn(),
recordPendingMessageSend: vi.fn(() => null),
confirmMessageSend: vi.fn(),
discardPendingMessageSend: vi.fn(),
}));
vi.mock('@app/features/slowmode/components/alerts/SlowmodeRateLimitedModal', () => ({
SlowmodeRateLimitedModal: () => null,
}));
vi.mock('@app/features/ui/commands/ModalCommands', () => ({modal: (render: unknown) => render, push: vi.fn()}));
vi.mock('@app/features/typing/utils/TypingUtils', () => ({TypingUtils: {clear: vi.fn()}}));
vi.mock('@app/features/platform/utils/ComponentBus', () => ({ComponentBus: {dispatch: vi.fn()}}));
vi.mock('@lingui/core/macro', () => ({msg: (value: unknown) => value}));
vi.mock('@lingui/react/macro', () => ({useLingui: () => ({i18n: {_: () => ''}})}));
const channel = {id: 'c'} as unknown as Channel;
let host: HTMLDivElement;
let root: Root;
function renderSendMessage(): SendMessageFunction {
let sendMessage: SendMessageFunction | null = null;
const Probe = () => {
sendMessage = useMessageSubmission({channel, referencedMessage: null, replyingMessage: null}).sendMessage;
return null;
};
act(() => {
root.render(createElement(Probe));
});
return sendMessage!;
}
beforeEach(() => {
host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
});
afterEach(() => {
act(() => {
root.unmount();
});
document.body.replaceChildren();
vi.clearAllMocks();
cloudUpload.getTextareaAttachments.mockReturnValue([]);
messageCommands.reserveSend.mockReturnValue(true);
});
describe('useMessageSubmission', () => {
it('sends nothing and keeps the draft for a message that is only @silent', () => {
const sendMessage = renderSendMessage();
expect(sendMessage('@silent', false)).toBe(false);
expect(messageCommands.reserveSend).not.toHaveBeenCalled();
expect(draftCommands.deleteDraft).not.toHaveBeenCalled();
expect(messageCommands.send).not.toHaveBeenCalled();
});
it('sends nothing and keeps the draft for a /tts command that is only @silent', () => {
const sendMessage = renderSendMessage();
expect(sendMessage('@silent', false, true)).toBe(false);
expect(messageCommands.reserveSend).not.toHaveBeenCalled();
expect(draftCommands.deleteDraft).not.toHaveBeenCalled();
expect(messageCommands.send).not.toHaveBeenCalled();
});
it('sends nothing and keeps the draft for a sticker send that is only @silent', () => {
const sendMessage = renderSendMessage();
expect(sendMessage('@silent', false, [])).toBe(false);
expect(messageCommands.reserveSend).not.toHaveBeenCalled();
expect(draftCommands.deleteDraft).not.toHaveBeenCalled();
});
it('sends an attachment captioned only by @silent with no content and the silent flag', () => {
cloudUpload.getTextareaAttachments.mockReturnValue([{id: 1}]);
const sendMessage = renderSendMessage();
expect(sendMessage('@silent', false)).toBe(true);
expect(draftCommands.deleteDraft).toHaveBeenCalledWith('c');
expect(messageCommands.send).toHaveBeenCalledWith(
'c',
expect.objectContaining({content: '', flags: MessageFlags.SUPPRESS_NOTIFICATIONS}),
);
});
it('sends the text after @silent with the silent flag', () => {
const sendMessage = renderSendMessage();
expect(sendMessage('@silent hi', false)).toBe(true);
expect(draftCommands.deleteDraft).toHaveBeenCalledWith('c');
expect(messageCommands.send).toHaveBeenCalledWith(
'c',
expect.objectContaining({content: 'hi', flags: MessageFlags.SUPPRESS_NOTIFICATIONS}),
);
});
});
@@ -4,6 +4,8 @@ import type {Channel} from '@app/features/channel/models/Channel';
import * as DraftCommands from '@app/features/messaging/commands/DraftCommands';
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import {Message} from '@app/features/messaging/models/MessagingMessage';
import {CloudUpload} from '@app/features/messaging/upload/CloudUpload';
import {canSubmitMessage} from '@app/features/messaging/utils/MessageRequestUtils';
import * as MessageSubmitUtils from '@app/features/messaging/utils/MessageSubmitUtils';
import {formatUploadingAttachmentSummary} from '@app/features/messaging/utils/UploadingAttachmentLabelUtils';
import Permission from '@app/features/permissions/state/Permission';
@@ -91,6 +93,12 @@ export const useMessageSubmission = ({channel, referencedMessage, replyingMessag
: undefined;
const currentUser = Users.getCurrentUser();
if (!channel || !currentUser) return false;
const hasNonTextContent =
hasAttachments ||
stickers.length > 0 ||
favoriteMemeId !== undefined ||
CloudUpload.getTextareaAttachments(channel.id).length > 0;
if (!canSubmitMessage(content, hasNonTextContent)) return false;
if (isBlockedBySlowmode(channel)) return false;
const nonce = SnowflakeUtils.fromTimestamp(Date.now());
if (!MessageCommands.reserveSend(channel.id, nonce)) return false;
@@ -9,6 +9,7 @@ import Emoji from '@app/features/emoji/state/Emoji';
import {checkEmojiAvailabilityWithGuildFallback} from '@app/features/expressions/utils/ExpressionPermissionUtils';
import ChannelMemberCount from '@app/features/guild/state/ChannelMemberCount';
import Guilds from '@app/features/guild/state/Guilds';
import {dropTrailingEmptyBlockquoteLines} from '@app/features/lexical/composer/blockquoteLines';
import type {ComposerHandle} from '@app/features/lexical/composer/ComposerHandle';
import {
type LexicalMessageCommandResolution,
@@ -26,7 +27,7 @@ import {
buildExistingAttachmentEditReferences,
canSubmitEmptyMessageEdit,
} from '@app/features/messaging/utils/MessageEditContentUtils';
import {hasVisibleMessageContent} from '@app/features/messaging/utils/MessageRequestUtils';
import {canSubmitMessage, hasVisibleMessageContent} from '@app/features/messaging/utils/MessageRequestUtils';
import * as ReplaceCommandUtils from '@app/features/messaging/utils/ReplaceCommandUtils';
import {resolveTypedEmojiShortcodes} from '@app/features/messaging/utils/TypedEmojiShortcodeUtils';
import Permission from '@app/features/permissions/state/Permission';
@@ -446,7 +447,7 @@ export const useTextareaSubmit = ({
let actualContent = displayToActual(value).trim();
if (composerHandle !== null) {
lexicalCommand = LexicalMessageCommandResolver.resolve(composerHandle);
actualContent = composerHandle.getWireValue().trim();
actualContent = dropTrailingEmptyBlockquoteLines(composerHandle.getWireValue()).trim();
}
const resolvedContent = resolveTypedEmojiContent(actualContent);
let parsedCommand: CommandUtils.ParsedCommand | null = null;
@@ -513,7 +514,7 @@ export const useTextareaSubmit = ({
);
return;
}
if (!hasVisibleMessageContent(resolvedContent) && uploadAttachmentsLength === 0 && !hasPendingSticker) {
if (!canSubmitMessage(resolvedContent, uploadAttachmentsLength > 0 || hasPendingSticker)) {
return;
}
if (replaceCommand) {
@@ -0,0 +1,161 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
buildMessageCreateRequest,
buildMessageEditRequest,
canSubmitMessage,
getComposerMessageContent,
hasVisibleMessageContent,
normalizeMessageContent,
} from '@app/features/messaging/utils/MessageRequestUtils';
import {MessageFlags} from '@fluxer/constants/src/ChannelConstants';
import {afterEach, describe, expect, it, vi} from 'vitest';
const chatInputSettings = vi.hoisted(() => ({convertEmoticons: false}));
vi.mock('@app/features/messaging/state/ChatInputSettings', () => ({default: chatInputSettings}));
vi.mock('@app/features/user/state/UserSettings', () => ({default: {getSanitizeUrls: () => false}}));
vi.mock('@app/features/messaging/utils/EmoticonConversionUtils', () => ({
convertEmoticonsToEmoji: (content: string) => content.replaceAll(':)', '\u{1F642}'),
}));
afterEach(() => {
chatInputSettings.convertEmoticons = false;
});
describe('normalizeMessageContent', () => {
it('still strips @silent followed by a space and suppresses notifications', () => {
expect(normalizeMessageContent('@silent hello')).toEqual({
content: 'hello',
flags: MessageFlags.SUPPRESS_NOTIFICATIONS,
});
});
it('strips @silent on its own and suppresses notifications', () => {
expect(normalizeMessageContent('@silent')).toEqual({content: '', flags: MessageFlags.SUPPRESS_NOTIFICATIONS});
});
it.each([
['a newline', '\n'],
['a no-break space', '\u00a0'],
['a tab', '\t'],
])('strips @silent followed by %s and suppresses notifications', (_name, whitespace) => {
expect(normalizeMessageContent(`@silent${whitespace}hello`)).toEqual({
content: 'hello',
flags: MessageFlags.SUPPRESS_NOTIFICATIONS,
});
});
it('strips leading whitespace before @silent', () => {
expect(normalizeMessageContent(' @silent hello')).toEqual({
content: 'hello',
flags: MessageFlags.SUPPRESS_NOTIFICATIONS,
});
});
it('still strips only one whitespace character and one @silent', () => {
expect(normalizeMessageContent('@silent hello')).toEqual({
content: ' hello',
flags: MessageFlags.SUPPRESS_NOTIFICATIONS,
});
expect(normalizeMessageContent('@silent @silent hello')).toEqual({
content: '@silent hello',
flags: MessageFlags.SUPPRESS_NOTIFICATIONS,
});
});
it('still drops invisible content left after @silent', () => {
expect(normalizeMessageContent('@silent \u200b')).toEqual({
content: '',
flags: MessageFlags.SUPPRESS_NOTIFICATIONS,
});
});
it.each([
'@silently hello',
'@Silent hello',
'hello @silent',
'hello',
])('still leaves %j as it is with no flags', (content) => {
expect(normalizeMessageContent(content)).toEqual({content, flags: 0});
});
it('still converts emoticons after stripping @silent', () => {
chatInputSettings.convertEmoticons = true;
expect(normalizeMessageContent('@silent :)')).toEqual({
content: '\u{1F642}',
flags: MessageFlags.SUPPRESS_NOTIFICATIONS,
});
});
});
describe('canSubmitMessage', () => {
it('rejects a message that is only @silent', () => {
expect(canSubmitMessage('@silent', false)).toBe(false);
expect(canSubmitMessage('@silent\n', false)).toBe(false);
expect(canSubmitMessage('@silent \u200b', false)).toBe(false);
});
it('rejects blank content with nothing attached', () => {
expect(canSubmitMessage(' ', false)).toBe(false);
});
it('accepts @silent with attachments, a sticker or a favourite meme', () => {
expect(canSubmitMessage('@silent', true)).toBe(true);
});
it('accepts visible content with or without @silent', () => {
expect(canSubmitMessage('@silent hi', false)).toBe(true);
expect(canSubmitMessage('hello', false)).toBe(true);
});
});
describe('getComposerMessageContent', () => {
it('measures the content that will be sent when not editing on mobile', () => {
expect(getComposerMessageContent('@silent', false)).toBe('');
expect(getComposerMessageContent('@silent hello', false)).toBe('hello');
expect(hasVisibleMessageContent(getComposerMessageContent('@silent', false))).toBe(false);
});
it('strips @silent without running the outgoing conversions', () => {
chatInputSettings.convertEmoticons = true;
expect(getComposerMessageContent('@silent :)', false)).toBe(':)');
});
it('keeps @silent as text when editing on mobile', () => {
expect(getComposerMessageContent('@silent', true)).toBe('@silent');
expect(getComposerMessageContent('@silent hello', true)).toBe('@silent hello');
expect(hasVisibleMessageContent(getComposerMessageContent('@silent', true))).toBe(true);
});
});
describe('buildMessageCreateRequest', () => {
it('still omits empty content but keeps the silent flag for an attachment-only message', () => {
const attachment = {id: '1', filename: 'a.png', title: 'a.png'};
expect(
buildMessageCreateRequest({
content: '',
nonce: 'n',
attachments: [attachment],
flags: MessageFlags.SUPPRESS_NOTIFICATIONS,
}),
).toEqual({nonce: 'n', attachments: [attachment], flags: MessageFlags.SUPPRESS_NOTIFICATIONS});
});
it('still sends a /tts message after @silent as silent text-to-speech', () => {
const {content, flags} = normalizeMessageContent('@silent hi');
expect(buildMessageCreateRequest({content, nonce: 'n', flags, tts: true})).toEqual({
content: 'hi',
nonce: 'n',
flags: MessageFlags.SUPPRESS_NOTIFICATIONS,
tts: true,
});
});
});
describe('buildMessageEditRequest', () => {
it('still sends @silent verbatim in an edit with no flags', () => {
expect(buildMessageEditRequest({content: '@silent hello'})).toEqual({content: '@silent hello'});
expect(buildMessageEditRequest({content: '@silent'})).toEqual({content: '@silent'});
});
});
@@ -2,6 +2,7 @@
import ChatInputSettings from '@app/features/messaging/state/ChatInputSettings';
import {convertEmoticonsToEmoji} from '@app/features/messaging/utils/EmoticonConversionUtils';
import {parseSilentMessagePrefix} from '@app/features/messaging/utils/SilentMessagePrefix';
import {maybeSanitizeOutgoingMessage} from '@app/features/messaging/utils/UrlSanitizationUtils';
import {hasVisibleMessageContent} from '@app/features/messaging/utils/VisibleMessageContent';
import {MessageFlags} from '@fluxer/constants/src/ChannelConstants';
@@ -82,14 +83,27 @@ export interface NormalizedMessageContent {
}
export function normalizeMessageContent(content: string): NormalizedMessageContent {
const withoutSilent = removeSilentFlag(content);
const silentPrefix = parseSilentMessagePrefix(content);
const withoutSilent = silentPrefix == null ? content : content.slice(silentPrefix.end);
const converted = applyOutgoingEmoticonConversion(withoutSilent);
const sanitized = maybeSanitizeOutgoingMessage(converted);
const normalizedContent = hasVisibleMessageContent(sanitized) ? sanitized : '';
const flags = getMessageFlags(content);
const flags = silentPrefix == null ? 0 : MessageFlags.SUPPRESS_NOTIFICATIONS;
return {content: normalizedContent, flags};
}
export function canSubmitMessage(content: string, hasNonTextContent: boolean): boolean {
return hasNonTextContent || normalizeMessageContent(content).content.length > 0;
}
export function getComposerMessageContent(content: string, isEditingMessageOnMobile: boolean): string {
if (isEditingMessageOnMobile) {
return content;
}
const silentPrefix = parseSilentMessagePrefix(content);
return silentPrefix == null ? content : content.slice(silentPrefix.end);
}
export function normalizeMessageEditContent(content: string): string {
return applyOutgoingEmoticonConversion(content);
}
@@ -146,22 +160,9 @@ export function buildMessageEditRequest(payload: MessageEditPayload): MessageEdi
return requestBody;
}
const isSilentMessage = (content: string): boolean => {
return content.startsWith('@silent ');
};
const removeSilentFlag = (content: string): string => {
return content.startsWith('@silent ') ? content.replace('@silent ', '') : content;
};
const applyOutgoingEmoticonConversion = (content: string): string => {
return ChatInputSettings.convertEmoticons ? convertEmoticonsToEmoji(content) : content;
};
const getMessageFlags = (content: string): number => {
let flags = 0;
if (isSilentMessage(content)) {
flags |= MessageFlags.SUPPRESS_NOTIFICATIONS;
}
return flags;
};
const shouldIncludeAllowedMentions = (allowedMentions?: AllowedMentions): boolean => {
if (!allowedMentions) {
return false;
@@ -0,0 +1,96 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import {claimMessageAttachments, createOptimisticMessage} from '@app/features/messaging/utils/MessageSubmitUtils';
import type {User} from '@app/features/user/models/User';
import {MessageFlags, MessageStates, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import type {MessageAttachment} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {describe, expect, it, vi} from 'vitest';
const claimAttachmentsForMessage = vi.hoisted(() => vi.fn(() => []));
vi.mock('@app/features/messaging/models/MessagingMessage', () => ({
Message: class {
constructor(data: object) {
Object.assign(this, data);
}
},
}));
vi.mock('@app/features/messaging/upload/CloudUpload', () => ({CloudUpload: {claimAttachmentsForMessage}}));
vi.mock('@app/features/messaging/state/ChatInputSettings', () => ({default: {convertEmoticons: false}}));
vi.mock('@app/features/user/state/UserSettings', () => ({default: {getSanitizeUrls: () => false}}));
vi.mock('@app/features/messaging/utils/EmoticonConversionUtils', () => ({
convertEmoticonsToEmoji: (content: string) => content,
}));
const currentUser = {toJSON: () => ({id: 'me'})} as unknown as User;
const referencedMessage = {id: 'ref', author: {toJSON: () => ({id: 'other'})}} as unknown as Message;
describe('createOptimisticMessage', () => {
it('still strips @silent followed by a space and marks the message silent', () => {
expect(
createOptimisticMessage({content: '@silent hello', channelId: 'c', nonce: 'n', currentUser}, []),
).toMatchObject({
content: 'hello',
flags: MessageFlags.SUPPRESS_NOTIFICATIONS,
type: MessageTypes.DEFAULT,
state: MessageStates.SENDING,
});
});
it('strips @silent before a line break and marks the message silent', () => {
expect(
createOptimisticMessage({content: '@silent\nhello', channelId: 'c', nonce: 'n', currentUser}, []),
).toMatchObject({content: 'hello', flags: MessageFlags.SUPPRESS_NOTIFICATIONS});
});
it('marks an attachment captioned only by @silent as silent with no content', () => {
const attachment = {id: 'upload'} as MessageAttachment;
expect(
createOptimisticMessage({content: '@silent', channelId: 'c', nonce: 'n', currentUser}, [attachment]),
).toMatchObject({content: '', flags: MessageFlags.SUPPRESS_NOTIFICATIONS, attachments: [attachment]});
});
it('still marks a silent reply', () => {
expect(
createOptimisticMessage(
{content: '@silent hi', channelId: 'c', nonce: 'n', currentUser, referencedMessage, replyMentioning: true},
[],
),
).toMatchObject({
content: 'hi',
flags: MessageFlags.SUPPRESS_NOTIFICATIONS,
type: MessageTypes.REPLY,
message_reference: {channel_id: 'c', message_id: 'ref', type: 0},
_allowedMentions: {replied_user: true},
});
});
it('still leaves @silent later in the message as text', () => {
expect(
createOptimisticMessage({content: 'hello @silent', channelId: 'c', nonce: 'n', currentUser}, []),
).toMatchObject({content: 'hello @silent', flags: 0});
});
});
describe('claimMessageAttachments', () => {
it('claims attachments captioned only by @silent as silent with no content', () => {
claimMessageAttachments('c', 'n', '@silent');
expect(claimAttachmentsForMessage).toHaveBeenLastCalledWith('c', 'n', undefined, {
content: '',
messageReference: undefined,
allowedMentions: {replied_user: true},
flags: MessageFlags.SUPPRESS_NOTIFICATIONS,
});
});
it('claims attachments with the caption after @silent and a line break', () => {
claimMessageAttachments('c', 'n', '@silent\ncaption');
expect(claimAttachmentsForMessage).toHaveBeenLastCalledWith('c', 'n', undefined, {
content: 'caption',
messageReference: undefined,
allowedMentions: {replied_user: true},
flags: MessageFlags.SUPPRESS_NOTIFICATIONS,
});
});
});
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {parseSilentMessagePrefix} from '@app/features/messaging/utils/SilentMessagePrefix';
import {describe, expect, it} from 'vitest';
describe('parseSilentMessagePrefix', () => {
it('matches @silent followed by a space', () => {
expect(parseSilentMessagePrefix('@silent hello')).toEqual({tokenStart: 0, tokenEnd: 7, end: 8});
});
it('matches @silent on its own, with or without a trailing space', () => {
expect(parseSilentMessagePrefix('@silent')).toEqual({tokenStart: 0, tokenEnd: 7, end: 7});
expect(parseSilentMessagePrefix('@silent ')).toEqual({tokenStart: 0, tokenEnd: 7, end: 8});
});
it.each([
['a newline', '\n'],
['a tab', '\t'],
['a no-break space', '\u00a0'],
])('matches @silent followed by %s', (_name, whitespace) => {
expect(parseSilentMessagePrefix(`@silent${whitespace}hello`)).toEqual({tokenStart: 0, tokenEnd: 7, end: 8});
});
it('consumes only one whitespace character after the token', () => {
expect(parseSilentMessagePrefix('@silent hello')).toEqual({tokenStart: 0, tokenEnd: 7, end: 8});
});
it('skips leading whitespace before the token', () => {
expect(parseSilentMessagePrefix(' @silent hello')).toEqual({tokenStart: 2, tokenEnd: 9, end: 10});
expect(parseSilentMessagePrefix('\n@silent hello')).toEqual({tokenStart: 1, tokenEnd: 8, end: 9});
});
it.each([
'',
'hello',
'hello @silent',
'@silently hello',
'@Silent hello',
'@silent@everyone',
'`@silent` hello',
'> @silent hello',
])('does not match %j', (content) => {
expect(parseSilentMessagePrefix(content)).toBeNull();
});
it.each([
'@silent hello',
' @silent hello',
'\n@silent hello',
'\t@silent hello',
'\u00a0@silent hello',
'\u3000@silent hello',
'\ufeff@silent hello',
'\u200b@silent hello',
'@silent',
'@silent ',
'@silent\u00a0',
'@silent\u200b',
'@silently hello',
'hello @silent',
' ',
])('matches %j the same way before and after trimming', (content) => {
expect(parseSilentMessagePrefix(content) != null).toBe(parseSilentMessagePrefix(content.trim()) != null);
});
});
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const SILENT_MESSAGE_PREFIX_RE = /^(\s*)(@silent)(?:\s|$)/;
interface SilentMessagePrefix {
tokenStart: number;
tokenEnd: number;
end: number;
}
export function parseSilentMessagePrefix(content: string): SilentMessagePrefix | null {
const match = SILENT_MESSAGE_PREFIX_RE.exec(content);
if (match == null) {
return null;
}
const tokenStart = match[1]!.length;
return {tokenStart, tokenEnd: tokenStart + match[2]!.length, end: match[0].length};
}
@@ -1,20 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {Endpoints} from '@app/features/app/constants/Endpoints';
import {Channel} from '@app/features/channel/models/Channel';
import {ACK_BATCH_DELAY_MS, type GatewayReadState} from '@app/features/read_state/state/read_states/shared';
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import type {Channel as WireChannel} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {Message as WireMessage} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const makeChannel = (id: string) => ({
id,
type: 0,
guildId: 'guild-1',
isPrivate: () => false,
getGuildId: () => 'guild-1',
});
const channels = new Map<string, Channel>();
const blockedUserIds = new Set<string>();
let pinnedToEnd = false;
let automaticAck = false;
const loadedMessages: Array<{id: string; author: {id: string}}> = [];
let hasMoreBefore = false;
let hasNewestMessages = true;
vi.mock('@app/features/app/state/RuntimeConfig', () => ({default: {localInstanceDomain: 'fluxer.test'}}));
vi.mock('@app/features/channel/state/Channels', () => ({
default: {getChannel: (id: string) => makeChannel(id), getBasicChannel: (id: string) => makeChannel(id)},
default: {getChannel: (id: string) => channels.get(id)},
}));
vi.mock('@app/features/messaging/state/MessagingMessages', () => ({
default: {
@@ -35,8 +40,12 @@ vi.mock('@app/features/messaging/state/MessagingMessages', () => ({
}),
},
}));
vi.mock('@app/features/user/state/Users', () => ({default: {getCurrentUser: () => ({id: 'me'})}}));
vi.mock('@app/features/relationship/state/Relationships', () => ({default: {isBlocked: () => false}}));
vi.mock('@app/features/user/state/Users', () => ({
default: {getCurrentUser: () => ({id: 'me'}), cacheUsers: () => {}},
}));
vi.mock('@app/features/relationship/state/Relationships', () => ({
default: {isBlocked: (id: string) => blockedUserIds.has(id)},
}));
vi.mock('@app/features/member/state/GuildMembers', () => ({default: {getMember: () => null}}));
vi.mock('@app/features/user/state/UserGuildSettings', () => ({
default: {
@@ -45,13 +54,16 @@ vi.mock('@app/features/user/state/UserGuildSettings', () => ({
isGuildOrChannelMuted: () => false,
},
}));
vi.mock('@app/features/ui/state/Dimension', () => ({default: {channelPinnedToEnd: () => false}}));
vi.mock('@app/features/ui/state/Dimension', () => ({default: {channelPinnedToEnd: () => pinnedToEnd}}));
vi.mock('@app/features/notification/state/NotificationAutoAck', () => ({
default: {isAutomaticAckEnabled: () => false},
default: {isAutomaticAckEnabled: () => automaticAck, disableForChannel: () => {}},
}));
vi.mock('@app/features/platform/transport/RestTransport', () => ({
http: {post: vi.fn(async () => ({body: {read_states: []}})), get: vi.fn()},
}));
vi.mock('@app/features/platform/transport/RestTransport', () => ({http: {post: vi.fn(), get: vi.fn()}}));
const {default: ReadStates} = await import('@app/features/read_state/state/ReadStates');
const {http} = await import('@app/features/platform/transport/RestTransport');
const ID = {
ack: '1519773906704011264',
@@ -62,6 +74,7 @@ let nextChannelId = 0;
function seedReadChannel() {
const channelId = `channel-${++nextChannelId}`;
channels.set(channelId, new Channel({id: channelId, type: ChannelTypes.GUILD_TEXT, guild_id: 'guild-1'}));
const state = ReadStates.get(channelId);
state.readStateKnown = true;
state.ackMessageId = ID.ack;
@@ -71,13 +84,17 @@ function seedReadChannel() {
return {channelId, state};
}
describe('ReadStates unread invariant', () => {
beforeEach(() => {
loadedMessages.length = 0;
hasMoreBefore = false;
hasNewestMessages = true;
});
beforeEach(() => {
loadedMessages.length = 0;
hasMoreBefore = false;
hasNewestMessages = true;
blockedUserIds.clear();
pinnedToEnd = false;
automaticAck = false;
vi.mocked(http.post).mockClear();
});
describe('ReadStates unread invariant', () => {
it('never reports a positive unread count without an unread anchor after a passive update', () => {
const {channelId} = seedReadChannel();
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.newer}, 'guild-1');
@@ -162,3 +179,615 @@ describe('ReadStates unread invariant', () => {
expect(ReadStates.getUnreadCount(channelId) > 0).toBe(true);
});
});
const ME = 'me';
const TUNA = 'tuna';
const CHANNEL = {
dm: '1485064866382176259',
groupDm: '1485064866382176260',
notes: '1485064866382176261',
guildText: '1485064866382176262',
newDm: '1547743690000000000',
};
const MESSAGE = {
older: '1547700000000000000',
last: '1547743000000000000',
incoming: '1547743701495717888',
next: '1547743701495717890',
own: '1547743701495717892',
reply: '1547743701495717893',
};
function user(id: string): UserPartial {
return {id, username: id, discriminator: '0', global_name: null, avatar: null, avatar_color: null, flags: 0};
}
function dm(id: string, lastMessageId: string | null): WireChannel {
return {
id,
type: ChannelTypes.DM,
last_message_id: lastMessageId,
last_pin_timestamp: null,
recipients: [user(TUNA)],
};
}
function groupDm(lastMessageId: string | null): WireChannel {
return {
id: CHANNEL.groupDm,
type: ChannelTypes.GROUP_DM,
last_message_id: lastMessageId,
last_pin_timestamp: null,
recipients: [user(TUNA)],
};
}
function guildText(lastMessageId: string): WireChannel {
return {id: CHANNEL.guildText, type: ChannelTypes.GUILD_TEXT, guild_id: 'guild-1', last_message_id: lastMessageId};
}
function wireMessage(
id: string,
channelId: string,
authorId: string,
overrides: Partial<WireMessage> = {},
): WireMessage {
return {
id,
channel_id: channelId,
author: user(authorId),
type: MessageTypes.DEFAULT,
flags: 0,
pinned: false,
tts: false,
mention_everyone: false,
content: 'test',
timestamp: '2026-09-11T00:00:00.000Z',
mentions: [],
mention_roles: [],
...overrides,
} as WireMessage;
}
function recipientAddMessage(): WireMessage {
return wireMessage(MESSAGE.next, CHANNEL.groupDm, TUNA, {type: MessageTypes.RECIPIENT_ADD, mentions: [user(ME)]});
}
function readState(channelId: string, ackMessageId: string, mentionCount = 0): GatewayReadState {
return {id: channelId, last_message_id: ackMessageId, mention_count: mentionCount, version: '1'};
}
function ready(readStates: Array<GatewayReadState>, openChannels: Array<WireChannel>): void {
channels.clear();
for (const channel of openChannels) {
channels.set(channel.id, new Channel(channel));
}
ReadStates.handleGatewayReady({readState: readStates, channels: openChannels});
}
function channelCreate(channel: WireChannel): void {
channels.set(channel.id, new Channel(channel));
ReadStates.handleChannelCreate({channel});
}
function channelDelete(channel: {id: string; type?: number; guild_id?: string}): void {
channels.delete(channel.id);
ReadStates.handleChannelDelete({channel});
}
function messageCreate(message: WireMessage): void {
ReadStates.handleIncomingMessage({channelId: message.channel_id, message});
}
function unreadState(channelId: string) {
return {
unread: ReadStates.hasUnread(channelId),
unreadCount: ReadStates.getUnreadCount(channelId),
mentionCount: ReadStates.getMentionCount(channelId),
ackMessageId: ReadStates.ackMessageId(channelId),
};
}
describe('ReadStates private channel open, close and reopen', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
ReadStates.clearAll();
vi.useRealTimers();
});
it('shows a DM closed before READY as unread when CHANNEL_CREATE and MESSAGE_CREATE reopen it', () => {
ready([readState(CHANNEL.dm, MESSAGE.older)], []);
channelCreate(dm(CHANNEL.dm, MESSAGE.incoming));
expect(ReadStates.hasUnread(CHANNEL.dm)).toBe(true);
expect(ReadStates.hasUnreadPrivateChannel(CHANNEL.dm)).toBe(true);
ReadStates.consumePendingChanges();
const privateChannelVersion = ReadStates.privateChannelVersion;
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA));
expect(ReadStates.privateChannelVersion).toBe(privateChannelVersion + 1);
expect(ReadStates.consumePendingChanges()).toEqual({
all: false,
channelIds: [CHANNEL.dm],
changes: [{channelId: CHANNEL.dm, guildId: null}],
});
expect(unreadState(CHANNEL.dm)).toEqual({
unread: true,
unreadCount: 1,
mentionCount: 1,
ackMessageId: MESSAGE.older,
});
expect(ReadStates.hasUnreadPrivateChannel(CHANNEL.dm)).toBe(true);
expect(ReadStates.getPrivateChannelUnreadCount(CHANNEL.dm)).toBe(1);
expect(ReadStates.getPrivateChannelMentionCount(CHANNEL.dm)).toBe(1);
expect(ReadStates.getOldestUnreadMessageId(CHANNEL.dm)).toBe(MESSAGE.incoming);
expect(ReadStates.mentionChannelIds).toContain(CHANNEL.dm);
});
it('keeps the mentions of a DM READY closed over the limit when a message reopens it', () => {
ready([readState(CHANNEL.dm, MESSAGE.older, 2)], []);
expect(unreadState(CHANNEL.dm)).toEqual({unread: false, unreadCount: 0, mentionCount: 0, ackMessageId: null});
channelCreate(dm(CHANNEL.dm, MESSAGE.incoming));
expect(unreadState(CHANNEL.dm)).toEqual({
unread: true,
unreadCount: 0,
mentionCount: 2,
ackMessageId: MESSAGE.older,
});
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA));
expect(unreadState(CHANNEL.dm)).toEqual({
unread: true,
unreadCount: 1,
mentionCount: 3,
ackMessageId: MESSAGE.older,
});
});
it('shows a DM closed earlier in the session as unread when a message reopens it', () => {
ready([readState(CHANNEL.dm, MESSAGE.older)], [dm(CHANNEL.dm, MESSAGE.older)]);
channelDelete({id: CHANNEL.dm, type: ChannelTypes.DM});
channelCreate(dm(CHANNEL.dm, MESSAGE.incoming));
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA));
expect(unreadState(CHANNEL.dm)).toEqual({
unread: true,
unreadCount: 1,
mentionCount: 1,
ackMessageId: MESSAGE.older,
});
expect(ReadStates.getOldestUnreadMessageId(CHANNEL.dm)).toBe(MESSAGE.incoming);
});
it('counts only the messages that arrive after a closed DM reopens', () => {
ready([readState(CHANNEL.dm, MESSAGE.older)], [dm(CHANNEL.dm, MESSAGE.older)]);
messageCreate(wireMessage(MESSAGE.last, CHANNEL.dm, TUNA));
expect(unreadState(CHANNEL.dm)).toEqual({
unread: true,
unreadCount: 1,
mentionCount: 1,
ackMessageId: MESSAGE.older,
});
channelDelete({id: CHANNEL.dm, type: ChannelTypes.DM});
channelCreate(dm(CHANNEL.dm, MESSAGE.incoming));
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA));
expect(unreadState(CHANNEL.dm)).toEqual({
unread: true,
unreadCount: 1,
mentionCount: 2,
ackMessageId: MESSAGE.older,
});
expect(ReadStates.getOldestUnreadMessageId(CHANNEL.dm)).toBe(MESSAGE.incoming);
});
it('hides a closed DM and brings its mentions back when a message reopens it', () => {
ready([readState(CHANNEL.dm, MESSAGE.older, 2)], [dm(CHANNEL.dm, MESSAGE.last)]);
ReadStates.consumePendingChanges();
const privateChannelVersion = ReadStates.privateChannelVersion;
channelDelete({id: CHANNEL.dm, type: ChannelTypes.DM});
expect(ReadStates.privateChannelVersion).toBe(privateChannelVersion + 1);
expect(ReadStates.consumePendingChanges().changes).toEqual([{channelId: CHANNEL.dm, guildId: null}]);
expect(unreadState(CHANNEL.dm)).toEqual({unread: false, unreadCount: 0, mentionCount: 0, ackMessageId: null});
expect(ReadStates.mentionChannelIds).not.toContain(CHANNEL.dm);
channelCreate(dm(CHANNEL.dm, MESSAGE.incoming));
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA));
expect(unreadState(CHANNEL.dm)).toEqual({
unread: true,
unreadCount: 1,
mentionCount: 3,
ackMessageId: MESSAGE.older,
});
});
it('keeps a closed DM out of every private channel count while its entry survives', () => {
ready([readState(CHANNEL.dm, MESSAGE.older, 2)], [dm(CHANNEL.dm, MESSAGE.last)]);
expect(ReadStates.isUnreadOrMentioned(CHANNEL.dm)).toBe(true);
channelDelete({id: CHANNEL.dm, type: ChannelTypes.DM});
expect(ReadStates.getChannelIds()).toContain(CHANNEL.dm);
expect(ReadStates.hasUnreadPrivateChannel(CHANNEL.dm)).toBe(false);
expect(ReadStates.getPrivateChannelUnreadCount(CHANNEL.dm)).toBe(0);
expect(ReadStates.getPrivateChannelMentionCount(CHANNEL.dm)).toBe(0);
expect(ReadStates.isUnreadOrMentioned(CHANNEL.dm)).toBe(false);
});
it('forgets the view state of a DM when it is closed', () => {
ready([readState(CHANNEL.dm, MESSAGE.older)], [dm(CHANNEL.dm, MESSAGE.last)]);
const state = ReadStates.get(CHANNEL.dm);
state.messagesLoaded = true;
state.ackedManually = true;
state.stickyUnreadMessageId = MESSAGE.last;
state.unreadCount = 3;
state.oldestUnreadMessageId = MESSAGE.last;
state.estimated = true;
channelDelete({id: CHANNEL.dm, type: ChannelTypes.DM});
expect(ReadStates.getIfExists(CHANNEL.dm)).toMatchObject({
messagesLoaded: false,
ackedManually: false,
stickyUnreadMessageId: null,
ackMessageId: MESSAGE.older,
unreadCount: 0,
oldestUnreadMessageId: null,
estimated: false,
});
});
it('keeps an ack that arrives while the DM is closed', () => {
ready([readState(CHANNEL.dm, MESSAGE.older, 1)], [dm(CHANNEL.dm, MESSAGE.last)]);
channelDelete({id: CHANNEL.dm, type: ChannelTypes.DM});
ReadStates.handleMessageAck({
channelId: CHANNEL.dm,
messageId: MESSAGE.last,
mentionCount: 0,
manual: false,
version: '2',
});
channelCreate(dm(CHANNEL.dm, MESSAGE.incoming));
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA));
expect(unreadState(CHANNEL.dm)).toEqual({
unread: true,
unreadCount: 1,
mentionCount: 1,
ackMessageId: MESSAGE.last,
});
});
it('ignores an older ack that arrives while the DM is closed', () => {
ready([readState(CHANNEL.dm, MESSAGE.last, 1)], [dm(CHANNEL.dm, MESSAGE.last)]);
channelDelete({id: CHANNEL.dm, type: ChannelTypes.DM});
ReadStates.handleMessageAck({
channelId: CHANNEL.dm,
messageId: MESSAGE.older,
mentionCount: 9,
manual: false,
version: '2',
});
channelCreate(dm(CHANNEL.dm, MESSAGE.incoming));
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA));
expect(unreadState(CHANNEL.dm)).toEqual({
unread: true,
unreadCount: 1,
mentionCount: 2,
ackMessageId: MESSAGE.last,
});
});
it('applies a manual ack that arrives while the DM is closed', () => {
ready([readState(CHANNEL.dm, MESSAGE.older, 2)], [dm(CHANNEL.dm, MESSAGE.last)]);
channelDelete({id: CHANNEL.dm, type: ChannelTypes.DM});
ReadStates.handleMessageAck({
channelId: CHANNEL.dm,
messageId: MESSAGE.last,
mentionCount: 0,
manual: true,
version: '2',
});
channelCreate(dm(CHANNEL.dm, MESSAGE.last));
expect(unreadState(CHANNEL.dm)).toEqual({
unread: false,
unreadCount: 0,
mentionCount: 0,
ackMessageId: MESSAGE.last,
});
});
it('still sends the ack for a DM read and closed within the batch delay', async () => {
ready([readState(CHANNEL.dm, MESSAGE.older)], [dm(CHANNEL.dm, MESSAGE.last)]);
ReadStates.handleChannelAck({channelId: CHANNEL.dm});
channelDelete({id: CHANNEL.dm, type: ChannelTypes.DM});
await vi.advanceTimersByTimeAsync(ACK_BATCH_DELAY_MS);
expect(http.post).toHaveBeenCalledWith(Endpoints.READ_STATES_ACK, {
body: {read_states: [{channel_id: CHANNEL.dm, message_id: MESSAGE.last}]},
});
channelCreate(dm(CHANNEL.dm, MESSAGE.last));
expect(unreadState(CHANNEL.dm)).toEqual({
unread: false,
unreadCount: 0,
mentionCount: 0,
ackMessageId: MESSAGE.last,
});
});
it('shows the first messages from a new contact as unread mentions', () => {
ready([], []);
channelCreate(dm(CHANNEL.newDm, MESSAGE.incoming));
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.newDm, TUNA));
expect(unreadState(CHANNEL.newDm)).toEqual({unread: true, unreadCount: 1, mentionCount: 1, ackMessageId: null});
expect(ReadStates.getOldestUnreadMessageId(CHANNEL.newDm)).toBe(MESSAGE.incoming);
expect(ReadStates.getIfExists(CHANNEL.newDm)?.readStateKnown).toBe(true);
messageCreate(wireMessage(MESSAGE.next, CHANNEL.newDm, TUNA));
expect(unreadState(CHANNEL.newDm)).toEqual({unread: true, unreadCount: 2, mentionCount: 2, ackMessageId: null});
});
it('keeps a message already loaded into a DM without read state covered', () => {
ready([], []);
channelCreate(dm(CHANNEL.dm, MESSAGE.last));
loadedMessages.push({id: MESSAGE.last, author: {id: TUNA}}, {id: MESSAGE.incoming, author: {id: TUNA}});
ReadStates.handleLoadMessages({channelId: CHANNEL.dm, messages: []});
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA));
expect(unreadState(CHANNEL.dm)).toEqual({unread: false, unreadCount: 0, mentionCount: 0, ackMessageId: null});
expect(ReadStates.getIfExists(CHANNEL.dm)?.readStateKnown).toBe(false);
});
it('characterisation: keeps a self-opened DM read when its read state covers the last message', () => {
ready([readState(CHANNEL.dm, MESSAGE.last)], []);
channelCreate(dm(CHANNEL.dm, MESSAGE.last));
expect(unreadState(CHANNEL.dm)).toEqual({
unread: false,
unreadCount: 0,
mentionCount: 0,
ackMessageId: MESSAGE.last,
});
});
it('shows a self-opened DM as unread when READY had it unread', () => {
ready([readState(CHANNEL.dm, MESSAGE.older)], []);
channelCreate(dm(CHANNEL.dm, MESSAGE.last));
expect(unreadState(CHANNEL.dm)).toEqual({
unread: true,
unreadCount: 0,
mentionCount: 0,
ackMessageId: MESSAGE.older,
});
});
it('leaves a self-opened DM without read state unknown', () => {
ready([], []);
channelCreate(dm(CHANNEL.dm, MESSAGE.last));
expect(unreadState(CHANNEL.dm)).toEqual({unread: false, unreadCount: 0, mentionCount: 0, ackMessageId: null});
expect(ReadStates.getIfExists(CHANNEL.dm)?.readStateKnown).toBe(false);
});
it('keeps an open unread DM unread when a duplicate CHANNEL_CREATE arrives', () => {
ready([readState(CHANNEL.dm, MESSAGE.older)], [dm(CHANNEL.dm, MESSAGE.last)]);
channelCreate(dm(CHANNEL.dm, MESSAGE.last));
expect(unreadState(CHANNEL.dm)).toEqual({
unread: true,
unreadCount: 0,
mentionCount: 0,
ackMessageId: MESSAGE.older,
});
});
it("characterisation: acks the current user's first message in a new DM and counts the reply", () => {
ready([], []);
channelCreate(dm(CHANNEL.newDm, null));
expect(ReadStates.hasUnread(CHANNEL.newDm)).toBe(false);
messageCreate(wireMessage(MESSAGE.own, CHANNEL.newDm, ME));
expect(unreadState(CHANNEL.newDm)).toEqual({
unread: false,
unreadCount: 0,
mentionCount: 0,
ackMessageId: MESSAGE.own,
});
messageCreate(wireMessage(MESSAGE.reply, CHANNEL.newDm, TUNA));
expect(unreadState(CHANNEL.newDm)).toEqual({
unread: true,
unreadCount: 1,
mentionCount: 1,
ackMessageId: MESSAGE.own,
});
});
it("acks the current user's message into a reopened DM that READY had unread", () => {
ready([readState(CHANNEL.dm, MESSAGE.older)], []);
channelCreate(dm(CHANNEL.dm, MESSAGE.last));
expect(ReadStates.hasUnread(CHANNEL.dm)).toBe(true);
messageCreate(wireMessage(MESSAGE.own, CHANNEL.dm, ME));
expect(unreadState(CHANNEL.dm)).toEqual({
unread: false,
unreadCount: 0,
mentionCount: 0,
ackMessageId: MESSAGE.own,
});
});
it("characterisation: acks the current user's message that CHANNEL_CREATE already carries", () => {
ready([], []);
channelCreate(dm(CHANNEL.newDm, MESSAGE.own));
messageCreate(wireMessage(MESSAGE.own, CHANNEL.newDm, ME));
expect(unreadState(CHANNEL.newDm)).toEqual({
unread: false,
unreadCount: 0,
mentionCount: 0,
ackMessageId: MESSAGE.own,
});
});
it('characterisation: counts only the recipient add message when the user joins a group DM with history', () => {
ready([], []);
channelCreate(groupDm(MESSAGE.last));
expect(ReadStates.hasUnread(CHANNEL.groupDm)).toBe(false);
messageCreate(recipientAddMessage());
expect(unreadState(CHANNEL.groupDm)).toEqual({
unread: true,
unreadCount: 1,
mentionCount: 1,
ackMessageId: MESSAGE.last,
});
});
it('characterisation: counts the recipient add message when the user joins an empty group DM', () => {
ready([], []);
channelCreate(groupDm(null));
messageCreate(recipientAddMessage());
expect(unreadState(CHANNEL.groupDm)).toEqual({unread: true, unreadCount: 1, mentionCount: 1, ackMessageId: null});
});
it('keeps the read state of a group DM the user leaves and rejoins', () => {
ready([readState(CHANNEL.groupDm, MESSAGE.older)], [groupDm(MESSAGE.older)]);
channelDelete({id: CHANNEL.groupDm, type: ChannelTypes.GROUP_DM});
channelCreate(groupDm(MESSAGE.last));
expect(unreadState(CHANNEL.groupDm)).toEqual({
unread: true,
unreadCount: 0,
mentionCount: 0,
ackMessageId: MESSAGE.older,
});
messageCreate(recipientAddMessage());
expect(unreadState(CHANNEL.groupDm)).toEqual({
unread: true,
unreadCount: 1,
mentionCount: 1,
ackMessageId: MESSAGE.older,
});
});
it('characterisation: drops the pending ack of a group DM the user leaves', async () => {
ready([readState(CHANNEL.groupDm, MESSAGE.older)], [groupDm(MESSAGE.last)]);
ReadStates.handleChannelAck({channelId: CHANNEL.groupDm});
channelDelete({id: CHANNEL.groupDm, type: ChannelTypes.GROUP_DM});
await vi.advanceTimersByTimeAsync(ACK_BATCH_DELAY_MS);
expect(http.post).not.toHaveBeenCalled();
});
it('keeps the read state of personal notes closed and reopened', () => {
const notes: WireChannel = {
id: CHANNEL.notes,
type: ChannelTypes.DM_PERSONAL_NOTES,
last_message_id: MESSAGE.older,
last_pin_timestamp: null,
};
ready([readState(CHANNEL.notes, MESSAGE.older)], [notes]);
channelDelete({id: CHANNEL.notes, type: ChannelTypes.DM_PERSONAL_NOTES});
channelCreate({...notes, last_message_id: MESSAGE.last});
expect(unreadState(CHANNEL.notes)).toEqual({
unread: true,
unreadCount: 0,
mentionCount: 0,
ackMessageId: MESSAGE.older,
});
});
it('characterisation: acks own messages in personal notes', () => {
ready([], []);
channelCreate({
id: CHANNEL.notes,
type: ChannelTypes.DM_PERSONAL_NOTES,
last_message_id: MESSAGE.own,
last_pin_timestamp: null,
});
messageCreate(wireMessage(MESSAGE.own, CHANNEL.notes, ME));
expect(unreadState(CHANNEL.notes)).toEqual({
unread: false,
unreadCount: 0,
mentionCount: 0,
ackMessageId: MESSAGE.own,
});
});
it('characterisation: auto-acks a message in the open DM pinned to the bottom', () => {
ready([readState(CHANNEL.dm, MESSAGE.older)], [dm(CHANNEL.dm, MESSAGE.older)]);
ReadStates.handleLoadMessages({channelId: CHANNEL.dm, messages: []});
automaticAck = true;
pinnedToEnd = true;
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA));
expect(unreadState(CHANNEL.dm)).toEqual({
unread: false,
unreadCount: 0,
mentionCount: 0,
ackMessageId: MESSAGE.incoming,
});
expect(ReadStates.getIfExists(CHANNEL.dm)?.inFlightAckMessageId).toBe(MESSAGE.incoming);
});
it('characterisation: keeps a DM reopened by a blocked author read', () => {
blockedUserIds.add(TUNA);
ready([readState(CHANNEL.dm, MESSAGE.older)], []);
channelCreate(dm(CHANNEL.dm, MESSAGE.incoming));
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA));
expect(unreadState(CHANNEL.dm)).toEqual({
unread: false,
unreadCount: 0,
mentionCount: 0,
ackMessageId: MESSAGE.incoming,
});
});
it('characterisation: keeps a first DM from a blocked author read', () => {
blockedUserIds.add(TUNA);
ready([], []);
channelCreate(dm(CHANNEL.newDm, MESSAGE.incoming));
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.newDm, TUNA));
expect(unreadState(CHANNEL.newDm)).toEqual({
unread: false,
unreadCount: 0,
mentionCount: 0,
ackMessageId: MESSAGE.incoming,
});
});
it('shows the call message that reopens a DM with read state as unread', () => {
ready([readState(CHANNEL.dm, MESSAGE.older)], []);
channelCreate(dm(CHANNEL.dm, MESSAGE.last));
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA, {type: MessageTypes.CALL}));
expect(unreadState(CHANNEL.dm)).toEqual({
unread: true,
unreadCount: 1,
mentionCount: 1,
ackMessageId: MESSAGE.older,
});
});
it('characterisation: shows the call message that reopens a DM without read state as unread', () => {
ready([], []);
channelCreate(dm(CHANNEL.dm, MESSAGE.last));
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA, {type: MessageTypes.CALL}));
expect(unreadState(CHANNEL.dm)).toEqual({
unread: true,
unreadCount: 1,
mentionCount: 1,
ackMessageId: MESSAGE.last,
});
});
it('characterisation: keeps a guild message covered when read state is unknown and the watermark already equals it', () => {
ready([], [guildText(MESSAGE.incoming)]);
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.guildText, TUNA, {guild_id: 'guild-1'}));
expect(unreadState(CHANNEL.guildText)).toEqual({
unread: false,
unreadCount: 0,
mentionCount: 0,
ackMessageId: null,
});
expect(ReadStates.getIfExists(CHANNEL.guildText)?.readStateKnown).toBe(false);
});
it('characterisation: still forgets a guild channel that a visibility change hides and shows again', () => {
ready([readState(CHANNEL.guildText, MESSAGE.older, 2)], [guildText(MESSAGE.last)]);
expect(unreadState(CHANNEL.guildText)).toEqual({
unread: true,
unreadCount: 0,
mentionCount: 2,
ackMessageId: MESSAGE.older,
});
channelDelete({id: CHANNEL.guildText, guild_id: 'guild-1'});
channelCreate(guildText(MESSAGE.last));
expect(unreadState(CHANNEL.guildText)).toEqual({
unread: false,
unreadCount: 0,
mentionCount: 0,
ackMessageId: null,
});
});
});
@@ -482,7 +482,10 @@ class ReadStates {
if (action.message.guild_id != null) {
state.storedGuildId = action.message.guild_id;
}
const previousLastMessageId = state.lastMessageId;
const previousLastMessageId =
state.isPrivate && !state.messagesLoaded && state.lastMessageId === action.message.id
? null
: state.lastMessageId;
const currentUser = Users.getCurrentUser();
const authorBlocked = Relationships.isBlocked(action.message.author.id);
const hadUnreadOrMentions = state.isUnreadOrMentioned();
@@ -568,17 +571,6 @@ class ReadStates {
state.readStateKnown = archivedState.readStateKnown;
this.archivedStates.delete(action.channel.id as ChannelId);
}
if (
(action.channel.type === ChannelTypes.DM ||
action.channel.type === ChannelTypes.GROUP_DM ||
action.channel.type === ChannelTypes.DM_PERSONAL_NOTES) &&
action.channel.last_message_id != null
) {
state.readStateKnown = true;
state.ackMessageId = action.channel.last_message_id;
} else if (GUILD_TEXT_BASED_CHANNEL_TYPES.has(action.channel.type) && state.hasUnread()) {
this.clearUnreadStateIfRead(state);
}
this.notifyChange(action.channel.id);
}
@@ -626,6 +618,25 @@ class ReadStates {
guild_id?: string;
};
}): void {
const state = this.getIfExists(action.channel.id);
if (
state != null &&
(action.channel.type === ChannelTypes.DM ||
action.channel.type === ChannelTypes.GROUP_DM ||
action.channel.type === ChannelTypes.DM_PERSONAL_NOTES)
) {
if (action.channel.type === ChannelTypes.GROUP_DM) {
this.cancelPendingAck(action.channel.id);
}
state.messagesLoaded = false;
state.ackedManually = false;
state.clearStickyUnread();
state.estimated = false;
state.unreadCount = 0;
state.oldestUnreadMessageId = null;
this.notifyChange(action.channel.id);
return;
}
if (action.channel.guild_id != null && GUILD_TEXT_BASED_CHANNEL_TYPES.has(action.channel.type ?? -1)) {
this.archiveState(action.channel.id);
}
@@ -0,0 +1,325 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UpdaterDownloadOption} from '@app/features/platform/types/Electron';
import {describe, expect, it, vi} from 'vitest';
import {buildLinuxManualUpdateOptions} from './LinuxManualUpdateOptions';
vi.mock('@app/features/app/config/Config', () => ({
default: {
PUBLIC_BUILD_VERSION: 'test',
PUBLIC_RELEASE_CHANNEL: 'canary',
PUBLIC_BOOTSTRAP_API_ENDPOINT: 'https://example.invalid',
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: 'https://example.invalid',
},
}));
const REPORTED_VERSION = '2026.908.173325';
const NEWER_VERSION = '2026.909.202036';
const CANARY_X64 = 'https://api.canary.fluxer.app/dl/desktop/canary/linux/x64';
const STABLE_X64 = 'https://api.fluxer.app/dl/desktop/stable/linux/x64';
function desktopMainOptions(params: {
linkBase: string;
productName: string;
version: string;
linkVersion: string;
}): Array<UpdaterDownloadOption> {
const {linkBase, productName, version, linkVersion} = params;
return [
{
format: 'appimage',
label: 'AppImage',
url: `${linkBase}/${linkVersion}/appimage`,
suggestedName: `${productName}-${version}-linux-x86_64.AppImage`,
sha256: `appimage-${version}`,
},
{
format: 'deb',
label: 'DEB package',
url: `${linkBase}/${linkVersion}/deb`,
suggestedName: `${productName}-${version}-linux-amd64.deb`,
sha256: `deb-${version}`,
},
{
format: 'rpm',
label: 'RPM package',
url: `${linkBase}/${linkVersion}/rpm`,
suggestedName: `${productName}-${version}-linux-x86_64.rpm`,
sha256: `rpm-${version}`,
},
{
format: 'tar_gz',
label: 'tar.gz archive',
url: `${linkBase}/${linkVersion}/tar_gz`,
suggestedName: `${productName}-${version}-linux-x64.tar.gz`,
sha256: `tar_gz-${version}`,
},
];
}
describe('buildLinuxManualUpdateOptions', () => {
it('pins the link, file name and checksum of every format to the same version', () => {
const options = buildLinuxManualUpdateOptions({channel: 'stable', arch: 'x64', version: REPORTED_VERSION});
expect(options).toEqual([
{
format: 'appimage',
label: 'AppImage',
url: `${STABLE_X64}/${REPORTED_VERSION}/appimage`,
suggestedName: `Fluxer-${REPORTED_VERSION}-linux-x86_64.AppImage`,
sha256: null,
},
{
format: 'deb',
label: 'DEB package',
url: `${STABLE_X64}/${REPORTED_VERSION}/deb`,
suggestedName: `Fluxer-${REPORTED_VERSION}-linux-amd64.deb`,
sha256: null,
},
{
format: 'rpm',
label: 'RPM package',
url: `${STABLE_X64}/${REPORTED_VERSION}/rpm`,
suggestedName: `Fluxer-${REPORTED_VERSION}-linux-x86_64.rpm`,
sha256: null,
},
{
format: 'tar_gz',
label: 'tar.gz archive',
url: `${STABLE_X64}/${REPORTED_VERSION}/tar_gz`,
suggestedName: `Fluxer-${REPORTED_VERSION}-linux-x64.tar.gz`,
sha256: null,
},
]);
});
it('pins the latest links sent by current desktop builds to the announced version', () => {
const options = buildLinuxManualUpdateOptions({
downloadUrl: `${CANARY_X64}/latest/appimage`,
channel: 'canary',
arch: 'x64',
version: REPORTED_VERSION,
knownOptions: desktopMainOptions({
linkBase: CANARY_X64,
productName: 'Fluxer-Canary',
version: REPORTED_VERSION,
linkVersion: 'latest',
}),
});
expect(options).toEqual(
desktopMainOptions({
linkBase: CANARY_X64,
productName: 'Fluxer-Canary',
version: REPORTED_VERSION,
linkVersion: REPORTED_VERSION,
}),
);
});
it('keeps the pinned links sent by newer desktop builds', () => {
const pinnedOptions = desktopMainOptions({
linkBase: CANARY_X64,
productName: 'Fluxer-Canary',
version: REPORTED_VERSION,
linkVersion: REPORTED_VERSION,
});
const options = buildLinuxManualUpdateOptions({
downloadUrl: pinnedOptions[0].url,
channel: 'canary',
arch: 'x64',
version: REPORTED_VERSION,
knownOptions: pinnedOptions,
});
expect(options).toEqual(pinnedOptions);
});
it('only uses latest links and latest file names when no version is known', () => {
const options = buildLinuxManualUpdateOptions({
downloadUrl: `${STABLE_X64}/latest/appimage`,
channel: 'stable',
arch: 'x64',
version: null,
knownOptions: desktopMainOptions({
linkBase: STABLE_X64,
productName: 'Fluxer',
version: REPORTED_VERSION,
linkVersion: 'latest',
}),
});
expect(options.map(({url, suggestedName, sha256}) => ({url, suggestedName, sha256}))).toEqual([
{url: `${STABLE_X64}/latest/appimage`, suggestedName: 'Fluxer-latest-linux-x86_64.AppImage', sha256: null},
{url: `${STABLE_X64}/latest/deb`, suggestedName: 'Fluxer-latest-linux-amd64.deb', sha256: null},
{url: `${STABLE_X64}/latest/rpm`, suggestedName: 'Fluxer-latest-linux-x86_64.rpm', sha256: null},
{url: `${STABLE_X64}/latest/tar_gz`, suggestedName: 'Fluxer-latest-linux-x64.tar.gz', sha256: null},
]);
});
it('uses arm64 links and file name tokens on arm64 systems', () => {
const expected = [
{
url: `https://api.fluxer.app/dl/desktop/stable/linux/arm64/${REPORTED_VERSION}/appimage`,
suggestedName: `Fluxer-${REPORTED_VERSION}-linux-arm64.AppImage`,
},
{
url: `https://api.fluxer.app/dl/desktop/stable/linux/arm64/${REPORTED_VERSION}/deb`,
suggestedName: `Fluxer-${REPORTED_VERSION}-linux-arm64.deb`,
},
{
url: `https://api.fluxer.app/dl/desktop/stable/linux/arm64/${REPORTED_VERSION}/rpm`,
suggestedName: `Fluxer-${REPORTED_VERSION}-linux-aarch64.rpm`,
},
{
url: `https://api.fluxer.app/dl/desktop/stable/linux/arm64/${REPORTED_VERSION}/tar_gz`,
suggestedName: `Fluxer-${REPORTED_VERSION}-linux-arm64.tar.gz`,
},
];
const fromDesktopInfo = buildLinuxManualUpdateOptions({
channel: 'stable',
arch: 'aarch64',
version: REPORTED_VERSION,
});
const fromLatestLink = buildLinuxManualUpdateOptions({
downloadUrl: 'https://api.fluxer.app/dl/desktop/stable/linux/arm64/latest/appimage',
version: REPORTED_VERSION,
});
const fromPinnedLink = buildLinuxManualUpdateOptions({
downloadUrl: `https://api.fluxer.app/dl/desktop/stable/linux/arm64/${REPORTED_VERSION}/deb`,
version: REPORTED_VERSION,
});
for (const options of [fromDesktopInfo, fromLatestLink, fromPinnedLink]) {
expect(options.map(({url, suggestedName}) => ({url, suggestedName}))).toEqual(expected);
}
});
it('preserves the query string of latest and pinned links', () => {
const fromLatestLink = buildLinuxManualUpdateOptions({
downloadUrl: `${CANARY_X64}/latest/appimage?test=1`,
version: REPORTED_VERSION,
});
const fromPinnedLink = buildLinuxManualUpdateOptions({
downloadUrl: `${CANARY_X64}/${REPORTED_VERSION}/appimage?test=1`,
version: REPORTED_VERSION,
});
const expectedUrls = [
`${CANARY_X64}/${REPORTED_VERSION}/appimage?test=1`,
`${CANARY_X64}/${REPORTED_VERSION}/deb?test=1`,
`${CANARY_X64}/${REPORTED_VERSION}/rpm?test=1`,
`${CANARY_X64}/${REPORTED_VERSION}/tar_gz?test=1`,
];
expect(fromLatestLink.map((option) => option.url)).toEqual(expectedUrls);
expect(fromPinnedLink.map((option) => option.url)).toEqual(expectedUrls);
});
it('builds links on the stable and canary endpoints', () => {
const stable = buildLinuxManualUpdateOptions({channel: 'stable', arch: 'x64', version: REPORTED_VERSION});
const canary = buildLinuxManualUpdateOptions({channel: 'canary', arch: 'x64', version: REPORTED_VERSION});
const configuredCanary = buildLinuxManualUpdateOptions({
channel: 'canary',
arch: 'x64',
version: REPORTED_VERSION,
apiEndpoint: 'https://web.canary.fluxer.app/api/',
});
const proxiedCanaryLink = buildLinuxManualUpdateOptions({
downloadUrl: 'https://web.canary.fluxer.app/api/dl/desktop/canary/linux/x64/latest/deb',
version: REPORTED_VERSION,
apiEndpoint: 'https://api.fluxer.app',
});
expect(stable[1].url).toBe(`${STABLE_X64}/${REPORTED_VERSION}/deb`);
expect(canary[1].url).toBe(`${CANARY_X64}/${REPORTED_VERSION}/deb`);
expect(configuredCanary[1].url).toBe(
`https://web.canary.fluxer.app/api/dl/desktop/canary/linux/x64/${REPORTED_VERSION}/deb`,
);
expect(proxiedCanaryLink[1].url).toBe(
`https://web.canary.fluxer.app/api/dl/desktop/canary/linux/x64/${REPORTED_VERSION}/deb`,
);
});
it('never points a prompt for 2026.908.173325 at a link that could serve another build', () => {
const options = buildLinuxManualUpdateOptions({
downloadUrl: `${CANARY_X64}/latest/appimage`,
channel: 'canary',
arch: 'x64',
version: REPORTED_VERSION,
apiEndpoint: 'https://api.canary.fluxer.app',
knownOptions: desktopMainOptions({
linkBase: CANARY_X64,
productName: 'Fluxer-Canary',
version: REPORTED_VERSION,
linkVersion: 'latest',
}),
});
expect(options.find((option) => option.format === 'deb')).toMatchObject({
url: `${CANARY_X64}/${REPORTED_VERSION}/deb`,
suggestedName: `Fluxer-Canary-${REPORTED_VERSION}-linux-amd64.deb`,
sha256: `deb-${REPORTED_VERSION}`,
});
for (const option of options) {
expect(option.url).toContain(`/${REPORTED_VERSION}/`);
expect(option.url).not.toContain('/latest/');
expect(option.url).not.toContain(NEWER_VERSION);
expect(option.suggestedName).toContain(REPORTED_VERSION);
}
});
it.each([
'v2026.908.173325',
'2026.908',
'2026.908.173325-beta',
])('treats the off-shape version %s as unknown', (version) => {
const options = buildLinuxManualUpdateOptions({
downloadUrl: `${STABLE_X64}/latest/appimage`,
version,
knownOptions: desktopMainOptions({
linkBase: STABLE_X64,
productName: 'Fluxer',
version,
linkVersion: 'latest',
}),
});
expect(options[1]).toEqual({
format: 'deb',
label: 'DEB package',
url: `${STABLE_X64}/latest/deb`,
suggestedName: 'Fluxer-latest-linux-amd64.deb',
sha256: null,
});
});
it('ignores a download link that is not for Linux', () => {
const options = buildLinuxManualUpdateOptions({
downloadUrl: 'https://api.fluxer.app/dl/desktop/stable/darwin/arm64/latest/dmg?test=1',
channel: 'canary',
arch: 'x64',
version: REPORTED_VERSION,
});
expect(options.map((option) => option.url)).toEqual([
`${CANARY_X64}/${REPORTED_VERSION}/appimage`,
`${CANARY_X64}/${REPORTED_VERSION}/deb`,
`${CANARY_X64}/${REPORTED_VERSION}/rpm`,
`${CANARY_X64}/${REPORTED_VERSION}/tar_gz`,
]);
});
it('reads the architecture of a known package from its pinned link before its file name', () => {
const options = buildLinuxManualUpdateOptions({
channel: 'stable',
arch: 'x64',
version: REPORTED_VERSION,
knownOptions: [
{
format: 'deb',
label: 'DEB package',
url: `https://api.fluxer.app/dl/desktop/stable/linux/arm64/${REPORTED_VERSION}/deb`,
suggestedName: `Fluxer-${REPORTED_VERSION}-linux-amd64.deb`,
sha256: 'arm64-deb',
},
],
});
expect(options[1]).toEqual({
format: 'deb',
label: 'DEB package',
url: `${STABLE_X64}/${REPORTED_VERSION}/deb`,
suggestedName: `Fluxer-${REPORTED_VERSION}-linux-amd64.deb`,
sha256: null,
});
});
});
@@ -35,7 +35,7 @@ const DEFAULT_API_ENDPOINTS: Record<DesktopDownloadChannel, string> = {
canary: 'https://api.canary.fluxer.app',
};
interface ParsedLinuxLatestDownloadUrl {
interface ParsedLinuxDownloadUrl {
apiEndpoint: string;
channel: DesktopDownloadChannel;
arch: LinuxDownloadArch;
@@ -79,7 +79,7 @@ function normalizeApiEndpoint(value: string | null | undefined, channel: Desktop
return DEFAULT_API_ENDPOINTS[channel];
}
function parseLinuxLatestDownloadUrl(value: string | null | undefined): ParsedLinuxLatestDownloadUrl | null {
function parseLinuxDownloadUrl(value: string | null | undefined): ParsedLinuxDownloadUrl | null {
if (!value) {
return null;
}
@@ -94,7 +94,8 @@ function parseLinuxLatestDownloadUrl(value: string | null | undefined): ParsedLi
const platform = segments[dlIndex + 3];
const arch = normalizeLinuxDownloadArch(segments[dlIndex + 4]);
const version = segments[dlIndex + 5];
if (platform !== 'linux' || version !== 'latest') {
const format = segments[dlIndex + 6];
if (platform !== 'linux' || !version || !format) {
return null;
}
const endpointSegments = segments.slice(0, dlIndex);
@@ -126,7 +127,7 @@ function getLinuxDownloadArchFromText(value: string | null | undefined): LinuxDo
function getLinuxDownloadOptionArch(option: UpdaterDownloadOption): LinuxDownloadArch | null {
return (
parseLinuxLatestDownloadUrl(option.url)?.arch ??
parseLinuxDownloadUrl(option.url)?.arch ??
getLinuxDownloadArchFromText(option.suggestedName) ??
getLinuxDownloadArchFromText(option.label)
);
@@ -144,14 +145,15 @@ function getKnownLinuxManualOption(
);
}
function buildLinuxLatestDownloadUrl(params: {
function buildLinuxDownloadUrl(params: {
apiEndpoint: string;
channel: DesktopDownloadChannel;
arch: LinuxDownloadArch;
versionToken: string;
format: LinuxManualDownloadFormat;
search: string;
}): string {
return `${params.apiEndpoint}/dl/desktop/${params.channel}/linux/${params.arch}/latest/${params.format}${params.search}`;
return `${params.apiEndpoint}/dl/desktop/${params.channel}/linux/${params.arch}/${params.versionToken}/${params.format}${params.search}`;
}
function getModernProductName(channel: DesktopDownloadChannel): string {
@@ -162,27 +164,30 @@ function getSuggestedName(
format: LinuxManualDownloadFormat,
channel: DesktopDownloadChannel,
arch: LinuxDownloadArch,
version: string | null | undefined,
versionToken: string,
): string {
const versionToken = version?.trim() || 'latest';
const archToken = LINUX_MANUAL_ARCH_TOKENS[format][arch];
const extension = LINUX_MANUAL_FORMAT_EXTENSIONS[format];
return `${getModernProductName(channel)}-${versionToken}-linux-${archToken}${extension}`;
}
export function buildLinuxManualUpdateOptions(input: LinuxManualUpdateOptionsInput): Array<UpdaterDownloadOption> {
const parsedUrl = parseLinuxLatestDownloadUrl(input.downloadUrl);
const parsedUrl = parseLinuxDownloadUrl(input.downloadUrl);
const channel = parsedUrl?.channel ?? normalizeDesktopDownloadChannel(input.channel);
const arch = normalizeLinuxDownloadArchOrNull(input.arch) ?? parsedUrl?.arch ?? 'x64';
const apiEndpoint = parsedUrl?.apiEndpoint ?? normalizeApiEndpoint(input.apiEndpoint, channel);
const search = parsedUrl?.search ?? '';
const version = input.version?.trim() ?? '';
const hasVersion = /^\d+\.\d+\.\d+$/u.test(version);
const versionToken = hasVersion ? version : 'latest';
const knownOptions = hasVersion ? (input.knownOptions ?? []) : [];
return LINUX_MANUAL_DOWNLOAD_FORMATS.map((format) => {
const knownOption = getKnownLinuxManualOption(input.knownOptions ?? [], format, arch);
const knownOption = getKnownLinuxManualOption(knownOptions, format, arch);
return {
format,
label: LINUX_MANUAL_FORMAT_LABELS[format],
url: buildLinuxLatestDownloadUrl({apiEndpoint, channel, arch, format, search}),
suggestedName: knownOption?.suggestedName ?? getSuggestedName(format, channel, arch, input.version),
url: buildLinuxDownloadUrl({apiEndpoint, channel, arch, versionToken, format, search}),
suggestedName: knownOption?.suggestedName ?? getSuggestedName(format, channel, arch, versionToken),
sha256: knownOption?.sha256 ?? null,
};
});
@@ -12,6 +12,7 @@ import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet';
import Guilds from '@app/features/guild/state/Guilds';
import {dropTrailingEmptyBlockquoteLines} from '@app/features/lexical/composer/blockquoteLines';
import type {LexicalRichInputHandle} from '@app/features/lexical/composer/LexicalRichInput';
import * as GuildMemberCommands from '@app/features/member/commands/GuildMemberCommands';
import GuildMembers from '@app/features/member/state/GuildMembers';
@@ -448,7 +449,7 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
const handleBioChange = useCallback((display: string, segments: Array<MentionSegment>, wire: string) => {
setBioValue(display);
setBioSegments(segments);
setBioActualValue(wire);
setBioActualValue(dropTrailingEmptyBlockquoteLines(wire));
}, []);
const onSubmit = useCallback(
async (data: FormInputs) => {
@@ -0,0 +1,61 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {act, createElement} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {beforeEach, describe, expect, test, vi} from 'vitest';
const preload = vi.hoisted(() => ({
start: vi.fn(),
stop: vi.fn(),
}));
vi.mock('@app/features/voice/utils/MediaDeviceStartupPreload', () => ({
startMediaDeviceStartupPreload: () => {
preload.start();
return preload.stop;
},
}));
const {MediaDeviceStartupPreloadManager} = await import(
'@app/features/voice/components/MediaDeviceStartupPreloadManager'
);
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root;
beforeEach(() => {
vi.clearAllMocks();
document.body.replaceChildren();
const host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
});
describe('MediaDeviceStartupPreloadManager', () => {
test('starts the preload once loaded and stops it on unmount', async () => {
act(() => {
root.render(createElement(MediaDeviceStartupPreloadManager));
});
await vi.waitFor(() => expect(preload.start).toHaveBeenCalledTimes(1));
expect(preload.stop).not.toHaveBeenCalled();
act(() => {
root.unmount();
});
expect(preload.stop).toHaveBeenCalledTimes(1);
});
test('never starts the preload when unmounted before it finishes loading', async () => {
act(() => {
root.render(createElement(MediaDeviceStartupPreloadManager));
});
act(() => {
root.unmount();
});
await import('@app/features/voice/utils/MediaDeviceStartupPreload');
await new Promise((resolve) => setTimeout(resolve, 0));
expect(preload.start).not.toHaveBeenCalled();
expect(preload.stop).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Logger} from '@app/features/platform/utils/AppLogger';
import {loadLazyModule} from '@app/features/platform/utils/LazyModuleLoader';
import {useEffect} from 'react';
const logger = new Logger('MediaDeviceStartupPreloadManager');
export const MediaDeviceStartupPreloadManager: React.FC = () => {
useEffect(() => {
let disposed = false;
let stopPreload: (() => void) | null = null;
void loadLazyModule(() => import('@app/features/voice/utils/MediaDeviceStartupPreload'))
.then(({startMediaDeviceStartupPreload}) => {
if (disposed) return;
stopPreload = startMediaDeviceStartupPreload();
})
.catch((error) => {
logger.warn('Failed to load media device startup preload', {error});
});
return () => {
disposed = true;
stopPreload?.();
};
}, []);
return null;
};
@@ -0,0 +1,162 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
clearVideoFrameWatchersForTests,
getActiveVideoFrameWatcherCountForTests,
watchVideoElementRenderedFrame,
} from '@app/features/voice/components/VideoElementFrameState';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const POLL_TICK_MS = 125;
interface FakeVideoOptions {
supportsFrameCallback: boolean;
readyState?: number;
videoWidth?: number;
videoHeight?: number;
}
interface FakeVideo {
element: HTMLVideoElement;
presentFrame: () => void;
cancelledFrameCallbacks: Array<number>;
listenerCount: () => number;
}
function createFakeVideo(options: FakeVideoOptions): FakeVideo {
const listeners = new Map<string, Set<() => void>>();
const frameCallbacks = new Map<number, () => void>();
const cancelledFrameCallbacks: Array<number> = [];
let nextFrameCallbackHandle = 1;
const element: Record<string, unknown> = {
readyState: options.readyState ?? 0,
videoWidth: options.videoWidth ?? 0,
videoHeight: options.videoHeight ?? 0,
addEventListener: (type: string, listener: () => void): void => {
const existing = listeners.get(type);
if (existing) {
existing.add(listener);
return;
}
listeners.set(type, new Set([listener]));
},
removeEventListener: (type: string, listener: () => void): void => {
listeners.get(type)?.delete(listener);
},
};
if (options.supportsFrameCallback) {
element.requestVideoFrameCallback = (callback: () => void): number => {
const handle = nextFrameCallbackHandle;
nextFrameCallbackHandle += 1;
frameCallbacks.set(handle, callback);
return handle;
};
element.cancelVideoFrameCallback = (handle: number): void => {
cancelledFrameCallbacks.push(handle);
frameCallbacks.delete(handle);
};
}
return {
element: element as unknown as HTMLVideoElement,
presentFrame: (): void => {
for (const [handle, callback] of frameCallbacks) {
frameCallbacks.delete(handle);
callback();
}
},
cancelledFrameCallbacks,
listenerCount: (): number => {
let total = 0;
for (const registered of listeners.values()) {
total += registered.size;
}
return total;
},
};
}
describe('watchVideoElementRenderedFrame', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('window', globalThis);
clearVideoFrameWatchersForTests();
});
afterEach(() => {
clearVideoFrameWatchersForTests();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it('reports a frame from the element the ref switches to after watching starts', () => {
const initial = createFakeVideo({supportsFrameCallback: true});
const videoRef: {current: HTMLVideoElement | null} = {current: initial.element};
const onFrame = vi.fn();
const dispose = watchVideoElementRenderedFrame({videoRef, onFrame});
videoRef.current = createFakeVideo({
supportsFrameCallback: true,
readyState: 2,
videoWidth: 1280,
videoHeight: 720,
}).element;
vi.advanceTimersByTime(POLL_TICK_MS);
expect(onFrame).toHaveBeenCalledTimes(1);
expect(getActiveVideoFrameWatcherCountForTests()).toBe(0);
vi.advanceTimersByTime(POLL_TICK_MS * 4);
expect(onFrame).toHaveBeenCalledTimes(1);
dispose();
});
it('reports one frame and drops the poll target when the frame callback fires first', () => {
const video = createFakeVideo({supportsFrameCallback: true});
const videoRef: {current: HTMLVideoElement | null} = {current: video.element};
const onFrame = vi.fn();
const dispose = watchVideoElementRenderedFrame({videoRef, onFrame});
expect(getActiveVideoFrameWatcherCountForTests()).toBe(1);
video.presentFrame();
expect(onFrame).toHaveBeenCalledTimes(1);
expect(getActiveVideoFrameWatcherCountForTests()).toBe(0);
expect(video.listenerCount()).toBe(0);
expect(vi.getTimerCount()).toBe(0);
vi.advanceTimersByTime(POLL_TICK_MS * 4);
expect(onFrame).toHaveBeenCalledTimes(1);
dispose();
});
it('reports nothing and leaves no interval running when disposed before any frame', () => {
const video = createFakeVideo({supportsFrameCallback: true});
const videoRef: {current: HTMLVideoElement | null} = {current: video.element};
const onFrame = vi.fn();
const dispose = watchVideoElementRenderedFrame({videoRef, onFrame});
dispose();
videoRef.current = createFakeVideo({
supportsFrameCallback: false,
readyState: 2,
videoWidth: 1280,
videoHeight: 720,
}).element;
vi.advanceTimersByTime(POLL_TICK_MS * 4);
expect(onFrame).not.toHaveBeenCalled();
expect(getActiveVideoFrameWatcherCountForTests()).toBe(0);
expect(vi.getTimerCount()).toBe(0);
expect(video.cancelledFrameCallbacks).toHaveLength(1);
expect(video.listenerCount()).toBe(0);
});
});
@@ -117,18 +117,17 @@ export function watchVideoElementRenderedFrame({videoRef, onFrame}: VideoElement
onFrame();
return () => {};
}
removeSharedPollTarget = addSharedVideoFramePollTarget({videoRef, onFrame: markReadyIfPossible});
video?.addEventListener('loadeddata', markReadyIfPossible);
video?.addEventListener('playing', markReadyIfPossible);
video?.addEventListener('resize', markReadyIfPossible);
if (video?.requestVideoFrameCallback) {
frameCallbackHandle = video.requestVideoFrameCallback(() => {
frameCallbackHandle = null;
markPresentedFrame();
});
} else {
removeSharedPollTarget = addSharedVideoFramePollTarget({videoRef, onFrame: markReadyIfPossible});
video?.addEventListener('loadeddata', markReadyIfPossible);
video?.addEventListener('playing', markReadyIfPossible);
video?.addEventListener('resize', markReadyIfPossible);
markReadyIfPossible();
}
markReadyIfPossible();
return () => {
disposed = true;
clearWatchers();
@@ -76,6 +76,7 @@ import {
} from '@app/features/voice/components/voice_participant_tile/hooks';
import LastFrameSnapshotCache from '@app/features/voice/components/voice_participant_tile/LastFrameSnapshotCache';
import {ScreenSharePlaceholder} from '@app/features/voice/components/voice_participant_tile/ScreenSharePlaceholder';
import {screenShareVideoSubscriptionRecoveryCoordinator} from '@app/features/voice/components/voice_participant_tile/ScreenShareVideoSubscriptionRecovery';
import {
CAMERA_BUFFERING_DESCRIPTOR,
CAMERA_HIDDEN_DESCRIPTOR,
@@ -113,6 +114,7 @@ import {
selectVoiceMediaGraphDeferredStopKeys,
selectVoiceMediaGraphFailure,
selectVoiceMediaGraphViewerStreamKeys,
selectVoiceMediaGraphWatchGeneration,
} from '@app/features/voice/engine/VoiceMediaGraph';
import {voiceMediaGraphStore} from '@app/features/voice/engine/VoiceMediaGraphStore';
import {selectVoiceMediaGraphStreamTileState} from '@app/features/voice/engine/VoiceMediaGraphTileState';
@@ -405,11 +407,22 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
const isConnectedToTileChannel =
Boolean(channelId) && MediaEngine.channelId === channelId && MediaEngine.guildId === (guildId ?? null);
const isWatching = isConnectedToTileChannel && graphViewerStreamKeys.includes(streamKey);
const graphTileState = selectVoiceMediaGraphStreamTileState(graphSnapshot, {
streamKey: streamKey || null,
participantIdentity: identity || null,
source: VoiceTrackSource.ScreenShare,
});
const graphWatchGeneration = selectVoiceMediaGraphWatchGeneration(graphSnapshot, streamKey);
const graphTileState = selectVoiceMediaGraphStreamTileState(
graphSnapshot,
{
streamKey: streamKey || null,
participantIdentity: identity || null,
source: VoiceTrackSource.ScreenShare,
},
{
hasRecoveryBudget: screenShareVideoSubscriptionRecoveryCoordinator.hasFirstFrameRecoveryBudget(
streamKey,
graphWatchGeneration,
),
nowMs: voiceMediaGraphStore.nowMs(),
},
);
const graphWatchFailure = isScreenShare
? selectVoiceMediaGraphFailure(graphSnapshot, {
streamKey,
@@ -552,6 +565,7 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
isPublicationDesired,
hasSubscribedVideo: hasSubscribedScreenShareVideo,
operationKey: isScreenShareRepublishBuffering ? `republish:${screenSharePublicationMigrationVersion}` : null,
publication,
videoRef,
});
useStoreVersion(LastFrameSnapshotCache);
@@ -30,6 +30,7 @@ const GRAPH_TILE_STATES: ReadonlyArray<VoiceMediaGraphStreamTileState> = [
'attaching',
'subscribedAwaitingFrame',
'rendering',
'recovering',
'failed',
];
@@ -39,6 +40,7 @@ const WATCH_INTENT_GRAPH_TILE_STATES: ReadonlyArray<VoiceMediaGraphStreamTileSta
'attaching',
'subscribedAwaitingFrame',
'rendering',
'recovering',
'failed',
];
@@ -217,6 +219,18 @@ describe('VoiceParticipantTileStateMachine graph-derived screen share state', ()
expect(selectVoiceParticipantTileScreenShareState(signals({graphTileState: 'rendering'}))).toBe('idle');
});
it('shows buffering while screen share recovery is still running', () => {
expect(selectVoiceParticipantTileScreenShareState(signals({graphTileState: 'recovering'}))).toBe('buffering');
expect(
selectVoiceParticipantTileScreenShareState(signals({graphTileState: 'recovering', isTrackReference: false})),
).toBe('buffering');
});
it('holds the error code back until recovery has given up', () => {
expect(shouldShowWatchFailed(signals({graphTileState: 'recovering'}))).toBe(false);
expect(shouldShowWatchFailed(signals({graphTileState: 'failed'}))).toBe(true);
});
it('shows the watch failed overlay when the graph reports a failure', () => {
expect(selectVoiceParticipantTileScreenShareState(signals({graphTileState: 'failed'}))).toBe('watchFailed');
expect(
@@ -87,6 +87,8 @@ export function graphTileStateHoldsWatchIntent(graphTileState: VoiceMediaGraphSt
return true;
case 'rendering':
return true;
case 'recovering':
return true;
case 'failed':
return true;
case 'idle':
@@ -109,6 +111,8 @@ export function shouldShowScreenShareBuffering(signals: VoiceParticipantTileScre
return true;
case 'subscribedAwaitingFrame':
return true;
case 'recovering':
return true;
case 'publicationMissing':
if (signals.isRepublishGracePending) return true;
return signals.isTrackReference;
@@ -0,0 +1,129 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {screenShareWatchAttemptKey} from '@app/features/voice/components/useScreenShareWatchFailure';
import {
createVoiceMediaGraphSnapshot,
selectVoiceMediaGraphAttempt,
selectVoiceMediaGraphDeadline,
transitionVoiceMediaGraph,
type VoiceMediaGraphSnapshot,
voiceMediaGraphAttemptKeyIsOperation,
voiceMediaGraphWatchAttemptDeadlineKey,
WATCH_ATTEMPT_TIMEOUT_MS,
} from '@app/features/voice/engine/VoiceMediaGraph';
import {
selectVoiceMediaGraphStreamTileState,
type VoiceMediaGraphStreamTileState,
} from '@app/features/voice/engine/VoiceMediaGraphTileState';
import {VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource';
import {describe, expect, it} from 'vitest';
const STREAM_KEY = 'dm:channel-a:connection-a';
const PARTICIPANT_IDENTITY = 'user_2_connection-a';
function watchAttemptKey(trackSid: string | null): string {
return screenShareWatchAttemptKey({streamKey: STREAM_KEY, watchGeneration: 1, trackSid, operationKey: null});
}
describe('screenShareWatchAttemptKey', () => {
it('gives a republished track its own attempt key', () => {
expect(watchAttemptKey('TR_2')).not.toBe(watchAttemptKey('TR_1'));
});
it('puts the stream, the watch generation and the published track in the key', () => {
expect(watchAttemptKey('TR_1')).toBe(`${STREAM_KEY}:1:TR_1:watch`);
});
it('separates a stream with no published track from one with a track', () => {
expect(watchAttemptKey(null)).not.toBe(watchAttemptKey('TR_1'));
});
it('marks a republish buffering attempt as an operation attempt', () => {
const buffering = screenShareWatchAttemptKey({
streamKey: STREAM_KEY,
watchGeneration: 1,
trackSid: 'TR_1',
operationKey: 'republish:3',
});
expect(voiceMediaGraphAttemptKeyIsOperation(buffering)).toBe(true);
expect(voiceMediaGraphAttemptKeyIsOperation(watchAttemptKey('TR_1'))).toBe(false);
});
});
function subscribedScreenShareGraph(): VoiceMediaGraphSnapshot {
const graph = transitionVoiceMediaGraph(createVoiceMediaGraphSnapshot(), {
type: 'subscription.subscribe',
participantIdentity: PARTICIPANT_IDENTITY,
source: VoiceTrackSource.ScreenShare,
hasPublication: true,
observedElement: null,
context: 'focused',
});
return transitionVoiceMediaGraph(graph, {type: 'subscription.clearCommands'});
}
function screenShareTileState(graph: VoiceMediaGraphSnapshot): VoiceMediaGraphStreamTileState {
return selectVoiceMediaGraphStreamTileState(graph, {
streamKey: STREAM_KEY,
participantIdentity: PARTICIPANT_IDENTITY,
source: VoiceTrackSource.ScreenShare,
});
}
describe('screen share watch attempts across a republish', () => {
it('arms a fresh first frame deadline when a new track replaces a rendered one', () => {
const firstAttemptKey = watchAttemptKey('TR_1');
let graph = transitionVoiceMediaGraph(subscribedScreenShareGraph(), {
type: 'watch.started',
streamKey: STREAM_KEY,
at: 0,
});
graph = transitionVoiceMediaGraph(graph, {
type: 'publication.observed',
participantIdentity: PARTICIPANT_IDENTITY,
source: VoiceTrackSource.ScreenShare,
trackSid: 'TR_1',
at: 0,
});
graph = transitionVoiceMediaGraph(graph, {
type: 'watch.attemptEnsured',
streamKey: STREAM_KEY,
attemptKey: firstAttemptKey,
startedAt: 0,
});
graph = transitionVoiceMediaGraph(graph, {
type: 'watch.renderedFrame',
streamKey: STREAM_KEY,
attemptKey: firstAttemptKey,
renderedAt: 500,
});
expect(screenShareTileState(graph)).toBe('rendering');
graph = transitionVoiceMediaGraph(graph, {
type: 'publication.lost',
participantIdentity: PARTICIPANT_IDENTITY,
source: VoiceTrackSource.ScreenShare,
at: 4000,
});
graph = transitionVoiceMediaGraph(graph, {
type: 'publication.observed',
participantIdentity: PARTICIPANT_IDENTITY,
source: VoiceTrackSource.ScreenShare,
trackSid: 'TR_2',
at: 4500,
});
graph = transitionVoiceMediaGraph(graph, {
type: 'watch.attemptEnsured',
streamKey: STREAM_KEY,
attemptKey: watchAttemptKey('TR_2'),
startedAt: 4600,
});
expect(selectVoiceMediaGraphAttempt(graph, STREAM_KEY)?.hasRenderedVideoFrame).toBe(false);
expect(screenShareTileState(graph)).not.toBe('rendering');
expect(selectVoiceMediaGraphDeadline(graph, voiceMediaGraphWatchAttemptDeadlineKey(STREAM_KEY))?.dueAt).toBe(
4600 + WATCH_ATTEMPT_TIMEOUT_MS,
);
});
});
@@ -0,0 +1,76 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {useScreenShareWatchFailure} from '@app/features/voice/components/useScreenShareWatchFailure';
import {clearVideoFrameWatchersForTests} from '@app/features/voice/components/VideoElementFrameState';
import {voiceMediaGraphStore} from '@app/features/voice/engine/VoiceMediaGraphStore';
import {ScreenShareWatchFailures} from '@app/features/voice/state/ScreenShareWatchFailures';
import type {RemoteTrackPublication} from 'livekit-client';
import {act, useRef} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
const STREAM_KEY = 'guild-a:channel-a:connection-watch';
function WatchProbe({publication}: {publication: RemoteTrackPublication | null}) {
const videoRef = useRef<HTMLVideoElement | null>(null);
useScreenShareWatchFailure({
enabled: true,
streamKey: STREAM_KEY,
participantIdentity: 'user_1_connection-watch',
trackSid: 'TR_watch',
hasPublication: publication != null,
isPublicationDesired: publication != null,
hasSubscribedVideo: publication != null,
publication,
videoRef,
});
return null;
}
function watchDeadlineFor(streamKey: string) {
const snapshot = voiceMediaGraphStore.getGraphSnapshot();
for (const deadline of snapshot.deadlinesByKey.values()) {
if (deadline.kind === 'watchAttempt' && deadline.streamKey === streamKey) return deadline;
}
return null;
}
describe('useScreenShareWatchFailure', () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;
beforeEach(() => {
voiceMediaGraphStore.reset();
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root?.unmount();
});
root = null;
container?.remove();
container = null;
clearVideoFrameWatchersForTests();
});
it('keeps the running watch deadline when the publication arrives after the watch started', () => {
ScreenShareWatchFailures.markWatchStarted(STREAM_KEY);
act(() => {
root?.render(<WatchProbe publication={null} />);
});
const deadline = watchDeadlineFor(STREAM_KEY);
expect(deadline).not.toBeNull();
act(() => {
root?.render(<WatchProbe publication={{trackSid: 'TR_watch'} as unknown as RemoteTrackPublication} />);
});
expect(watchDeadlineFor(STREAM_KEY)).toBe(deadline);
});
});
@@ -8,10 +8,12 @@ import {
type ScreenShareWatchFailure,
ScreenShareWatchFailures,
} from '@app/features/voice/state/ScreenShareWatchFailures';
import type {RemoteTrackPublication} from 'livekit-client';
import type React from 'react';
import {useEffect, useMemo} from 'react';
const SCREEN_SHARE_SOURCE = 'screen_share';
const MISSING_TRACK_SID = 'no-track';
interface UseScreenShareWatchFailureOptions {
enabled: boolean;
@@ -23,6 +25,7 @@ interface UseScreenShareWatchFailureOptions {
isPublicationDesired: boolean;
hasSubscribedVideo: boolean;
operationKey?: string | number | null;
publication?: RemoteTrackPublication | null;
videoRef: React.RefObject<HTMLVideoElement | null>;
}
@@ -62,6 +65,26 @@ function createFailureTarget({
return target;
}
interface ScreenShareWatchAttemptKeyOptions {
streamKey: string;
watchGeneration: number;
trackSid?: string | null;
operationKey?: string | number | null;
}
export function screenShareWatchAttemptKey({
streamKey,
watchGeneration,
trackSid,
operationKey,
}: ScreenShareWatchAttemptKeyOptions): string {
if (!streamKey) return '';
const track = trackSid || MISSING_TRACK_SID;
return operationKey == null
? `${streamKey}:${watchGeneration}:${track}:watch`
: `${streamKey}:${watchGeneration}:${track}:operation:${operationKey}`;
}
export function useScreenShareWatchFailure({
enabled,
streamKey,
@@ -69,6 +92,7 @@ export function useScreenShareWatchFailure({
participantSid,
trackSid,
operationKey,
publication,
videoRef,
}: UseScreenShareWatchFailureOptions): ScreenShareWatchFailureState {
const attemptEnabled = enabled && streamKey !== '';
@@ -84,9 +108,7 @@ export function useScreenShareWatchFailure({
);
const watchGeneration = attemptEnabled ? ScreenShareWatchFailures.getWatchGeneration(streamKey) : 0;
const attemptKey = attemptEnabled
? operationKey == null
? `${streamKey}:${watchGeneration}:watch`
: `${streamKey}:${watchGeneration}:operation:${operationKey}`
? screenShareWatchAttemptKey({streamKey, watchGeneration, trackSid, operationKey})
: '';
const isOperationBuffering = operationKey != null;
const failure = attemptEnabled ? ScreenShareWatchFailures.getFailure(target) : null;
@@ -101,6 +123,14 @@ export function useScreenShareWatchFailure({
};
}, [attemptEnabled, attemptKey, target]);
useEffect(() => {
if (!attemptEnabled) return;
ScreenShareWatchFailures.setWatchTarget(streamKey, {videoRef, publication});
return () => {
ScreenShareWatchFailures.clearWatchTarget(streamKey);
};
}, [attemptEnabled, publication, streamKey, videoRef]);
useEffect(() => {
if (!attemptEnabled || !attemptKey) return;
if (isOperationBuffering) return;
@@ -79,9 +79,9 @@ function createWatchedSnapshot(failure: Partial<VoiceMediaGraphFailure> | null):
});
}
function createGraphPort(snapshot: VoiceMediaGraphSnapshot): ScreenShareVideoSubscriptionRecoveryGraph {
function createGraphPort(getSnapshot: () => VoiceMediaGraphSnapshot): ScreenShareVideoSubscriptionRecoveryGraph {
return {
getGraphSnapshot: () => snapshot,
getGraphSnapshot: getSnapshot,
nowMs: () => 1000,
transition: () => undefined,
};
@@ -95,20 +95,47 @@ function createReceivablePublication(): ScreenShareVideoSubscriptionRecoveryPubl
};
}
function acquireWatchingTile(failure: Partial<VoiceMediaGraphFailure> | null) {
const snapshot = createWatchedSnapshot(failure);
function createMutedPublication(): ScreenShareVideoSubscriptionRecoveryPublication {
return {
trackSid: TRACK_SID,
isSubscribed: true,
track: {mediaStreamTrack: {readyState: 'live', muted: true}},
};
}
function createWatchingTile(
failure: Partial<VoiceMediaGraphFailure> | null,
publication: ScreenShareVideoSubscriptionRecoveryPublication = createReceivablePublication(),
) {
let snapshot = createWatchedSnapshot(failure);
const {scheduler, fire, pendingCount, scheduledDelays} = createScheduler();
const coordinator = new ScreenShareVideoSubscriptionRecoveryCoordinator(scheduler, createGraphPort(snapshot));
const coordinator = new ScreenShareVideoSubscriptionRecoveryCoordinator(
scheduler,
createGraphPort(() => snapshot),
);
const recover = vi.fn();
coordinator.acquire({
key: TRACK_SID,
publication: createReceivablePublication(),
streamKey: STREAM_KEY,
participantIdentity: PARTICIPANT_IDENTITY,
isStillWanted: () => isScreenShareVideoSubscriptionRecoveryWanted(snapshot, STREAM_KEY),
recover,
});
return {coordinator, fire, pendingCount, scheduledDelays, recover};
const acquire = () =>
coordinator.acquire({
key: TRACK_SID,
publication,
streamKey: STREAM_KEY,
participantIdentity: PARTICIPANT_IDENTITY,
isStillWanted: () => isScreenShareVideoSubscriptionRecoveryWanted(snapshot, STREAM_KEY),
recover,
});
const restartWatch = (nextFailure: Partial<VoiceMediaGraphFailure> | null) => {
snapshot = createWatchedSnapshot(nextFailure);
};
return {coordinator, fire, pendingCount, scheduledDelays, recover, acquire, restartWatch};
}
function acquireWatchingTile(
failure: Partial<VoiceMediaGraphFailure> | null,
publication?: ScreenShareVideoSubscriptionRecoveryPublication,
) {
const tile = createWatchingTile(failure, publication);
tile.acquire();
return tile;
}
describe('isScreenShareVideoSubscriptionRecoveryWanted', () => {
@@ -180,4 +207,57 @@ describe('ScreenShareVideoSubscriptionRecoveryCoordinator first-frame recovery',
expect(recover).not.toHaveBeenCalled();
expect(coordinator.getActiveSessionCount()).toBe(0);
});
it('keeps the recovery count when the lease is released and taken again', () => {
const {coordinator, fire, recover, acquire} = createWatchingTile(FIRST_FRAME_TIMEOUT_FAILURE);
const release = acquire();
fire();
expect(recover).toHaveBeenCalledTimes(1);
release();
expect(coordinator.getActiveSessionCount()).toBe(0);
acquire();
fire();
fire();
expect(recover).toHaveBeenCalledTimes(3);
fire();
expect(recover).toHaveBeenCalledTimes(3);
expect(coordinator.getActiveSessionCount()).toBe(0);
});
it('hands the next watch of the same stream a fresh recovery budget', () => {
const {fire, recover, acquire, restartWatch} = createWatchingTile(FIRST_FRAME_TIMEOUT_FAILURE);
const release = acquire();
fire();
fire();
fire();
expect(recover).toHaveBeenCalledTimes(3);
release();
restartWatch(null);
acquire();
restartWatch(FIRST_FRAME_TIMEOUT_FAILURE);
fire();
expect(recover).toHaveBeenCalledTimes(4);
});
it('leaves a muted track alone until a failure is recorded', () => {
const {coordinator, fire, recover} = acquireWatchingTile(null, createMutedPublication());
fire();
fire();
expect(recover).not.toHaveBeenCalled();
expect(coordinator.getActiveSessionCount()).toBe(1);
});
});
@@ -62,7 +62,6 @@ interface ScreenShareVideoSubscriptionRecoverySession {
key: string;
leaseCount: number;
attempt: number;
firstFrameRecoveries: number;
timeoutId: number | null;
graphGeneration: number | null;
options: ScreenShareVideoSubscriptionRecoveryLeaseOptions;
@@ -74,6 +73,7 @@ const SCREEN_SHARE_VIDEO_SUBSCRIPTION_HEALTH_CHECK_DELAY_MS = 2500;
const SCREEN_SHARE_VIDEO_SUBSCRIPTION_REFRESH_ATTEMPTS = 0;
const SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_ATTEMPTS = 3;
const SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_DELAY_MS = 20_000;
const SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_RECORDS = 32;
const defaultScheduler: ScreenShareVideoSubscriptionRecoveryScheduler = {
setTimeout: (callback, delayMs) => window.setTimeout(callback, delayMs),
@@ -97,20 +97,28 @@ function asScreenShareRecoveryTrackLike(track: unknown): ScreenShareRecoveryTrac
return track as ScreenShareRecoveryTrackLike;
}
function isTrackReceivable(track: ScreenShareRecoveryTrackLike): boolean {
function isTrackReceivable(track: ScreenShareRecoveryTrackLike, {allowMuted}: {allowMuted: boolean}): boolean {
const mediaStreamTrack = track.mediaStreamTrack;
if (mediaStreamTrack?.readyState === 'ended') return false;
if (allowMuted) return true;
if (mediaStreamTrack?.muted === true) return false;
if (track.isMuted === true) return false;
if (track.muted === true) return false;
return true;
}
function hasReceivableTrack(publication: ScreenShareVideoSubscriptionRecoveryPublication): boolean {
function hasReceivableTrack(
publication: ScreenShareVideoSubscriptionRecoveryPublication,
{allowMuted}: {allowMuted: boolean},
): boolean {
if (publication.isSubscribed === false) return false;
const track = asScreenShareRecoveryTrackLike(publication.track);
if (track === null) return false;
return isTrackReceivable(track);
return isTrackReceivable(track, {allowMuted});
}
function firstFrameRecoveryKey(streamKey: string, generation: number): string {
return `${streamKey}:${generation}`;
}
export function isScreenShareVideoSubscriptionRecoveryWanted(
@@ -151,6 +159,7 @@ export function selectScreenShareVideoSubscriptionRecoveryMode(
export class ScreenShareVideoSubscriptionRecoveryCoordinator {
private readonly sessions = new Map<string, ScreenShareVideoSubscriptionRecoverySession>();
private readonly firstFrameRecoveriesByWatch = new Map<string, number>();
private readonly scheduler: ScreenShareVideoSubscriptionRecoveryScheduler;
private readonly graph: ScreenShareVideoSubscriptionRecoveryGraph;
@@ -163,13 +172,13 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator {
}
acquire(options: ScreenShareVideoSubscriptionRecoveryLeaseOptions): () => void {
this.resetFirstFrameRecoveriesUnlessStalled(options.streamKey ?? null);
let session = this.sessions.get(options.key);
if (!session) {
session = {
key: options.key,
leaseCount: 0,
attempt: 0,
firstFrameRecoveries: 0,
timeoutId: null,
graphGeneration: null,
options,
@@ -198,6 +207,19 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator {
return this.sessions.size;
}
getFirstFrameRecoveryCount(streamKey: string | null | undefined, generation: number): number {
if (!streamKey) return 0;
return this.firstFrameRecoveriesByWatch.get(firstFrameRecoveryKey(streamKey, generation)) ?? 0;
}
hasFirstFrameRecoveryBudget(streamKey: string | null | undefined, generation: number): boolean {
if (!streamKey) return false;
return (
this.getFirstFrameRecoveryCount(streamKey, generation) <
SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_ATTEMPTS
);
}
private release(key: string): void {
const session = this.sessions.get(key);
if (!session) return;
@@ -219,7 +241,8 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator {
private ensureScheduled(session: ScreenShareVideoSubscriptionRecoverySession): void {
if (session.timeoutId !== null) return;
const delayMs = hasReceivableTrack(session.options.publication)
const allowMuted = this.firstFrameStalledStreamKey(session) === null;
const delayMs = hasReceivableTrack(session.options.publication, {allowMuted})
? SCREEN_SHARE_VIDEO_SUBSCRIPTION_HEALTH_CHECK_DELAY_MS
: getScreenShareVideoSubscriptionRetryDelayMs(session.attempt + 1);
this.schedule(session, delayMs);
@@ -230,9 +253,13 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator {
session.timeoutId = this.scheduler.setTimeout(() => this.run(session.key), delayMs);
}
private watchGeneration(streamKey: string): number {
return selectVoiceMediaGraphWatchGeneration(this.graph.getGraphSnapshot(), streamKey);
}
private captureGraphGeneration(streamKey: string | null): number | null {
if (!streamKey) return null;
return selectVoiceMediaGraphWatchGeneration(this.graph.getGraphSnapshot(), streamKey);
return this.watchGeneration(streamKey);
}
private isGraphStateCurrent(session: ScreenShareVideoSubscriptionRecoverySession): boolean {
@@ -243,13 +270,35 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator {
return selectVoiceMediaGraphWatchGeneration(snapshot, streamKey) === session.graphGeneration;
}
private hasFirstFrameTimeoutFailure(session: ScreenShareVideoSubscriptionRecoverySession): boolean {
const streamKey = session.options.streamKey ?? null;
if (!streamKey) return false;
private hasFirstFrameTimeout(streamKey: string): boolean {
const failure = selectVoiceMediaGraphFailure(this.graph.getGraphSnapshot(), {streamKey});
return failure?.code === ScreenShareWatchErrorCode.FirstFrameTimeout;
}
private firstFrameStalledStreamKey(session: ScreenShareVideoSubscriptionRecoverySession): string | null {
const streamKey = session.options.streamKey ?? null;
if (!streamKey) return null;
return this.hasFirstFrameTimeout(streamKey) ? streamKey : null;
}
private resetFirstFrameRecoveriesUnlessStalled(streamKey: string | null): void {
if (!streamKey) return;
if (this.hasFirstFrameTimeout(streamKey)) return;
this.firstFrameRecoveriesByWatch.delete(firstFrameRecoveryKey(streamKey, this.watchGeneration(streamKey)));
}
private noteFirstFrameRecovery(streamKey: string, generation: number): void {
const key = firstFrameRecoveryKey(streamKey, generation);
const next = this.getFirstFrameRecoveryCount(streamKey, generation) + 1;
this.firstFrameRecoveriesByWatch.delete(key);
this.firstFrameRecoveriesByWatch.set(key, next);
while (this.firstFrameRecoveriesByWatch.size > SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_RECORDS) {
const oldest = this.firstFrameRecoveriesByWatch.keys().next().value;
if (oldest === undefined) break;
this.firstFrameRecoveriesByWatch.delete(oldest);
}
}
private run(key: string): void {
const session = this.sessions.get(key);
if (!session) return;
@@ -259,20 +308,21 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator {
this.closeSession(session);
return;
}
const firstFrameStalled = this.hasFirstFrameTimeoutFailure(session);
if (
firstFrameStalled &&
session.firstFrameRecoveries >= SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_ATTEMPTS
) {
this.closeSession(session);
return;
const stalledStreamKey = this.firstFrameStalledStreamKey(session);
if (stalledStreamKey === null) {
if (hasReceivableTrack(options.publication, {allowMuted: true})) {
session.attempt = 0;
this.schedule(session, SCREEN_SHARE_VIDEO_SUBSCRIPTION_HEALTH_CHECK_DELAY_MS);
return;
}
} else {
const generation = this.watchGeneration(stalledStreamKey);
if (!this.hasFirstFrameRecoveryBudget(stalledStreamKey, generation)) {
this.closeSession(session);
return;
}
this.noteFirstFrameRecovery(stalledStreamKey, generation);
}
if (!firstFrameStalled && hasReceivableTrack(options.publication)) {
session.attempt = 0;
this.schedule(session, SCREEN_SHARE_VIDEO_SUBSCRIPTION_HEALTH_CHECK_DELAY_MS);
return;
}
if (firstFrameStalled) session.firstFrameRecoveries += 1;
session.attempt += 1;
const mode = selectScreenShareVideoSubscriptionRecoveryMode(session.attempt);
const delayMs = getScreenShareVideoSubscriptionRetryDelayMs(session.attempt);
@@ -286,7 +336,7 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator {
this.recoverPublication(session, mode);
this.schedule(
session,
firstFrameStalled
stalledStreamKey !== null
? SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_DELAY_MS
: getScreenShareVideoSubscriptionRetryDelayMs(session.attempt + 1),
);
@@ -515,6 +515,19 @@ class ScreenShareCodecNegotiation {
return this.selectedCodec;
}
getLocalCodecAdvertisements(): Array<FluxerCodecAdvertisement> {
if (this.localCodecs.length === 0) return buildLocalCodecAdvertisements();
return [...this.localCodecs];
}
getRemoteDecodeCodecsByIdentity(): Record<string, Array<VideoCodec>> {
const result: Record<string, Array<VideoCodec>> = {};
for (const [identity, codecs] of this.remoteCodecsByIdentity) {
result[identity] = [...getDecodeSet(codecs)];
}
return result;
}
setSelectionChangeListener(
listener: ((room: Room, codec: VideoCodec, reason: NegotiationReason) => void) | null,
): void {
@@ -2,7 +2,7 @@
import type {FluxerCodecAdvertisement} from '@app/features/voice/engine/ScreenShareCodecNegotiation';
import type {HardwareEncodeReport} from '@app/features/voice/utils/GpuEncoderCapabilities';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const gpuReport: HardwareEncodeReport = {
av1: 'hardware',
@@ -77,13 +77,15 @@ const {
buildLocalCodecAdvertisements,
computeNegotiatedVideoCodec,
} = await import('./ScreenShareCodecNegotiation');
const {findStalledVideoDecoder} = await import('@app/features/voice/utils/ScreenShareCodecDiagnostics');
const {findStalledVideoDecoder, scheduleScreenShareDecoderVerification} = await import(
'@app/features/voice/utils/ScreenShareCodecDiagnostics'
);
const {getVideoDecoderExclusionsSync, markScreenShareDecodeFailure, resetVideoDecoderExclusions} = await import(
'@app/features/voice/utils/VideoDecoderCapabilities'
);
const {resetCachedCodecCapabilities} = await import('@app/features/voice/utils/CodecCapabilityDetector');
function stalledScreenShareStats(mimeType: string): RTCStatsReport {
function screenShareStats(mimeType: string, frames: {framesReceived: number; framesDecoded: number}): RTCStatsReport {
const entries: Array<Record<string, unknown>> = [
{id: 'codec-1', type: 'codec', mimeType},
{
@@ -93,13 +95,23 @@ function stalledScreenShareStats(mimeType: string): RTCStatsReport {
codecId: 'codec-1',
packetsReceived: 4200,
bytesReceived: 3_500_000,
framesReceived: 180,
framesDecoded: 0,
decoderImplementation: 'D3D11VideoDecoder',
powerEfficientDecoder: true,
...frames,
},
];
return new Map(entries.map((entry) => [entry.id as string, entry])) as unknown as RTCStatsReport;
}
function stalledScreenShareStats(mimeType: string): RTCStatsReport {
return screenShareStats(mimeType, {framesReceived: 180, framesDecoded: 0});
}
function statsSequence(...reports: Array<RTCStatsReport>): () => Promise<RTCStatsReport | undefined> {
const queue = [...reports];
return () => Promise.resolve(queue.shift());
}
function decodeAdvertisedFor(name: 'H264' | 'VP8' | 'VP9'): boolean | undefined {
return buildLocalCodecAdvertisements().find((codec) => codec.name === name)?.decode;
}
@@ -164,3 +176,119 @@ describe('a stalled H.264 screen share decode', () => {
expect(decodeAdvertisedFor('VP8')).toBe(true);
});
});
describe('confirming a screen share decode stall before a codec is withdrawn', () => {
beforeEach(() => {
vi.useFakeTimers();
resetVideoDecoderExclusions();
resetCachedCodecCapabilities();
ScreenShareCodecNegotiation.dispose();
});
afterEach(() => {
vi.useRealTimers();
});
it('withdraws nothing from a single stalled sample', async () => {
const onDecodeFailure = vi.fn();
scheduleScreenShareDecoderVerification(
statsSequence(
screenShareStats('video/H264', {framesReceived: 180, framesDecoded: 0}),
screenShareStats('video/H264', {framesReceived: 240, framesDecoded: 0}),
),
undefined,
onDecodeFailure,
);
await vi.advanceTimersByTimeAsync(5000);
expect(onDecodeFailure).not.toHaveBeenCalled();
expect(decodeAdvertisedFor('H264')).toBe(true);
});
it('withdraws nothing when packets arrive but no whole frames do', async () => {
const onDecodeFailure = vi.fn();
scheduleScreenShareDecoderVerification(
statsSequence(
screenShareStats('video/H264', {framesReceived: 0, framesDecoded: 0}),
screenShareStats('video/H264', {framesReceived: 0, framesDecoded: 0}),
),
undefined,
onDecodeFailure,
);
await vi.advanceTimersByTimeAsync(5000);
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(5000);
expect(onDecodeFailure).not.toHaveBeenCalled();
expect(decodeAdvertisedFor('H264')).toBe(true);
});
it('withdraws the codec exactly once when a second sample confirms the stall', async () => {
const withdrawn: Array<string> = [];
scheduleScreenShareDecoderVerification(
statsSequence(
screenShareStats('video/H264', {framesReceived: 180, framesDecoded: 0}),
screenShareStats('video/H264', {framesReceived: 240, framesDecoded: 0}),
),
undefined,
(failure) => {
if (markScreenShareDecodeFailure(failure.codec, 'screen-share-decode-stalled')) {
withdrawn.push(failure.codec);
}
},
);
await vi.advanceTimersByTimeAsync(5000);
expect(withdrawn).toEqual([]);
expect(decodeAdvertisedFor('H264')).toBe(true);
await vi.advanceTimersByTimeAsync(5000);
expect(withdrawn).toEqual(['h264']);
expect(decodeAdvertisedFor('H264')).toBe(false);
});
it('withdraws nothing when the second sample decoded a frame', async () => {
const onDecodeFailure = vi.fn();
scheduleScreenShareDecoderVerification(
statsSequence(
screenShareStats('video/H264', {framesReceived: 180, framesDecoded: 0}),
screenShareStats('video/H264', {framesReceived: 240, framesDecoded: 12}),
),
undefined,
onDecodeFailure,
);
await vi.advanceTimersByTimeAsync(10000);
expect(onDecodeFailure).not.toHaveBeenCalled();
expect(decodeAdvertisedFor('H264')).toBe(true);
});
it('withdraws nothing when no new frames arrived between the samples', async () => {
const onDecodeFailure = vi.fn();
scheduleScreenShareDecoderVerification(
statsSequence(
screenShareStats('video/H264', {framesReceived: 180, framesDecoded: 0}),
screenShareStats('video/H264', {framesReceived: 180, framesDecoded: 0}),
),
undefined,
onDecodeFailure,
);
await vi.advanceTimersByTimeAsync(10000);
expect(onDecodeFailure).not.toHaveBeenCalled();
expect(decodeAdvertisedFor('H264')).toBe(true);
});
it('withdraws nothing when the track goes away between the samples', async () => {
const onDecodeFailure = vi.fn();
const cancel = scheduleScreenShareDecoderVerification(
statsSequence(
screenShareStats('video/H264', {framesReceived: 180, framesDecoded: 0}),
screenShareStats('video/H264', {framesReceived: 240, framesDecoded: 0}),
),
undefined,
onDecodeFailure,
);
await vi.advanceTimersByTimeAsync(5000);
expect(vi.getTimerCount()).toBe(1);
cancel();
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(5000);
expect(onDecodeFailure).not.toHaveBeenCalled();
expect(decodeAdvertisedFor('H264')).toBe(true);
});
});
@@ -21,6 +21,7 @@ import {
selectVoiceMediaGraphSubscriptionEntry,
selectVoiceMediaGraphViewerStreamKeys,
selectVoiceMediaGraphWatchGeneration,
selectVoiceMediaGraphWatchStopReason,
transitionVoiceMediaGraph,
transitionVoiceMediaGraphViewerStreamKeys,
type VoiceMediaGraphDeadline,
@@ -85,6 +86,7 @@ describe('VoiceMediaGraph watch generations', () => {
startedAt: 1000,
hasRenderedVideoFrame: false,
generation: 0,
holders: 1,
});
});
@@ -760,6 +762,131 @@ describe('VoiceMediaGraph deadlines', () => {
expect(recorded?.code).toBe(-2301);
expect(recorded?.reason).toBe('publication-missing-timeout');
});
it('records the expired republish grace as the stop reason for the dropped stream', () => {
let graph = transitionVoiceMediaGraph(createVoiceMediaGraphSnapshot(), {type: 'watchIntent.add', key: STREAM_A});
graph = transitionVoiceMediaGraph(graph, {type: 'watchIntent.deferRemove', key: STREAM_A, at: 1000});
graph = transitionVoiceMediaGraph(graph, {
type: 'time.deadlineFired',
key: voiceMediaGraphDeferredStopDeadlineKey(STREAM_A),
at: 1000 + PUBLISHER_REPUBLISH_GRACE_MS,
});
expect(selectVoiceMediaGraphViewerStreamKeys(graph)).toEqual([]);
expect(selectVoiceMediaGraphWatchStopReason(graph, STREAM_A)).toBe('republish-grace-expired');
});
it('forgets the recorded stop reason once the stream is watched again', () => {
let graph = transitionVoiceMediaGraph(createVoiceMediaGraphSnapshot(), {type: 'watchIntent.add', key: STREAM_A});
graph = transitionVoiceMediaGraph(graph, {type: 'watchIntent.deferRemove', key: STREAM_A, at: 1000});
graph = transitionVoiceMediaGraph(graph, {
type: 'time.deadlineFired',
key: voiceMediaGraphDeferredStopDeadlineKey(STREAM_A),
at: 1000 + PUBLISHER_REPUBLISH_GRACE_MS,
});
graph = transitionVoiceMediaGraph(graph, {type: 'watch.started', streamKey: STREAM_A, at: 6000});
expect(selectVoiceMediaGraphWatchStopReason(graph, STREAM_A)).toBeNull();
});
it('keeps the recorded stop reason through the watch stop that follows the grace expiry', () => {
let graph = transitionVoiceMediaGraph(createVoiceMediaGraphSnapshot(), {type: 'watchIntent.add', key: STREAM_A});
graph = transitionVoiceMediaGraph(graph, {type: 'watchIntent.deferRemove', key: STREAM_A, at: 1000});
graph = transitionVoiceMediaGraph(graph, {
type: 'time.deadlineFired',
key: voiceMediaGraphDeferredStopDeadlineKey(STREAM_A),
at: 1000 + PUBLISHER_REPUBLISH_GRACE_MS,
});
graph = transitionVoiceMediaGraph(graph, {type: 'watch.stopped', streamKey: STREAM_A});
expect(selectVoiceMediaGraphWatchStopReason(graph, STREAM_A)).toBe('republish-grace-expired');
});
});
describe('VoiceMediaGraph watch attempt holders', () => {
const ATTEMPT_KEY = 'attempt-1';
function ensureAttempt(graph: VoiceMediaGraphSnapshot, startedAt: number): VoiceMediaGraphSnapshot {
return transitionVoiceMediaGraph(graph, {
type: 'watch.attemptEnsured',
streamKey: STREAM_A,
attemptKey: ATTEMPT_KEY,
startedAt,
});
}
function releaseAttempt(graph: VoiceMediaGraphSnapshot): VoiceMediaGraphSnapshot {
return transitionVoiceMediaGraph(graph, {
type: 'watch.attemptReleased',
streamKey: STREAM_A,
attemptKey: ATTEMPT_KEY,
});
}
function watchedStream(at: number): VoiceMediaGraphSnapshot {
return transitionVoiceMediaGraph(createVoiceMediaGraphSnapshot(), {
type: 'watch.started',
streamKey: STREAM_A,
at,
});
}
it('keeps the attempt deadline while a second tile still holds the attempt', () => {
let graph = ensureAttempt(watchedStream(1000), 1000);
graph = ensureAttempt(graph, 1200);
graph = releaseAttempt(graph);
expect(selectVoiceMediaGraphAttempt(graph, STREAM_A)?.holders).toBe(1);
expect(selectVoiceMediaGraphDeadline(graph, voiceMediaGraphWatchAttemptDeadlineKey(STREAM_A))?.dueAt).toBe(
1000 + WATCH_ATTEMPT_TIMEOUT_MS,
);
});
it('drops the attempt deadline once the last tile releases the attempt', () => {
let graph = ensureAttempt(watchedStream(1000), 1000);
graph = ensureAttempt(graph, 1200);
graph = releaseAttempt(releaseAttempt(graph));
expect(selectVoiceMediaGraphAttempt(graph, STREAM_A)?.holders).toBe(0);
expect(selectVoiceMediaGraphDeadline(graph, voiceMediaGraphWatchAttemptDeadlineKey(STREAM_A))).toBeNull();
});
it('rearms a released attempt from the time the attempt started', () => {
let graph = ensureAttempt(watchedStream(1000), 1000);
graph = releaseAttempt(graph);
graph = ensureAttempt(graph, 9000);
expect(selectVoiceMediaGraphAttempt(graph, STREAM_A)?.startedAt).toBe(1000);
expect(selectVoiceMediaGraphDeadline(graph, voiceMediaGraphWatchAttemptDeadlineKey(STREAM_A))?.dueAt).toBe(
1000 + WATCH_ATTEMPT_TIMEOUT_MS,
);
});
it('counts one holder per tile when every mount runs its effect twice', () => {
let graph = ensureAttempt(watchedStream(1000), 1000);
graph = ensureAttempt(releaseAttempt(graph), 1400);
graph = ensureAttempt(graph, 1500);
graph = ensureAttempt(releaseAttempt(graph), 1600);
expect(selectVoiceMediaGraphAttempt(graph, STREAM_A)?.holders).toBe(2);
graph = releaseAttempt(releaseAttempt(graph));
expect(selectVoiceMediaGraphAttempt(graph, STREAM_A)?.holders).toBe(0);
expect(selectVoiceMediaGraphDeadline(graph, voiceMediaGraphWatchAttemptDeadlineKey(STREAM_A))).toBeNull();
});
it('ignores a release for an attempt the graph no longer tracks', () => {
const graph = watchedStream(1000);
expect(releaseAttempt(graph)).toBe(graph);
});
});
describe('VoiceMediaGraph desired and actual subscription state', () => {
@@ -159,8 +159,11 @@ export interface VoiceMediaGraphWatchAttempt {
startedAt: number;
hasRenderedVideoFrame: boolean;
generation: number;
holders: number;
}
export type VoiceMediaGraphWatchStopReason = 'republish-grace-expired';
export interface VoiceMediaGraphWatchIntent {
viewerStreamKeys: ReadonlyArray<string>;
deferredStopKeys: ReadonlySet<string>;
@@ -179,6 +182,7 @@ export interface VoiceMediaGraphSnapshot<TFailure extends VoiceMediaGraphFailure
failuresByKey: ReadonlyMap<string, TFailure>;
watchGenerationByStreamKey: ReadonlyMap<string, number>;
attemptsByStreamKey: ReadonlyMap<string, VoiceMediaGraphWatchAttempt>;
watchStopReasonByStreamKey: ReadonlyMap<string, VoiceMediaGraphWatchStopReason>;
watchIntent: VoiceMediaGraphWatchIntent;
subscriptionsByKey: ReadonlyMap<string, VoiceMediaGraphSubscriptionEntry>;
subscriptionCommands: ReadonlyArray<VoiceMediaGraphSubscriptionCommand>;
@@ -218,6 +222,7 @@ export function createVoiceMediaGraphSnapshot<
failuresByKey: new Map(),
watchGenerationByStreamKey: new Map(),
attemptsByStreamKey: new Map(),
watchStopReasonByStreamKey: new Map(),
watchIntent: {
viewerStreamKeys: [],
deferredStopKeys: EMPTY_STRING_SET,
@@ -402,6 +407,7 @@ function transitionWatchStarted<TFailure extends VoiceMediaGraphFailure>(
),
watchGenerationByStreamKey: mapSetBounded(snapshot.watchGenerationByStreamKey, streamKey, generation),
attemptsByStreamKey: mapDelete(snapshot.attemptsByStreamKey, streamKey),
watchStopReasonByStreamKey: mapDelete(snapshot.watchStopReasonByStreamKey, streamKey),
deadlinesByKey,
};
}
@@ -430,6 +436,7 @@ function transitionFailureWatchClearAll<TFailure extends VoiceMediaGraphFailure>
failuresByKey: new Map(),
watchGenerationByStreamKey: new Map(),
attemptsByStreamKey: new Map(),
watchStopReasonByStreamKey: new Map(),
deadlinesByKey: new Map(),
};
}
@@ -1205,7 +1212,15 @@ function applyDeferredStopDeadline<TFailure extends VoiceMediaGraphFailure>(
): VoiceMediaGraphSnapshot<TFailure> {
if (!deadline.streamKey) return snapshot;
if (!snapshot.watchIntent.deferredStopKeys.has(deadline.streamKey)) return snapshot;
return transitionWatchIntentRemoveMany(snapshot, [deadline.streamKey]);
const stopped: VoiceMediaGraphSnapshot<TFailure> = {
...snapshot,
watchStopReasonByStreamKey: mapSetBounded(
snapshot.watchStopReasonByStreamKey,
deadline.streamKey,
'republish-grace-expired',
),
};
return transitionWatchIntentRemoveMany(stopped, [deadline.streamKey]);
}
function applyPublicationMissingDeadline<TFailure extends VoiceMediaGraphFailure>(
@@ -1415,14 +1430,18 @@ function ensureVoiceMediaGraphWatchAttempt<TFailure extends VoiceMediaGraphFailu
const deadlineKey = voiceMediaGraphWatchAttemptDeadlineKey(event.streamKey);
const existing = snapshot.attemptsByStreamKey.get(event.streamKey);
if (existing?.attemptKey === event.attemptKey) {
if (existing.hasRenderedVideoFrame) return snapshot;
if (snapshot.deadlinesByKey.has(deadlineKey)) return snapshot;
const attemptsByStreamKey = mapSetBounded(snapshot.attemptsByStreamKey, event.streamKey, {
...existing,
holders: existing.holders + 1,
});
if (existing.hasRenderedVideoFrame) return {...snapshot, attemptsByStreamKey};
if (snapshot.deadlinesByKey.has(deadlineKey)) return {...snapshot, attemptsByStreamKey};
const deadlinesByKey = mapSetBounded(
snapshot.deadlinesByKey,
deadlineKey,
voiceMediaGraphWatchAttemptDeadline(event.streamKey, event.attemptKey, existing.generation, event.startedAt),
voiceMediaGraphWatchAttemptDeadline(event.streamKey, event.attemptKey, existing.generation, existing.startedAt),
);
return {...snapshot, deadlinesByKey};
return {...snapshot, attemptsByStreamKey, deadlinesByKey};
}
const generation = event.generation ?? snapshot.watchGenerationByStreamKey.get(event.streamKey) ?? 0;
const attempt: VoiceMediaGraphWatchAttempt = {
@@ -1430,6 +1449,7 @@ function ensureVoiceMediaGraphWatchAttempt<TFailure extends VoiceMediaGraphFailu
startedAt: event.startedAt,
hasRenderedVideoFrame: false,
generation,
holders: 1,
};
const deadlinesByKey = mapSetBounded(
snapshot.deadlinesByKey,
@@ -1448,10 +1468,15 @@ function releaseVoiceMediaGraphWatchAttempt<TFailure extends VoiceMediaGraphFail
event: {streamKey: string; attemptKey: string},
): VoiceMediaGraphSnapshot<TFailure> {
if (!event.streamKey) return snapshot;
const existing = snapshot.attemptsByStreamKey.get(event.streamKey);
if (!existing || existing.attemptKey !== event.attemptKey) return snapshot;
const holders = Math.max(existing.holders - 1, 0);
const attemptsByStreamKey = mapSetBounded(snapshot.attemptsByStreamKey, event.streamKey, {...existing, holders});
if (holders > 0) return {...snapshot, attemptsByStreamKey};
const deadlineKey = voiceMediaGraphWatchAttemptDeadlineKey(event.streamKey);
const deadline = snapshot.deadlinesByKey.get(deadlineKey);
if (!deadline || deadline.attemptKey !== event.attemptKey) return snapshot;
return {...snapshot, deadlinesByKey: mapDelete(snapshot.deadlinesByKey, deadlineKey)};
if (!deadline || deadline.attemptKey !== event.attemptKey) return {...snapshot, attemptsByStreamKey};
return {...snapshot, attemptsByStreamKey, deadlinesByKey: mapDelete(snapshot.deadlinesByKey, deadlineKey)};
}
function markRenderedFrameOnSubscriptionEntry<TFailure extends VoiceMediaGraphFailure>(
@@ -1482,6 +1507,7 @@ function markVoiceMediaGraphRenderedFrame<TFailure extends VoiceMediaGraphFailur
startedAt: existing?.startedAt ?? event.renderedAt,
hasRenderedVideoFrame: true,
generation: existing?.generation ?? snapshot.watchGenerationByStreamKey.get(event.streamKey) ?? 0,
holders: existing?.holders ?? 0,
};
const next: VoiceMediaGraphSnapshot<TFailure> = {
...snapshot,
@@ -1560,6 +1586,14 @@ export function selectVoiceMediaGraphAttempt(
return snapshot.attemptsByStreamKey.get(streamKey) ?? null;
}
export function selectVoiceMediaGraphWatchStopReason(
snapshot: VoiceMediaGraphSnapshot,
streamKey: string,
): VoiceMediaGraphWatchStopReason | null {
if (!streamKey) return null;
return snapshot.watchStopReasonByStreamKey.get(streamKey) ?? null;
}
export function selectVoiceMediaGraphViewerStreamKeys(snapshot: VoiceMediaGraphSnapshot): ReadonlyArray<string> {
return snapshot.watchIntent.viewerStreamKeys;
}
@@ -5,12 +5,16 @@ import {
transitionVoiceMediaGraph,
type VoiceMediaGraphSnapshot,
} from '@app/features/voice/engine/VoiceMediaGraph';
import {selectVoiceMediaGraphStreamTileState} from '@app/features/voice/engine/VoiceMediaGraphTileState';
import {
selectVoiceMediaGraphStreamTileState,
VOICE_MEDIA_GRAPH_FIRST_FRAME_RECOVERY_VISIBLE_MS,
} from '@app/features/voice/engine/VoiceMediaGraphTileState';
import {VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource';
import {describe, expect, it} from 'vitest';
const STREAM_KEY = 'dm:channel-a:connection-a';
const PARTICIPANT_IDENTITY = 'user_2_connection-a';
const FAILURE_REPORTED_AT = 300;
const target = {
streamKey: STREAM_KEY,
@@ -42,6 +46,24 @@ function attach(graph: VoiceMediaGraphSnapshot): VoiceMediaGraphSnapshot {
});
}
function reportFailure(graph: VoiceMediaGraphSnapshot, code: number, reason: string): VoiceMediaGraphSnapshot {
return transitionVoiceMediaGraph(graph, {
type: 'failure.reported',
failure: {
code,
reason,
reportedAt: FAILURE_REPORTED_AT,
streamKey: STREAM_KEY,
participantIdentity: PARTICIPANT_IDENTITY,
source: 'screen_share',
},
});
}
function watchedWithFailure(code: number, reason: string): VoiceMediaGraphSnapshot {
return reportFailure(attach(subscribe(createVoiceMediaGraphSnapshot(), true)), code, reason);
}
describe('selectVoiceMediaGraphStreamTileState', () => {
it('returns idle when the graph has no state for the tile', () => {
expect(selectVoiceMediaGraphStreamTileState(createVoiceMediaGraphSnapshot(), target)).toBe('idle');
@@ -140,6 +162,51 @@ describe('selectVoiceMediaGraphStreamTileState', () => {
expect(selectVoiceMediaGraphStreamTileState(graph, target)).toBe('subscribedAwaitingFrame');
});
it('returns recovering while a first-frame timeout still has recovery attempts left', () => {
const graph = watchedWithFailure(-2303, 'first-frame-timeout');
expect(
selectVoiceMediaGraphStreamTileState(graph, target, {
hasRecoveryBudget: true,
nowMs: FAILURE_REPORTED_AT + 1000,
}),
).toBe('recovering');
});
it('returns failed once the first-frame recovery budget is spent', () => {
const graph = watchedWithFailure(-2303, 'first-frame-timeout');
expect(
selectVoiceMediaGraphStreamTileState(graph, target, {
hasRecoveryBudget: false,
nowMs: FAILURE_REPORTED_AT + 1000,
}),
).toBe('failed');
});
it('returns failed once a first-frame timeout has been on screen for the visible cap', () => {
const graph = watchedWithFailure(-2303, 'first-frame-timeout');
expect(
selectVoiceMediaGraphStreamTileState(graph, target, {
hasRecoveryBudget: true,
nowMs: FAILURE_REPORTED_AT + VOICE_MEDIA_GRAPH_FIRST_FRAME_RECOVERY_VISIBLE_MS,
}),
).toBe('failed');
});
it('returns failed for a first-frame timeout when the caller tracks no recovery', () => {
const graph = watchedWithFailure(-2303, 'first-frame-timeout');
expect(selectVoiceMediaGraphStreamTileState(graph, target)).toBe('failed');
});
it('keeps every other failure code failed while recovery attempts remain', () => {
const graph = watchedWithFailure(-2202, 'remote-track-subscription-failed');
expect(
selectVoiceMediaGraphStreamTileState(graph, target, {
hasRecoveryBudget: true,
nowMs: FAILURE_REPORTED_AT + 1000,
}),
).toBe('failed');
});
it('returns watchDesired for entry-less streams and idle after the watch ends', () => {
let graph = transitionVoiceMediaGraph(createVoiceMediaGraphSnapshot(), {
type: 'watchIntent.add',
@@ -4,11 +4,15 @@ import {
selectVoiceMediaGraphAttempt,
selectVoiceMediaGraphFailure,
selectVoiceMediaGraphSubscriptionEntry,
type VoiceMediaGraphFailure,
type VoiceMediaGraphSnapshot,
type VoiceMediaGraphSubscriptionEntry,
} from './VoiceMediaGraph';
import {VOICE_MEDIA_GRAPH_FIRST_FRAME_TIMEOUT_FAILURE} from './VoiceMediaGraphDeadlines';
import type {VoiceTrackSource} from './VoiceTrackSource';
export const VOICE_MEDIA_GRAPH_FIRST_FRAME_RECOVERY_VISIBLE_MS = 30_000;
export type VoiceMediaGraphStreamTileState =
| 'idle'
| 'watchDesired'
@@ -16,6 +20,7 @@ export type VoiceMediaGraphStreamTileState =
| 'attaching'
| 'subscribedAwaitingFrame'
| 'rendering'
| 'recovering'
| 'failed';
export interface VoiceMediaGraphStreamTileTarget {
@@ -24,14 +29,32 @@ export interface VoiceMediaGraphStreamTileTarget {
source: VoiceTrackSource;
}
function tileHasFailure(snapshot: VoiceMediaGraphSnapshot, target: VoiceMediaGraphStreamTileTarget): boolean {
if (!target.streamKey && !target.participantIdentity) return false;
const failure = selectVoiceMediaGraphFailure(snapshot, {
export interface VoiceMediaGraphStreamTileRecovery {
hasRecoveryBudget: boolean;
nowMs: number;
}
function selectTileFailure(
snapshot: VoiceMediaGraphSnapshot,
target: VoiceMediaGraphStreamTileTarget,
): VoiceMediaGraphFailure | null {
if (!target.streamKey && !target.participantIdentity) return null;
return selectVoiceMediaGraphFailure(snapshot, {
streamKey: target.streamKey,
participantIdentity: target.participantIdentity,
source: target.source,
});
return failure !== null;
}
function selectFailureTileState(
failure: VoiceMediaGraphFailure,
recovery: VoiceMediaGraphStreamTileRecovery | undefined,
): VoiceMediaGraphStreamTileState {
if (!recovery) return 'failed';
if (failure.code !== VOICE_MEDIA_GRAPH_FIRST_FRAME_TIMEOUT_FAILURE.code) return 'failed';
if (!recovery.hasRecoveryBudget) return 'failed';
if (recovery.nowMs - failure.reportedAt >= VOICE_MEDIA_GRAPH_FIRST_FRAME_RECOVERY_VISIBLE_MS) return 'failed';
return 'recovering';
}
function tileIsRendering(
@@ -48,11 +71,13 @@ function tileIsRendering(
export function selectVoiceMediaGraphStreamTileState(
snapshot: VoiceMediaGraphSnapshot,
target: VoiceMediaGraphStreamTileTarget,
recovery?: VoiceMediaGraphStreamTileRecovery,
): VoiceMediaGraphStreamTileState {
const entry = target.participantIdentity
? selectVoiceMediaGraphSubscriptionEntry(snapshot, target.participantIdentity, target.source)
: null;
if (tileHasFailure(snapshot, target)) return 'failed';
const failure = selectTileFailure(snapshot, target);
if (failure !== null) return selectFailureTileState(failure, recovery);
if (tileIsRendering(snapshot, target, entry)) return 'rendering';
if (entry?.actual.lastError) return 'failed';
if (entry?.actual.subscribed === true) return 'subscribedAwaitingFrame';
@@ -0,0 +1,197 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap';
import type {RoomEventCallbacks, RoomEventDependencies} from '@app/features/voice/engine/VoiceRoomEventBinder';
import {ConnectionState, type Room, RoomEvent} from 'livekit-client';
import {beforeEach, describe, expect, it, vi} from 'vitest';
const mocks = vi.hoisted(() => ({
bindCodecNegotiation: vi.fn(() => vi.fn()),
disposeCodecNegotiation: vi.fn(),
bindScreenShareMigration: vi.fn(() => vi.fn()),
disposeScreenShareMigration: vi.fn(),
}));
vi.mock('@lingui/core/macro', () => {
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
});
vi.mock('@lingui/react/macro', () => ({
Trans: () => null,
useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? '', locale: 'en'}}),
}));
vi.mock('@app/features/voice/engine/ScreenShareCodecNegotiation', () => ({
default: {
bind: mocks.bindCodecNegotiation,
dispose: mocks.disposeCodecNegotiation,
getSelectedCodec: vi.fn(() => null),
publishLocalCapabilities: vi.fn(async () => null),
selectNativeScreenShareCodec: vi.fn(() => null),
selectScreenShareCodec: vi.fn(() => null),
setSelectionChangeListener: vi.fn(),
},
}));
vi.mock('@app/features/voice/engine/ScreenSharePublicationMigration', () => ({
default: {
bind: mocks.bindScreenShareMigration,
dispose: mocks.disposeScreenShareMigration,
getManagedScreenSharePublications: vi.fn(() => []),
getScreenSharePublicationsToDisable: vi.fn(() => []),
isScreenShareBuffering: vi.fn(() => false),
selectScreenSharePublication: vi.fn(() => null),
subscribe: vi.fn(() => vi.fn()),
version: 0,
},
}));
installVoiceMenuTestBootstrap();
const {bindRoomEvents} = await import('@app/features/voice/engine/VoiceRoomEventBinder');
class FakeRoom {
private handlers = new Map<string, Set<(...args: Array<unknown>) => unknown>>();
localParticipant = {identity: 'local', on: vi.fn(), off: vi.fn()};
remoteParticipants = new Map<string, {identity: string}>();
constructor(public state: ConnectionState) {}
on(event: string, handler: (...args: Array<unknown>) => unknown): this {
if (!this.handlers.has(event)) this.handlers.set(event, new Set());
this.handlers.get(event)?.add(handler);
return this;
}
off(event: string, handler: (...args: Array<unknown>) => unknown): this {
this.handlers.get(event)?.delete(handler);
return this;
}
async emit(event: string, ...args: Array<unknown>): Promise<void> {
for (const handler of this.handlers.get(event) ?? []) {
await handler(...args);
}
}
}
function createCallbacks(): RoomEventCallbacks {
return {
onConnected: vi.fn(async () => undefined),
onDisconnected: vi.fn(),
onReconnecting: vi.fn(),
onReconnected: vi.fn(),
};
}
function createDependencies(options: {attemptIsStale?: boolean} = {}): RoomEventDependencies {
return {
connection: {
createGuardedHandler: (_attemptId, handler) => {
return (...args) => {
if (options.attemptIsStale) return;
void handler(...args);
};
},
isDisconnecting: () => false,
isUserMovePending: () => true,
markConnected: vi.fn(),
markDisconnected: vi.fn(),
markReconnecting: vi.fn(),
markReconnected: vi.fn(),
},
media: {
applyAllLocalAudioPreferences: vi.fn(),
ensureMicrophone: vi.fn(async () => undefined),
playEntranceSound: vi.fn(async () => undefined),
resetStreamTracking: vi.fn(),
},
mediaState: {
handleLocalTrackStateChange: vi.fn(() => false),
resetLocalMediaState: vi.fn(),
},
participants: {
clear: vi.fn(),
hydrateFromRoom: vi.fn(),
removeParticipant: vi.fn(),
updateActiveSpeakers: vi.fn(),
upsertParticipant: vi.fn(),
},
permissions: {
applyDeafen: vi.fn(),
syncWithPermissionState: vi.fn(),
},
remoteSpeaking: {
attachIfApplicable: vi.fn(),
clear: vi.fn(),
detachByIdentity: vi.fn(),
detachIfTrackMatches: vi.fn(),
hydrateFromRoom: vi.fn(),
},
screenShare: {
cleanupLingeringScreenShareTracks: vi.fn(async () => undefined),
handleLocalScreenShareTrackUnpublished: vi.fn(),
isScreenSharePublicationReplaceInFlight: () => false,
},
subscriptions: {
isScreenShareSubscribed: () => false,
reattachScreenShareAfterPublish: vi.fn(),
reconcileSubscriptions: vi.fn(),
},
};
}
function bindFakeRoom(room: FakeRoom, options: {attemptIsStale?: boolean} = {}): void {
bindRoomEvents(room as unknown as Room, 1, 'guild-1', 'channel-1', createCallbacks(), createDependencies(options));
}
describe('binding room events for screen share negotiation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('binds codec negotiation and publication migration to a room that is already connected', () => {
bindFakeRoom(new FakeRoom(ConnectionState.Connected));
expect(mocks.bindCodecNegotiation).toHaveBeenCalledTimes(1);
expect(mocks.bindScreenShareMigration).toHaveBeenCalledTimes(1);
});
it('binds neither until a room that is not connected yet reports it has connected', async () => {
const room = new FakeRoom(ConnectionState.Disconnected);
bindFakeRoom(room);
expect(mocks.bindCodecNegotiation).not.toHaveBeenCalled();
expect(mocks.bindScreenShareMigration).not.toHaveBeenCalled();
room.state = ConnectionState.Connected;
await room.emit(RoomEvent.Connected);
expect(mocks.bindCodecNegotiation).toHaveBeenCalledTimes(1);
expect(mocks.bindScreenShareMigration).toHaveBeenCalledTimes(1);
});
it('binds once however often an already connected room reports it has connected', async () => {
const room = new FakeRoom(ConnectionState.Connected);
bindFakeRoom(room);
await room.emit(RoomEvent.Connected);
await room.emit(RoomEvent.Connected);
expect(mocks.bindCodecNegotiation).toHaveBeenCalledTimes(1);
expect(mocks.bindScreenShareMigration).toHaveBeenCalledTimes(1);
});
it('binds again after an already connected room disconnects and connects once more', async () => {
const room = new FakeRoom(ConnectionState.Connected);
bindFakeRoom(room);
await room.emit(RoomEvent.Disconnected);
expect(mocks.disposeCodecNegotiation).toHaveBeenCalledTimes(1);
expect(mocks.disposeScreenShareMigration).toHaveBeenCalledTimes(1);
await room.emit(RoomEvent.Connected);
expect(mocks.bindCodecNegotiation).toHaveBeenCalledTimes(2);
expect(mocks.bindScreenShareMigration).toHaveBeenCalledTimes(2);
});
it('binds nothing to an already connected room whose connection attempt is stale', () => {
bindFakeRoom(new FakeRoom(ConnectionState.Connected), {attemptIsStale: true});
expect(mocks.bindCodecNegotiation).not.toHaveBeenCalled();
expect(mocks.bindScreenShareMigration).not.toHaveBeenCalled();
});
});
@@ -29,7 +29,7 @@ import type {
RemoteTrackPublication,
Room,
} from 'livekit-client';
import {ParticipantEvent, RoomEvent, Track} from 'livekit-client';
import {ConnectionState, ParticipantEvent, RoomEvent, Track} from 'livekit-client';
const logger = new Logger('VoiceRoomEventBinder');
@@ -141,10 +141,11 @@ export function bindRoomEvents(
): void {
const guard = dependencies.connection.createGuardedHandler;
const participantSpeakingDisposers = new Map<string, ParticipantSpeakingDisposer>();
const screenShareDecoderVerificationTimers = new Map<string, NodeJS.Timeout>();
const screenShareDecoderVerificationCancels = new Map<string, () => void>();
const remoteTrackLifecycleDisposers = new Map<string, () => void>();
let codecNegotiationDisposer: (() => void) | null = null;
let screenShareMigrationDisposer: (() => void) | null = null;
let screenShareNegotiationBound = false;
const remoteTrackLifecycleRoleFor = (pub: RemoteTrackPublication): 'remote-screen-share' | 'remote-camera' | null => {
if (pub.kind !== Track.Kind.Video) return null;
if (pub.source === Track.Source.ScreenShare) return 'remote-screen-share';
@@ -227,16 +228,16 @@ export function bindRoomEvents(
};
const clearScreenShareDecoderVerification = (trackSid: string | undefined): void => {
if (!trackSid) return;
const timer = screenShareDecoderVerificationTimers.get(trackSid);
if (!timer) return;
clearTimeout(timer);
screenShareDecoderVerificationTimers.delete(trackSid);
const cancel = screenShareDecoderVerificationCancels.get(trackSid);
if (!cancel) return;
cancel();
screenShareDecoderVerificationCancels.delete(trackSid);
};
const clearAllScreenShareDecoderVerifications = (): void => {
for (const timer of screenShareDecoderVerificationTimers.values()) {
clearTimeout(timer);
for (const cancel of screenShareDecoderVerificationCancels.values()) {
cancel();
}
screenShareDecoderVerificationTimers.clear();
screenShareDecoderVerificationCancels.clear();
};
const bindCodecNegotiation = (): void => {
codecNegotiationDisposer?.();
@@ -254,16 +255,27 @@ export function bindRoomEvents(
ScreenSharePublicationMigration.dispose();
screenShareMigrationDisposer = null;
};
const bindScreenShareNegotiation = (): void => {
if (screenShareNegotiationBound) return;
screenShareNegotiationBound = true;
bindCodecNegotiation();
bindScreenShareMigration();
};
const unbindScreenShareNegotiation = (): void => {
screenShareNegotiationBound = false;
unbindCodecNegotiation();
unbindScreenShareMigration();
};
const scheduleDecoderVerification = (track: RemoteTrack, pub: RemoteTrackPublication): void => {
if (pub.source !== Track.Source.ScreenShare || pub.kind !== Track.Kind.Video) return;
clearScreenShareDecoderVerification(pub.trackSid);
const trackSid = pub.trackSid;
screenShareDecoderVerificationTimers.set(
screenShareDecoderVerificationCancels.set(
trackSid,
scheduleScreenShareDecoderVerification(
() => track.getRTCStatsReport(),
() => {
screenShareDecoderVerificationTimers.delete(trackSid);
screenShareDecoderVerificationCancels.delete(trackSid);
},
(failure) => {
if (!markScreenShareDecodeFailure(failure.codec, 'screen-share-decode-stalled')) return;
@@ -307,8 +319,7 @@ export function bindRoomEvents(
bindParticipantSpeakingEvents(room.localParticipant);
room.remoteParticipants.forEach((participant) => bindParticipantSpeakingEvents(participant));
dependencies.remoteSpeaking.hydrateFromRoom(room);
bindCodecNegotiation();
bindScreenShareMigration();
bindScreenShareNegotiation();
dependencies.permissions.applyDeafen(room, getEffectiveAudioState().effectiveDeaf);
dependencies.connection.markConnected();
await callbacks.onConnected();
@@ -335,8 +346,7 @@ export function bindRoomEvents(
guard(attemptId, () => {
participantSpeakingDisposers.forEach(({dispose}) => dispose());
participantSpeakingDisposers.clear();
unbindCodecNegotiation();
unbindScreenShareMigration();
unbindScreenShareNegotiation();
clearAllScreenShareDecoderVerifications();
clearAllRemoteTrackLifecycleBindings();
dependencies.remoteSpeaking.clear();
@@ -610,4 +620,7 @@ export function bindRoomEvents(
}
}),
);
if (room.state === ConnectionState.Connected) {
guard(attemptId, bindScreenShareNegotiation)();
}
}
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type VoiceEngineV2AppScreenShareControllerGateway,
VoiceEngineV2AppScreenShareControllerRouting,
} from '@app/features/voice/engine/v2/VoiceEngineV2AppScreenShareControllerRouting';
import type {VoiceEngineV2AppScreenShareExecutionAdapter} from '@app/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter';
import {
getRecentScreenShares,
recordScreenShareStarted,
resetRecentScreenSharesForTests,
} from '@app/features/voice/utils/ScreenShareLifecycleLog';
import {beforeEach, describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/voice/engine/voice_screen_share_manager/shared', () => ({
logger: {debug: () => undefined, info: () => undefined, warn: () => undefined, error: () => undefined},
}));
vi.mock('@app/features/voice/state/LocalVoiceState', () => ({
default: {getSelfStream: () => true},
}));
const setEnabled = vi.fn(async () => undefined);
function createAdapter(): VoiceEngineV2AppScreenShareExecutionAdapter {
return {liveKitFlows: {setEnabled}} as unknown as VoiceEngineV2AppScreenShareExecutionAdapter;
}
function createGateway(
overrides: Partial<VoiceEngineV2AppScreenShareControllerGateway> = {},
): VoiceEngineV2AppScreenShareControllerGateway {
return {
isScreenCommandRoutable: () => true,
hasScreenPublication: () => true,
hasScreenDesired: () => false,
clearScreenDesired: () => undefined,
executingScreenOperationId: () => null,
isScreenOperationPending: () => true,
publishScreen: async () => undefined,
unpublishScreen: async () => undefined,
...overrides,
};
}
describe('routed screen share stop attribution', () => {
beforeEach(() => {
resetRecentScreenSharesForTests();
setEnabled.mockClear();
});
it('keeps a stop the user asked for as a user stop when the controller carries it out', async () => {
const routing = new VoiceEngineV2AppScreenShareControllerRouting(createAdapter());
let executingOperationId: number | null = null;
routing.setGateway(
createGateway({
executingScreenOperationId: () => executingOperationId,
unpublishScreen: async (onPlanned) => {
onPlanned([7]);
executingOperationId = 7;
await routing.unpublishViaLiveKitFlows(null);
},
}),
);
recordScreenShareStarted();
await routing.setEnabled(null, false, {sendUpdate: true, playSound: true});
expect(setEnabled).toHaveBeenCalledTimes(1);
expect(getRecentScreenShares()[0].stopTrigger).toBe('user');
});
it('records an unpublish the app never asked for as a gateway echo', async () => {
const routing = new VoiceEngineV2AppScreenShareControllerRouting(createAdapter());
routing.setGateway(createGateway());
recordScreenShareStarted();
await routing.unpublishViaLiveKitFlows(null);
expect(getRecentScreenShares()[0].stopTrigger).toBe('gateway-echo');
});
});
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
import type {VoiceEngineV2AppScreenShareExecutionAdapter} from '@app/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter';
import {logger} from '@app/features/voice/engine/voice_screen_share_manager/shared';
import LocalVoiceState from '@app/features/voice/state/LocalVoiceState';
import {recordScreenShareStopped} from '@app/features/voice/utils/ScreenShareLifecycleLog';
import type {VoiceEngineV2ScreenOptions} from '@fluxer/voice_engine_v2';
import type {Room, ScreenShareCaptureOptions, TrackPublishOptions, VideoCodec} from 'livekit-client';
@@ -445,6 +446,7 @@ export class VoiceEngineV2AppScreenShareControllerRouting {
async unpublishViaLiveKitFlows(roomFromPort: Room | null): Promise<void> {
const request = this.takeStopRequest(this.executingScreenOperationId());
recordScreenShareStopped(request !== null ? 'user' : 'gateway-echo');
const room = request !== null ? request.room : roomFromPort;
try {
await this.adapter.liveKitFlows.setEnabled(room, false, {
@@ -78,6 +78,15 @@ import {
disarmNativeAudio,
reconfigureLinuxNativeAudioRouting,
} from '@app/features/voice/utils/NativeAudioCaptureBridge';
import {
recordScreenShareEncoderVerification,
recordScreenShareEndedModal,
recordScreenShareRequestedCodec,
recordScreenShareStartError,
recordScreenShareStarted,
recordScreenShareStopped,
type ScreenShareEndedModal,
} from '@app/features/voice/utils/ScreenShareLifecycleLog';
import {SCREEN_SHARE_DEGRADATION_PREFERENCE} from '@app/features/voice/utils/ScreenShareOptions';
import {ScreenShareRollbackIncompleteError} from '@app/features/voice/utils/ScreenShareRollbackIncompleteError';
import {handleScreenShareError} from '@app/features/voice/utils/ScreenShareUtils';
@@ -400,7 +409,8 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
this.transitionScreenShareLifecycleInternal({type: 'share.encoderVerification.cleared'});
}
showScreenShareEndedModalInternal(description: string): void {
showScreenShareEndedModalInternal(description: string, modal: ScreenShareEndedModal): void {
recordScreenShareEndedModal(modal);
ModalCommands.pushWithKey(
ModalCommands.modal(() => (
<GenericErrorModal
@@ -491,9 +501,11 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
i18n._(
cause === 'stalled' ? SCREEN_SHARE_ENCODER_FAILED_DESCRIPTOR : SCREEN_SHARE_CODEC_POLICY_FAILED_DESCRIPTOR,
),
cause === 'stalled' ? 'encoder-failed' : 'codec-policy-failed',
);
if (this.endedScreenShareStopInFlight) return;
if (!this.isScreenShareTrackPublishedInternal(participant, track)) return;
recordScreenShareStopped('codec-republish-failed');
this.transitionScreenShareLifecycleInternal({type: 'share.endedStop.start'});
const stopPromise = this.setScreenShareEnabled(room, false, {sendUpdate: true, playSound: true})
.catch((error) => {
@@ -516,6 +528,7 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
): void {
this.cancelEncoderVerificationInternal();
if (!codec) return;
recordScreenShareRequestedCodec(codec);
const publication = preferredTrack ? undefined : participant.getTrackPublication(Track.Source.ScreenShare);
const track = preferredTrack ?? (publication?.videoTrack as LocalVideoTrack | undefined);
const sender = track?.sender;
@@ -530,8 +543,10 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
const action = resolveScreenShareEncoderVerificationAction(failure);
switch (action.kind) {
case 'ignore-repeated-stall':
recordScreenShareEncoderVerification('ignore-repeated-stall', null);
return;
case 'recover-stalled':
recordScreenShareEncoderVerification('recover-stalled', null);
logger.warn('Screen share encoder verification failed', {
codec: action.codec,
failureReason: 'screen-share-encode-stalled',
@@ -546,12 +561,14 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
);
return;
case 'accept-negotiated':
recordScreenShareEncoderVerification('accept-negotiated', action.negotiated);
logger.info('Screen share publisher negotiated a different codec inside the publish policy', {
requested: action.requested,
negotiated: action.negotiated,
});
return;
case 'correct-negotiated':
recordScreenShareEncoderVerification('correct-negotiated', action.negotiated);
logger.warn('Screen share is sending a codec outside the publish policy', {
requested: action.requested,
negotiated: action.negotiated,
@@ -599,7 +616,8 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
trigger,
readyState: mediaStreamTrack.readyState,
});
this.showScreenShareEndedModalInternal(i18n._(SCREEN_SHARE_SOURCE_STOPPED_DESCRIPTOR));
this.showScreenShareEndedModalInternal(i18n._(SCREEN_SHARE_SOURCE_STOPPED_DESCRIPTOR), 'source-stopped');
recordScreenShareStopped('media-track-ended');
this.transitionScreenShareLifecycleInternal({type: 'share.endedStop.start'});
const stopPromise = this.setScreenShareEnabled(room, false, {sendUpdate: true, playSound: true})
.catch((error) => {
@@ -663,6 +681,7 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
}
handleLocalScreenShareTrackUnpublished(room: Room, playSound: boolean, publication?: LocalTrackPublication): void {
recordScreenShareStopped(this.isScreenSharePending ? 'user' : 'server-unpublish');
this.clearScreenShareKeepAliveSinkInternal();
this.cleanupActiveScreenShareEndListenerInternal();
const participant = room.localParticipant;
@@ -866,7 +885,17 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
publishOptions?: TrackPublishOptions,
): Promise<void> {
assert.equal(typeof enabled, 'boolean');
await this.controllerRouting.setEnabled(room, enabled, options, publishOptions);
if (enabled) {
recordScreenShareStarted();
}
try {
await this.controllerRouting.setEnabled(room, enabled, options, publishOptions);
} catch (error) {
if (enabled) {
recordScreenShareStartError(error);
}
throw error;
}
if (!enabled && options?.preserveStreamAudioPreferences !== true) {
VoiceSettingsCommands.update({
shareAppAudio: true,
@@ -908,7 +937,13 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
options?: DeviceScreenShareCaptureOptions,
publishOptions?: TrackPublishOptions,
): Promise<void> {
await this.liveKitFlows.startDeviceScreenShare(room, options, publishOptions);
recordScreenShareStarted();
try {
await this.liveKitFlows.startDeviceScreenShare(room, options, publishOptions);
} catch (error) {
recordScreenShareStartError(error);
throw error;
}
}
async replaceActiveDisplayScreenShare(
@@ -2,6 +2,7 @@
import {
type PerTrackStats,
type TransportInfo,
VoiceEngineV2AppStatsHostAdapter,
type VoiceEngineV2AppStatsHostAdapterScheduler,
} from '@app/features/voice/engine/v2/VoiceEngineV2AppStatsHostAdapter';
@@ -335,3 +336,124 @@ describe('VoiceEngineV2AppStatsHostAdapter firefox outbound rows', () => {
expect(rows[classification.localScreenShareTrackIndex!].mid).toBe('4');
});
});
function chromiumWatchedScreenShareReports(): Map<string, unknown> {
return new Map<string, unknown>([
[
'C1',
{
type: 'codec',
id: 'C1',
mimeType: 'video/H264',
payloadType: 108,
sdpFmtpLine: 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
},
],
[
'IT01V',
{
type: 'inbound-rtp',
id: 'IT01V',
kind: 'video',
ssrc: 2001,
mid: '5',
codecId: 'C1',
trackIdentifier: 'screen-share-track',
bytesReceived: 3_500_000,
packetsReceived: 4210,
framesReceived: 0,
framesDecoded: 0,
keyFramesDecoded: 0,
pliCount: 7,
frameWidth: 1920,
frameHeight: 1080,
},
],
[
'CP1',
{
type: 'candidate-pair',
id: 'CP1',
state: 'succeeded',
nominated: true,
selected: true,
localCandidateId: 'LC1',
remoteCandidateId: 'RC1',
currentRoundTripTime: 0.023,
},
],
[
'LC1',
{
type: 'local-candidate',
id: 'LC1',
candidateType: 'relay',
protocol: 'udp',
relayProtocol: 'tls',
url: 'turn:turn.fluxer.example:443?transport=tcp',
networkType: 'wifi',
},
],
['RC1', {type: 'remote-candidate', id: 'RC1', candidateType: 'host', protocol: 'udp'}],
[
'T1',
{type: 'transport', id: 'T1', dtlsState: 'connected', iceState: 'connected', selectedCandidatePairId: 'CP1'},
],
]);
}
async function collectWatchedScreenShareStats(): Promise<{
rows: Array<PerTrackStats>;
transport: TransportInfo | null;
}> {
const scheduler = createScheduler();
const subscriber = {
getStats() {
return Promise.resolve(chromiumWatchedScreenShareReports());
},
};
const adapter = new VoiceEngineV2AppStatsHostAdapter({now: () => 1000, scheduler});
adapter.setRoom({
engine: {pcManager: {subscriber, mode: 'subscriber-primary'}},
numParticipants: 2,
} as unknown as Room);
adapter.startStatsTracking();
scheduler.runStatsTick();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
const rows = adapter.perTrackStats;
const transport = adapter.subscriberTransport;
adapter.cleanup();
return {rows, transport};
}
describe('VoiceEngineV2AppStatsHostAdapter watched screen share rows', () => {
it('collects the inbound packet, byte and frame counters that tell a stalled decode apart from a dead feed', async () => {
const {rows} = await collectWatchedScreenShareStats();
expect(rows.map((row) => [row.packetsReceived, row.bytesReceived, row.framesReceived, row.framesDecoded])).toEqual([
[4210, 3_500_000, 0, 0],
]);
});
it('collects the codec sdp fmtp line of the receive row', async () => {
const {rows} = await collectWatchedScreenShareStats();
expect(rows[0].codecSdpFmtpLine).toBe('level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f');
});
it('collects the relay protocol of the selected local candidate, which the local protocol never shows', async () => {
const {transport} = await collectWatchedScreenShareStats();
expect(transport?.localProtocol).toBe('udp');
expect(transport?.localRelayProtocol).toBe('tls');
expect(transport?.localCandidateUrlHost).toBe('turn.fluxer.example');
});
it('collects the peer connection mode alongside the transport block', async () => {
const {transport} = await collectWatchedScreenShareStats();
expect(transport?.peerConnectionMode).toBe('subscriber-primary');
});
});
@@ -59,11 +59,15 @@ export interface PerTrackStats {
trackIdentifier?: string;
mediaSourceId?: string;
codec?: string;
codecSdpFmtpLine?: string;
payloadType?: number;
bitrateKbps: number;
bitrateWindowMs?: number;
packetsLost?: number;
packetsLossPercent?: number;
packetsReceived?: number;
bytesReceived?: number;
framesReceived?: number;
jitterMs?: number;
framesPerSecond?: number;
sourceFramesPerSecond?: number;
@@ -121,6 +125,9 @@ export interface TransportInfo {
candidatePairState?: string;
localCandidateType?: string;
localProtocol?: string;
localRelayProtocol?: string;
localCandidateUrlHost?: string;
peerConnectionMode?: string;
localNetworkType?: string;
remoteCandidateType?: string;
remoteProtocol?: string;
@@ -156,6 +163,7 @@ type RoomWithEngine = Room & {
pcManager?: {
publisher?: StatsSource;
subscriber?: StatsSource;
mode?: string;
};
};
};
@@ -191,6 +199,7 @@ interface RTCStatsEntry {
availableIncomingBitrate?: number;
codecId?: string;
mimeType?: string;
sdpFmtpLine?: string;
payloadType?: number;
ssrc?: number;
rid?: string;
@@ -205,6 +214,7 @@ interface RTCStatsEntry {
width?: number;
height?: number;
frames?: number;
framesReceived?: number;
framesEncoded?: number;
framesDecoded?: number;
framesDropped?: number;
@@ -223,6 +233,8 @@ interface RTCStatsEntry {
remoteCandidateId?: string;
candidateType?: string;
protocol?: string;
relayProtocol?: string;
url?: string;
networkType?: string;
nominated?: boolean;
selected?: boolean;
@@ -374,6 +386,9 @@ function buildInboundTrackExtras(
decoderAcceleration: VideoAccelerationStatus | undefined,
): Partial<PerTrackStats> {
return {
packetsReceived: report.packetsReceived,
bytesReceived: report.bytesReceived,
framesReceived: report.framesReceived,
keyFramesDecoded: report.keyFramesDecoded,
decoderImplementation: report.decoderImplementation,
powerEfficientDecoder: report.powerEfficientDecoder,
@@ -428,6 +443,7 @@ function buildPerTrackStat(args: {
(isOutbound && report.mid ? midToSenderTrackId.get(report.mid) : undefined),
mediaSourceId: report.mediaSourceId,
codec: codec?.mimeType,
codecSdpFmtpLine: codec?.sdpFmtpLine,
payloadType: codec?.payloadType,
bitrateKbps: Math.round(bitrate.bitrateKbps),
bitrateWindowMs: bitrate.windowMs,
@@ -466,10 +482,20 @@ function buildPerTrackStat(args: {
};
}
function getCandidateUrlHost(url: string | undefined): string | undefined {
if (!url) return undefined;
const schemeIndex = url.indexOf(':');
const authority = (schemeIndex === -1 ? url : url.slice(schemeIndex + 1)).split('?')[0];
const portIndex = authority.lastIndexOf(':');
const host = portIndex > 0 ? authority.slice(0, portIndex) : authority;
return host || undefined;
}
function buildTransportInfo(
activePair: RTCStatsEntry | null,
transportReport: RTCStatsEntry | null,
reportsById: Map<string, RTCStatsEntry>,
peerConnectionMode: string | undefined,
): TransportInfo | null {
if (!activePair && !transportReport) return null;
const local = activePair?.localCandidateId ? reportsById.get(activePair.localCandidateId) : undefined;
@@ -478,6 +504,9 @@ function buildTransportInfo(
candidatePairState: activePair?.state,
localCandidateType: local?.candidateType,
localProtocol: local?.protocol,
localRelayProtocol: local?.relayProtocol,
localCandidateUrlHost: getCandidateUrlHost(local?.url),
peerConnectionMode,
localNetworkType: local?.networkType,
remoteCandidateType: remote?.candidateType,
remoteProtocol: remote?.protocol,
@@ -503,6 +532,7 @@ async function collectFromStatsSource(
now: number,
rtpCounters: Map<string, VoiceStatsRtpCounter>,
activeCounterIds: Set<string>,
peerConnectionMode?: string,
): Promise<{
tracks: Array<PerTrackStats>;
rtt: number;
@@ -548,7 +578,7 @@ async function collectFromStatsSource(
const selectedPair = reportsById.get(transportReport.selectedCandidatePairId);
if (selectedPair?.type === 'candidate-pair') activePair = selectedPair;
}
const transport = buildTransportInfo(activePair, transportReport, reportsById);
const transport = buildTransportInfo(activePair, transportReport, reportsById, peerConnectionMode);
return {tracks, rtt, transport};
}
@@ -866,6 +896,7 @@ export class VoiceEngineV2AppStatsHostAdapter extends Store {
if (!engine?.pcManager) return;
const publisher = engine.pcManager.publisher;
const subscriber = engine.pcManager.subscriber;
const peerConnectionMode = engine.pcManager.mode;
if (!publisher && !subscriber) return;
const now = this.now();
const tracks: Array<PerTrackStats> = [];
@@ -875,13 +906,27 @@ export class VoiceEngineV2AppStatsHostAdapter extends Store {
let publisherTransport: TransportInfo | null = null;
let subscriberTransport: TransportInfo | null = null;
if (publisher) {
const result = await collectFromStatsSource(publisher, 'publisher', now, rtpCounters, activeCounterIds);
const result = await collectFromStatsSource(
publisher,
'publisher',
now,
rtpCounters,
activeCounterIds,
peerConnectionMode,
);
tracks.push(...result.tracks);
if (result.rtt > rtt) rtt = result.rtt;
publisherTransport = result.transport;
}
if (subscriber) {
const result = await collectFromStatsSource(subscriber, 'subscriber', now, rtpCounters, activeCounterIds);
const result = await collectFromStatsSource(
subscriber,
'subscriber',
now,
rtpCounters,
activeCounterIds,
peerConnectionMode,
);
tracks.push(...result.tracks);
if (result.rtt > rtt) rtt = result.rtt;
subscriberTransport = result.transport;
@@ -1,11 +1,38 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {findStalledVideoEncoder} from '@app/features/voice/engine/voice_screen_share_manager/shared';
import {describe, expect, test, vi} from 'vitest';
import {
findStalledVideoEncoder,
scheduleScreenShareEncoderVerification,
shouldTriggerSoftwareEncoderWarning,
} from '@app/features/voice/engine/voice_screen_share_manager/shared';
import SoftwareEncoderWarning from '@app/features/voice/state/SoftwareEncoderWarning';
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
vi.mock('@app/features/voice/utils/VideoQualityEntitlement', () => ({hasHigherVideoQuality: () => false}));
let h264Acceleration = 'hardware';
const markSoftwareEncodeObserved = vi.fn();
vi.mock('@app/features/voice/utils/CodecCapabilityDetector', () => ({
adjustScreenShareEncodingForCodec: (encoding: unknown) => encoding,
getCodecCapabilityReport: () => ({
vp8: {hardwareAccelerated: 'software'},
vp9: {hardwareAccelerated: 'software'},
h264: {hardwareAccelerated: h264Acceleration},
h265: {hardwareAccelerated: 'software'},
av1: {hardwareAccelerated: 'software'},
}),
markScreenShareCodecSoftwareEncodeObserved: (codec: string) => markSoftwareEncodeObserved(codec),
resolveVideoPublishCodecPolicy: (requested: string) => ({
allowed: [requested],
requested,
primary: requested,
backupCodec: false,
}),
}));
function makeStats(entries: ReadonlyArray<Record<string, unknown>>): RTCStatsReport {
return new Map(entries.map((entry) => [entry.id as string, entry])) as unknown as RTCStatsReport;
}
@@ -59,3 +86,72 @@ describe('findStalledVideoEncoder', () => {
expect(findStalledVideoEncoder(stats, 'h264')?.sourceFrames).toBe(240);
});
});
describe('the screen share encoder verification', () => {
function openH264Stats(): RTCStatsReport {
return makeStats([
CODEC_REPORT,
SOURCE_REPORT,
outboundReport({
active: true,
framesEncoded: 120,
framesSent: 120,
encoderImplementation: 'OpenH264',
powerEfficientEncoder: false,
}),
]);
}
async function verifyOpenH264Share(): Promise<void> {
scheduleScreenShareEncoderVerification(async () => openH264Stats(), 'h264');
await vi.advanceTimersByTimeAsync(2500);
}
beforeEach(() => {
vi.useFakeTimers();
h264Acceleration = 'hardware';
markSoftwareEncodeObserved.mockClear();
SoftwareEncoderWarning.reset();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
SoftwareEncoderWarning.reset();
});
test('records a software session verdict when the encoder turns out to be OpenH264', async () => {
await verifyOpenH264Share();
expect(markSoftwareEncodeObserved).toHaveBeenCalledWith('h264');
});
test('warns about the software encoder while the capability layer still claims hardware', async () => {
await verifyOpenH264Share();
expect(SoftwareEncoderWarning.showWarning).toBe(true);
});
test('does not warn once the session verdict says the codec encodes in software', async () => {
h264Acceleration = 'software';
await verifyOpenH264Share();
expect(SoftwareEncoderWarning.showWarning).toBe(false);
expect(markSoftwareEncodeObserved).toHaveBeenCalledWith('h264');
});
test('records no verdict when the user asked for the software encoder', async () => {
vi.spyOn(VoiceSettings, 'getScreenShareEncoderMode').mockReturnValue('software');
await verifyOpenH264Share();
expect(markSoftwareEncodeObserved).not.toHaveBeenCalled();
});
});
describe('the software encoder warning gate', () => {
test('stays quiet once the session verdict says the codec encodes in software', () => {
h264Acceleration = 'software';
expect(shouldTriggerSoftwareEncoderWarning('h264')).toBe(false);
});
test('still speaks up while the capability layer claims hardware', () => {
h264Acceleration = 'hardware';
expect(shouldTriggerSoftwareEncoderWarning('h264')).toBe(true);
});
});
@@ -9,6 +9,7 @@ import VoiceSettings from '@app/features/voice/state/VoiceSettings';
import {
adjustScreenShareEncodingForCodec,
getCodecCapabilityReport,
markScreenShareCodecSoftwareEncodeObserved,
resolveVideoPublishCodecPolicy,
type VideoPublishCodecPolicy,
} from '@app/features/voice/utils/CodecCapabilityDetector';
@@ -681,7 +682,11 @@ export function scheduleScreenShareEncoderVerification(
powerEfficientEncoder: encoder.powerEfficientEncoder,
expectedHardware,
});
if (shouldTriggerSoftwareEncoderWarning(codec)) {
const warnAboutSoftwareEncoder = shouldTriggerSoftwareEncoderWarning(codec);
if (VoiceSettings.getScreenShareEncoderMode() !== 'software') {
markScreenShareCodecSoftwareEncodeObserved(codec);
}
if (warnAboutSoftwareEncoder) {
SoftwareEncoderWarning.triggerWarning(codec, encoder.implementation);
}
} else {
@@ -0,0 +1,220 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {voiceMediaGraphStore} from '@app/features/voice/engine/VoiceMediaGraphStore';
import {ScreenShareWatchErrorCode, ScreenShareWatchFailures} from '@app/features/voice/state/ScreenShareWatchFailures';
import type {RemoteTrackPublication} from 'livekit-client';
import {beforeEach, describe, expect, it} from 'vitest';
function inboundStatsReport(): RTCStatsReport {
return new Map<string, unknown>([
[
'IT01V',
{
type: 'inbound-rtp',
id: 'IT01V',
kind: 'video',
codecId: 'C1',
packetsReceived: 4210,
bytesReceived: 3_500_000,
framesReceived: 0,
framesDecoded: 0,
keyFramesDecoded: 0,
framesDropped: 0,
pliCount: 7,
firCount: 0,
nackCount: 2,
freezeCount: 0,
decoderImplementation: 'ExternalDecoder (D3D11VideoDecoder)',
powerEfficientDecoder: true,
frameWidth: 1920,
frameHeight: 1080,
},
],
[
'C1',
{
type: 'codec',
id: 'C1',
mimeType: 'video/H264',
sdpFmtpLine: 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
},
],
]) as unknown as RTCStatsReport;
}
function publicationWithStats(report: RTCStatsReport): RemoteTrackPublication {
return {
videoTrack: {
getRTCStatsReport: () => Promise.resolve(report),
},
} as unknown as RemoteTrackPublication;
}
async function settleStatsSnapshot(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}
function fireWatchDeadlines(streamKey: string): void {
const snapshot = voiceMediaGraphStore.getGraphSnapshot();
const dueAt = voiceMediaGraphStore.nowMs() + 60_000;
for (const [key, deadline] of snapshot.deadlinesByKey) {
if (deadline.streamKey !== streamKey) continue;
voiceMediaGraphStore.transition({type: 'time.deadlineFired', key, at: dueAt});
}
}
function historyFor(streamKey: string) {
return ScreenShareWatchFailures.getFailureHistory().filter((record) => record.streamKey === streamKey);
}
function watchDeadlineFor(streamKey: string) {
const snapshot = voiceMediaGraphStore.getGraphSnapshot();
for (const deadline of snapshot.deadlinesByKey.values()) {
if (deadline.kind === 'watchAttempt' && deadline.streamKey === streamKey) return deadline;
}
return null;
}
describe('ScreenShareWatchFailures failure history', () => {
beforeEach(() => {
voiceMediaGraphStore.reset();
});
it('keeps a reported failure after the viewer stops watching', () => {
const streamKey = 'guild-a:channel-a:connection-stop';
ScreenShareWatchFailures.markWatchStarted(streamKey);
ScreenShareWatchFailures.reportFailure({
streamKey,
participantIdentity: 'user_1_connection-stop',
trackSid: 'TR_stop',
code: ScreenShareWatchErrorCode.RemoteTrackSubscriptionFailed,
reason: 'remote-track-subscription-failed',
});
expect(ScreenShareWatchFailures.getFailure({streamKey})).not.toBeNull();
ScreenShareWatchFailures.markWatchStopped(streamKey);
expect(ScreenShareWatchFailures.getFailure({streamKey})).toBeNull();
expect(historyFor(streamKey).map((record) => [record.code, record.trackSid, record.participantIdentity])).toEqual([
[ScreenShareWatchErrorCode.RemoteTrackSubscriptionFailed, 'TR_stop', 'user_1_connection-stop'],
]);
});
it('keeps a failure raised by a watch deadline after the viewer stops watching', () => {
const streamKey = 'guild-a:channel-a:connection-deadline';
const attemptKey = `${streamKey}:1:watch`;
ScreenShareWatchFailures.markWatchStarted(streamKey);
ScreenShareWatchFailures.ensureAttempt({streamKey}, attemptKey);
ScreenShareWatchFailures.setWatchTarget(streamKey, {videoRef: {current: null}});
fireWatchDeadlines(streamKey);
const graphFailure = ScreenShareWatchFailures.getFailure({streamKey});
expect(graphFailure).not.toBeNull();
ScreenShareWatchFailures.markWatchStopped(streamKey);
expect(ScreenShareWatchFailures.getFailure({streamKey})).toBeNull();
expect(historyFor(streamKey).map((record) => record.code)).toEqual([graphFailure!.code]);
});
it('records the inbound counters of the watched publication alongside the failure', async () => {
const streamKey = 'guild-a:channel-a:connection-counters';
const attemptKey = `${streamKey}:1:watch`;
ScreenShareWatchFailures.markWatchStarted(streamKey);
ScreenShareWatchFailures.ensureAttempt({streamKey}, attemptKey);
ScreenShareWatchFailures.setWatchTarget(streamKey, {
videoRef: {current: null},
publication: publicationWithStats(inboundStatsReport()),
});
ScreenShareWatchFailures.reportFailure({
streamKey,
trackSid: 'TR_counters',
code: ScreenShareWatchErrorCode.FirstFrameTimeout,
reason: 'first-frame-timeout',
});
await settleStatsSnapshot();
expect(historyFor(streamKey)[0].inbound).toEqual({
packetsReceived: 4210,
bytesReceived: 3_500_000,
framesReceived: 0,
framesDecoded: 0,
keyFramesDecoded: 0,
framesDropped: 0,
pliCount: 7,
firCount: 0,
nackCount: 2,
freezeCount: 0,
decoderImplementation: 'ExternalDecoder (D3D11VideoDecoder)',
powerEfficientDecoder: true,
mimeType: 'video/H264',
sdpFmtpLine: 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
frameWidth: 1920,
frameHeight: 1080,
});
});
it('records the size and readiness of the attached video element', () => {
const streamKey = 'guild-a:channel-a:connection-tile';
const attemptKey = `${streamKey}:1:watch`;
const video = {readyState: 0, videoWidth: 0, videoHeight: 0, clientWidth: 960, clientHeight: 540};
ScreenShareWatchFailures.markWatchStarted(streamKey);
ScreenShareWatchFailures.ensureAttempt({streamKey}, attemptKey);
ScreenShareWatchFailures.setWatchTarget(streamKey, {
videoRef: {current: video as unknown as HTMLVideoElement},
});
ScreenShareWatchFailures.reportFailure({
streamKey,
code: ScreenShareWatchErrorCode.FirstFrameTimeout,
reason: 'first-frame-timeout',
});
expect(historyFor(streamKey)[0].tile).toMatchObject({
hasVideoElement: true,
readyState: 0,
videoWidth: 0,
videoHeight: 0,
clientWidth: 960,
clientHeight: 540,
});
});
it('leaves the running watch deadline alone when the watch target is registered', () => {
const streamKey = 'guild-a:channel-a:connection-target';
const attemptKey = `${streamKey}:1:watch`;
ScreenShareWatchFailures.markWatchStarted(streamKey);
ScreenShareWatchFailures.ensureAttempt({streamKey}, attemptKey);
const deadline = watchDeadlineFor(streamKey);
expect(deadline).not.toBeNull();
ScreenShareWatchFailures.setWatchTarget(streamKey, {videoRef: {current: null}});
ScreenShareWatchFailures.setWatchTarget(streamKey, {
videoRef: {current: null},
publication: publicationWithStats(inboundStatsReport()),
});
expect(watchDeadlineFor(streamKey)).toBe(deadline);
});
it('keeps only the most recent failures once the ring is full', () => {
const streamKey = 'guild-a:channel-a:connection-ring';
ScreenShareWatchFailures.markWatchStarted(streamKey);
for (let index = 0; index < 20; index += 1) {
ScreenShareWatchFailures.reportFailure({
streamKey,
trackSid: `TR_ring_${index}`,
code: ScreenShareWatchErrorCode.FirstFrameTimeout,
reason: 'first-frame-timeout',
});
}
const history = historyFor(streamKey);
expect(history.length).toBe(16);
expect([history[0].trackSid, history[history.length - 1].trackSid]).toEqual(['TR_ring_4', 'TR_ring_19']);
});
});
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {RemoteTrackPublication} from 'livekit-client';
import {runInAction} from 'mobx';
import {
selectVoiceMediaGraphAttempt,
@@ -15,6 +16,9 @@ import type {ScreenSharePublicationOperation} from '../utils/ScreenShareSubscrip
export type {ScreenSharePublicationOperation};
const FAILURE_HISTORY_LIMIT = 16;
const HARVESTED_FAILURE_ID_LIMIT = 64;
export const ScreenShareWatchErrorCode = {
SubscriptionSetSubscribedFailed: -2101,
SubscriptionSetEnabledFailed: -2102,
@@ -76,6 +80,158 @@ export interface ScreenShareWatchAttempt {
generation: number;
}
export interface ScreenShareWatchFailureInboundSnapshot {
packetsReceived: number | null;
bytesReceived: number | null;
framesReceived: number | null;
framesDecoded: number | null;
keyFramesDecoded: number | null;
framesDropped: number | null;
pliCount: number | null;
firCount: number | null;
nackCount: number | null;
freezeCount: number | null;
decoderImplementation: string | null;
powerEfficientDecoder: boolean | null;
mimeType: string | null;
sdpFmtpLine: string | null;
frameWidth: number | null;
frameHeight: number | null;
}
export interface ScreenShareWatchFailureTileSnapshot {
hasVideoElement: boolean;
readyState: number | null;
videoWidth: number | null;
videoHeight: number | null;
clientWidth: number | null;
clientHeight: number | null;
visibilityState: string | null;
}
export interface ScreenShareWatchFailureRecord {
code: ScreenShareWatchErrorCode;
reason: ScreenShareWatchFailureReason;
reportedAt: number;
streamKey: string | null;
trackSid: string | null;
participantIdentity: string | null;
inbound: ScreenShareWatchFailureInboundSnapshot | null;
tile: ScreenShareWatchFailureTileSnapshot | null;
}
export interface ScreenShareWatchVideoElementRef {
current: HTMLVideoElement | null;
}
export interface ScreenShareWatchTarget {
videoRef?: ScreenShareWatchVideoElementRef | null;
publication?: RemoteTrackPublication | null;
}
interface InboundStatsEntry {
type?: string;
kind?: string;
mediaType?: string;
codecId?: string;
packetsReceived?: number;
bytesReceived?: number;
framesReceived?: number;
framesDecoded?: number;
keyFramesDecoded?: number;
framesDropped?: number;
pliCount?: number;
firCount?: number;
nackCount?: number;
freezeCount?: number;
decoderImplementation?: string;
powerEfficientDecoder?: boolean;
frameWidth?: number;
frameHeight?: number;
mimeType?: string;
sdpFmtpLine?: string;
}
function numberOrNull(value: number | undefined): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function stringOrNull(value: string | undefined): string | null {
return typeof value === 'string' && value !== '' ? value : null;
}
function findInboundVideoEntry(report: RTCStatsReport): InboundStatsEntry | null {
for (const raw of report.values()) {
const entry = raw as InboundStatsEntry;
if (entry.type !== 'inbound-rtp') continue;
const kind = entry.kind ?? entry.mediaType;
if (kind !== 'video') continue;
return entry;
}
return null;
}
function buildInboundSnapshot(report: RTCStatsReport): ScreenShareWatchFailureInboundSnapshot | null {
const inbound = findInboundVideoEntry(report);
if (!inbound) return null;
const codec = inbound.codecId ? (report.get(inbound.codecId) as InboundStatsEntry | undefined) : undefined;
return {
packetsReceived: numberOrNull(inbound.packetsReceived),
bytesReceived: numberOrNull(inbound.bytesReceived),
framesReceived: numberOrNull(inbound.framesReceived),
framesDecoded: numberOrNull(inbound.framesDecoded),
keyFramesDecoded: numberOrNull(inbound.keyFramesDecoded),
framesDropped: numberOrNull(inbound.framesDropped),
pliCount: numberOrNull(inbound.pliCount),
firCount: numberOrNull(inbound.firCount),
nackCount: numberOrNull(inbound.nackCount),
freezeCount: numberOrNull(inbound.freezeCount),
decoderImplementation: stringOrNull(inbound.decoderImplementation),
powerEfficientDecoder: typeof inbound.powerEfficientDecoder === 'boolean' ? inbound.powerEfficientDecoder : null,
mimeType: stringOrNull(codec?.mimeType),
sdpFmtpLine: stringOrNull(codec?.sdpFmtpLine),
frameWidth: numberOrNull(inbound.frameWidth),
frameHeight: numberOrNull(inbound.frameHeight),
};
}
function buildTileSnapshot(videoRef: ScreenShareWatchVideoElementRef | null | undefined) {
const video = videoRef?.current ?? null;
const visibilityState = typeof document === 'undefined' ? null : document.visibilityState;
if (!video) {
return {
hasVideoElement: false,
readyState: null,
videoWidth: null,
videoHeight: null,
clientWidth: null,
clientHeight: null,
visibilityState,
};
}
return {
hasVideoElement: true,
readyState: numberOrNull(video.readyState),
videoWidth: numberOrNull(video.videoWidth),
videoHeight: numberOrNull(video.videoHeight),
clientWidth: numberOrNull(video.clientWidth),
clientHeight: numberOrNull(video.clientHeight),
visibilityState,
};
}
function readInboundStatsReport(publication: RemoteTrackPublication | null | undefined) {
try {
return publication?.videoTrack?.getRTCStatsReport?.();
} catch {
return undefined;
}
}
function failureHistoryId(failure: ScreenShareWatchFailure): string {
return `${failure.code}:${failure.streamKey ?? ''}:${failure.trackSid ?? ''}:${failure.reportedAt}`;
}
export function getScreenShareWatchFailureForPublicationOperation(
operation: ScreenSharePublicationOperation,
): Pick<ScreenShareWatchFailure, 'code' | 'reason'> {
@@ -144,6 +300,11 @@ export function selectScreenShareWatchTimeoutFailureCode({
}
class ScreenShareWatchFailuresStore {
private readonly failureHistory: Array<ScreenShareWatchFailureRecord> = [];
private readonly harvestedFailureIds = new Set<string>();
private readonly watchTargetsByStreamKey = new Map<string, ScreenShareWatchTarget>();
private graphUnsubscribe: (() => void) | null = null;
private get graph(): VoiceMediaGraphSnapshot<ScreenShareWatchFailure> {
return voiceMediaGraphStore.graph as VoiceMediaGraphSnapshot<ScreenShareWatchFailure>;
}
@@ -152,8 +313,71 @@ class ScreenShareWatchFailuresStore {
voiceMediaGraphStore.transitionTypedFailure(event);
}
private rememberFailure(failure: ScreenShareWatchFailure): void {
const id = failureHistoryId(failure);
if (this.harvestedFailureIds.has(id)) return;
if (this.harvestedFailureIds.size >= HARVESTED_FAILURE_ID_LIMIT) {
const oldest = this.harvestedFailureIds.values().next().value;
if (oldest !== undefined) this.harvestedFailureIds.delete(oldest);
}
this.harvestedFailureIds.add(id);
const target = failure.streamKey ? this.watchTargetsByStreamKey.get(failure.streamKey) : undefined;
const record: ScreenShareWatchFailureRecord = {
code: failure.code,
reason: failure.reason,
reportedAt: failure.reportedAt,
streamKey: failure.streamKey ?? null,
trackSid: failure.trackSid ?? null,
participantIdentity: failure.participantIdentity ?? null,
inbound: null,
tile: buildTileSnapshot(target?.videoRef),
};
if (this.failureHistory.length >= FAILURE_HISTORY_LIMIT) this.failureHistory.shift();
this.failureHistory.push(record);
void this.attachInboundSnapshot(record, target?.publication);
}
private async attachInboundSnapshot(
record: ScreenShareWatchFailureRecord,
publication: RemoteTrackPublication | null | undefined,
): Promise<void> {
try {
const report = await readInboundStatsReport(publication);
if (!report) return;
record.inbound = buildInboundSnapshot(report);
} catch {
record.inbound = null;
}
}
private harvestGraphFailures(): void {
for (const failure of this.graph.failuresByKey.values()) {
this.rememberFailure(failure);
}
}
private ensureGraphSubscription(): void {
if (this.graphUnsubscribe !== null) return;
this.graphUnsubscribe = voiceMediaGraphStore.subscribe(() => {
this.harvestGraphFailures();
});
}
private releaseGraphSubscriptionIfIdle(): void {
if (this.watchTargetsByStreamKey.size > 0) return;
if (this.graphUnsubscribe === null) return;
this.graphUnsubscribe();
this.graphUnsubscribe = null;
}
getFailureHistory(): Array<ScreenShareWatchFailureRecord> {
this.harvestGraphFailures();
return this.failureHistory.map((record) => ({...record}));
}
markWatchStarted(streamKey: string): number {
if (!streamKey) return 0;
this.harvestGraphFailures();
let nextGeneration = 0;
runInAction(() => {
this.transition({type: 'watch.started', streamKey, at: voiceMediaGraphStore.nowMs()});
@@ -164,6 +388,7 @@ class ScreenShareWatchFailuresStore {
markWatchStopped(streamKey: string): void {
if (!streamKey) return;
this.harvestGraphFailures();
runInAction(() => {
this.transition({type: 'watch.stopped', streamKey});
});
@@ -177,6 +402,19 @@ class ScreenShareWatchFailuresStore {
return selectVoiceMediaGraphAttempt(this.graph, streamKey);
}
setWatchTarget(streamKey: string, watchTarget: ScreenShareWatchTarget): void {
if (!streamKey) return;
this.watchTargetsByStreamKey.set(streamKey, watchTarget);
this.ensureGraphSubscription();
}
clearWatchTarget(streamKey: string): void {
if (!streamKey) return;
this.harvestGraphFailures();
this.watchTargetsByStreamKey.delete(streamKey);
this.releaseGraphSubscriptionIfIdle();
}
ensureAttempt(
target: ScreenShareWatchFailureTarget & {streamKey: string},
attemptKey: string,
@@ -196,12 +434,14 @@ class ScreenShareWatchFailuresStore {
releaseAttempt(target: ScreenShareWatchFailureTarget & {streamKey: string}, attemptKey: string): void {
if (!target.streamKey || !attemptKey) return;
this.harvestGraphFailures();
runInAction(() => {
this.transition({type: 'watch.attemptReleased', streamKey: target.streamKey, attemptKey});
});
}
markRenderedVideoFrame(target: ScreenShareWatchFailureTarget & {streamKey: string}, attemptKey: string): void {
this.harvestGraphFailures();
const existingAttempt = selectVoiceMediaGraphAttempt(this.graph, target.streamKey);
if (existingAttempt && existingAttempt.attemptKey !== attemptKey) return;
const generation =
@@ -226,6 +466,7 @@ class ScreenShareWatchFailuresStore {
runInAction(() => {
this.transition({type: 'failure.reported', failure: normalizedFailure, generation: normalizedFailure.generation});
});
this.rememberFailure(normalizedFailure);
return normalizedFailure;
}
@@ -238,12 +479,14 @@ class ScreenShareWatchFailuresStore {
}
clearFailure(target: ScreenShareWatchFailureTarget): void {
this.harvestGraphFailures();
runInAction(() => {
this.transition({type: 'failure.cleared', target});
});
}
clearAll(): void {
this.harvestGraphFailures();
runInAction(() => {
this.transition({type: 'failureWatch.clearAll'});
});
@@ -7,7 +7,9 @@ import {afterEach, beforeEach, describe, expect, it, type MockInstance, vi} from
let av1OptIn = false;
let hevcOptIn = false;
let desktop = true;
let platform = 'windows';
let gpuReport: HardwareEncodeReport | null = null;
let nativeHardwareEncoder: {backend: string; codecs: ReadonlyArray<string>} | null = null;
let cameraPreference = 'auto';
let openH264Status: {enabled: boolean; downloaded: boolean} | null = null;
let codecCapabilityInfo: MockInstance<(...args: Array<unknown>) => void>;
@@ -25,7 +27,7 @@ vi.mock('@app/features/devtools/utils/DesktopTroubleshootingUtils', () => ({
}));
vi.mock('@app/features/ui/utils/NativeUtils', () => ({
guessPlatform: () => 'windows',
guessPlatform: () => platform,
isChromiumBrowser: () => true,
isDesktop: () => desktop,
isFirefoxBrowser: () => false,
@@ -36,8 +38,8 @@ vi.mock('@app/features/voice/utils/GpuEncoderCapabilities', () => ({
}));
vi.mock('@app/features/voice/utils/NativeHardwareEncoderCapabilities', () => ({
getNativeHardwareEncoderCapabilitiesSync: () => null,
hasNativeHardwareEncoder: () => false,
getNativeHardwareEncoderCapabilitiesSync: () => nativeHardwareEncoder,
hasNativeHardwareEncoder: (codec: string) => nativeHardwareEncoder?.codecs.includes(codec) === true,
resetNativeHardwareEncoderCapabilities: () => undefined,
}));
@@ -65,6 +67,7 @@ const {
getRoomVideoPublishDefaults,
isVideoCodecAllowedForPublish,
markScreenShareCodecEncodeRuntimeFailure,
markScreenShareCodecSoftwareEncodeObserved,
resetCachedCodecCapabilities,
resolveScreenShareEncoderVerificationAction,
resolveVideoPublishCodecPolicy,
@@ -288,3 +291,66 @@ describe('video publish codec policy', () => {
}
});
});
describe('H.264 hardware verdict', () => {
beforeEach(() => {
av1OptIn = false;
hevcOptIn = false;
desktop = true;
platform = 'windows';
gpuReport = null;
nativeHardwareEncoder = null;
cameraPreference = 'auto';
openH264Status = null;
senderCodecs = ALL_SENDER_CODECS;
resetCachedCodecCapabilities();
});
afterEach(() => {
platform = 'windows';
nativeHardwareEncoder = null;
resetCachedCodecCapabilities();
});
it('treats H.264 as software until the GPU probe has resolved', () => {
expect(getCodecCapabilityReport().h264.hardwareAccelerated).toBe('software');
expect(selectAutomaticScreenShareCodec('auto')).toEqual({codec: 'vp9', reason: 'software-vp9'});
});
it('does not pick hardware H.264 on a Windows AMD machine whose probe came back software', () => {
gpuReport = {...ALL_SOFTWARE, gpuFamily: 'amd-rdna3-plus'};
expect(getCodecCapabilityReport().h264.hardwareAccelerated).toBe('software');
expect(selectAutomaticScreenShareCodec('auto')).toEqual({codec: 'vp9', reason: 'software-vp9'});
});
it('keeps hardware H.264 on Linux when the probe reported a power efficient encoder', () => {
platform = 'linux';
gpuReport = {...ALL_SOFTWARE, h264: 'hardware'};
expect(getCodecCapabilityReport().h264.hardwareAccelerated).toBe('hardware');
expect(selectAutomaticScreenShareCodec('auto')).toEqual({codec: 'h264', reason: 'hardware-h264'});
});
it('does not let a macOS native VideoToolbox encoder stand in for hardware H.264', () => {
platform = 'macos';
nativeHardwareEncoder = {backend: 'videotoolbox', codecs: ['h264']};
const report = getCodecCapabilityReport();
expect(report.h264.supported).toBe(true);
expect(report.h264.hardwareAccelerated).toBe('software');
expect(selectAutomaticScreenShareCodec('auto').reason).not.toBe('hardware-h264');
});
it('still counts a native NVENC encoder as hardware H.264', () => {
platform = 'linux';
nativeHardwareEncoder = {backend: 'nvenc', codecs: ['h264']};
expect(getCodecCapabilityReport().h264.hardwareAccelerated).toBe('hardware');
expect(selectAutomaticScreenShareCodec('auto')).toEqual({codec: 'h264', reason: 'hardware-h264'});
});
it('stops selecting hardware H.264 once the session verdict says it encoded in software', () => {
gpuReport = {...ALL_SOFTWARE, h264: 'hardware'};
expect(selectAutomaticScreenShareCodec('auto')).toEqual({codec: 'h264', reason: 'hardware-h264'});
expect(markScreenShareCodecSoftwareEncodeObserved('h264')).toBe(true);
expect(getCodecCapabilityReport().h264.hardwareAccelerated).toBe('software');
expect(selectAutomaticScreenShareCodec('auto')).toEqual({codec: 'vp9', reason: 'software-vp9'});
});
});
@@ -92,6 +92,7 @@ let cachedReportHardwareAccelerationDisabled: boolean | undefined;
let cachedReportAv1OptIn: boolean | undefined;
let cachedReportHevcOptIn: boolean | undefined;
const runtimeEncodeFailureCodecs = new Set<VideoCodec>();
const observedSoftwareEncodeCodecs = new Set<VideoCodec>();
interface RawProbeResult {
caps: CodecCapabilities;
@@ -223,6 +224,11 @@ function getEffectiveScreenShareCapabilities(caps: CodecCapabilities): CodecCapa
};
}
function hasPublishPathNativeHardwareEncoder(codec: VideoCodec): boolean {
if (!hasNativeHardwareEncoder(codec)) return false;
return getNativeHardwareEncoderCapabilitiesSync()?.backend !== 'videotoolbox';
}
function buildReport(): CodecCapabilityReport {
const {caps, probedSuccessfully} = probeRawCapabilities();
const context = buildScreenShareCodecPolicyContext();
@@ -231,18 +237,19 @@ function buildReport(): CodecCapabilityReport {
const linuxNvidiaWebRtcEncodeLimited =
context.platform === 'linux' && gpuReport?.gpuFamily?.startsWith('nvidia-') === true;
const hardwareAccelerationDisabled = isDesktopHardwareAccelerationDisabled();
const openH264Status = getOpenH264StatusSync();
const openH264Active = openH264Status?.enabled === true && openH264Status.downloaded === true;
function hwAccel(codec: keyof CodecCapabilities): HardwareEncodeAnswer {
if (hardwareAccelerationDisabled) {
return 'software';
}
if (hasNativeHardwareEncoder(codec)) {
if (observedSoftwareEncodeCodecs.has(codec)) {
return 'software';
}
if (hasPublishPathNativeHardwareEncoder(codec)) {
return 'hardware';
}
const gpu = gpuReport ? gpuReport[codec] : 'unknown';
if (codec === 'h264' && openH264Active && gpu === 'unknown') {
return 'software';
if (codec === 'h264') {
return gpu === 'hardware' ? 'hardware' : 'software';
}
return gpu;
}
@@ -481,6 +488,14 @@ export function markScreenShareCodecEncodeRuntimeFailure(codec: VideoCodec, reas
return true;
}
export function markScreenShareCodecSoftwareEncodeObserved(codec: VideoCodec): boolean {
if (observedSoftwareEncodeCodecs.has(codec)) return false;
observedSoftwareEncodeCodecs.add(codec);
cachedReport = null;
logger.warn('Treating this codec as software-encoded for the rest of the session', {codec});
return true;
}
export type VideoPublishCodecDenial = 'sender-cannot-encode' | 'policy' | 'runtime-failed';
export interface VideoPublishCodecPolicy {
@@ -654,6 +669,7 @@ export function resetCachedCodecCapabilities(): void {
cachedReportAv1OptIn = undefined;
cachedReportHevcOptIn = undefined;
runtimeEncodeFailureCodecs.clear();
observedSoftwareEncodeCodecs.clear();
resetNativeHardwareEncoderCapabilities();
resetOpenH264Status();
}
@@ -0,0 +1,171 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {HardwareEncodeReport} from '@app/features/voice/utils/GpuEncoderCapabilities';
import {beforeEach, describe, expect, it, vi} from 'vitest';
let streamingMode = 'custom';
let screenshareResolution = 'high';
let videoFrameRate = 60;
let higherVideoQuality = true;
let shareSettingsUnavailable = false;
vi.mock('@app/features/voice/state/VoiceSettings', () => ({
default: {
getStreamingMode: () => {
if (shareSettingsUnavailable) throw new ReferenceError('Cannot access VoiceSettings before initialization');
return streamingMode;
},
getScreenshareResolution: () => screenshareResolution,
getVideoFrameRate: () => videoFrameRate,
},
}));
vi.mock('@app/features/voice/utils/VideoQualityEntitlement', () => ({
hasHigherVideoQuality: () => higherVideoQuality,
}));
vi.mock('@app/features/ui/utils/NativeUtils', () => ({
getElectronAPI: () => null,
getNativePlatformSync: () => 'windows',
isDesktop: () => false,
}));
interface EncodingInfoConfig {
type: string;
video: {contentType: string; width: number; height: number; bitrate: number; framerate: number};
}
const encodingInfoConfigs: Array<EncodingInfoConfig> = [];
let encodingInfoAnswer: () => {supported?: boolean; powerEfficient?: boolean} = () => ({
supported: true,
powerEfficient: false,
});
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
writable: true,
value: {
mediaCapabilities: {
encodingInfo: async (config: EncodingInfoConfig) => {
encodingInfoConfigs.push(config);
return encodingInfoAnswer();
},
},
},
});
const {
AMD_RDNA3_PLUS,
NEGOTIABLE_H264_PROBE_CONTENT_TYPE,
NVIDIA_PRE_ADA,
PCI_VENDOR_AMD,
PCI_VENDOR_APPLE,
PCI_VENDOR_NVIDIA,
probeWebRtcEncodeEfficiency,
reconcileHardwareEncodeReport,
} = await import('./GpuEncoderCapabilities');
const AMD_RDNA3_REPORT: HardwareEncodeReport = {...AMD_RDNA3_PLUS.caps, gpuFamily: AMD_RDNA3_PLUS.family};
const NVIDIA_REPORT: HardwareEncodeReport = {...NVIDIA_PRE_ADA.caps, gpuFamily: NVIDIA_PRE_ADA.family};
const PROBE_SAYS_SOFTWARE = {
av1: 'software',
h265: 'software',
h264: 'software',
vp9: 'software',
vp8: 'software',
} as const;
function h264Probes(): Array<EncodingInfoConfig> {
return encodingInfoConfigs.filter((config) => config.video.contentType.startsWith('video/H264'));
}
describe('the WebRTC encode probe', () => {
beforeEach(() => {
encodingInfoConfigs.length = 0;
streamingMode = 'custom';
screenshareResolution = 'high';
videoFrameRate = 60;
higherVideoQuality = true;
shareSettingsUnavailable = false;
encodingInfoAnswer = () => ({supported: true, powerEfficient: false});
});
it('asks about the single H.264 format the SFU can negotiate', async () => {
await probeWebRtcEncodeEfficiency();
expect(h264Probes().map((config) => config.video.contentType)).toEqual([
'video/H264;level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
]);
expect(NEGOTIABLE_H264_PROBE_CONTENT_TYPE).toBe(
'video/H264;level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
);
});
it('probes at the configured share size and frame rate', async () => {
await probeWebRtcEncodeEfficiency();
expect(h264Probes()[0]?.video).toMatchObject({width: 1920, height: 1080, framerate: 60, bitrate: 6_000_000});
});
it('follows the share settings down when the user picks a smaller share', async () => {
screenshareResolution = 'medium';
videoFrameRate = 30;
await probeWebRtcEncodeEfficiency();
expect(h264Probes()[0]?.video).toMatchObject({width: 1280, height: 720, framerate: 30, bitrate: 3_000_000});
});
it('answers software for H.264 when the negotiated format is not power efficient', async () => {
await expect(probeWebRtcEncodeEfficiency()).resolves.toMatchObject({h264: 'software'});
});
it('answers hardware for H.264 when the negotiated format is power efficient', async () => {
encodingInfoAnswer = () => ({supported: true, powerEfficient: true});
await expect(probeWebRtcEncodeEfficiency()).resolves.toMatchObject({h264: 'hardware'});
});
it('still answers at the largest share size when the share settings cannot be read', async () => {
shareSettingsUnavailable = true;
await expect(probeWebRtcEncodeEfficiency()).resolves.toMatchObject({h264: 'software'});
expect(h264Probes()[0]?.video).toMatchObject({width: 1920, height: 1080, framerate: 60, bitrate: 6_000_000});
});
});
describe('reconciling the GPU table against the probe', () => {
it('demotes H.264 to software on Windows when the negotiated format is not power efficient', () => {
const report = reconcileHardwareEncodeReport(AMD_RDNA3_REPORT, PROBE_SAYS_SOFTWARE, 'windows', PCI_VENDOR_AMD);
expect(report.h264).toBe('software');
});
it('demotes H.264 to software on macOS when the negotiated format is not power efficient', () => {
const report = reconcileHardwareEncodeReport(AMD_RDNA3_REPORT, PROBE_SAYS_SOFTWARE, 'macos', PCI_VENDOR_APPLE);
expect(report.h264).toBe('software');
});
it('demotes H.264 to software when the probe could not answer at all', () => {
expect(reconcileHardwareEncodeReport(AMD_RDNA3_REPORT, null, 'windows', PCI_VENDOR_AMD).h264).toBe('software');
});
it('keeps hardware H.264 on Windows when the probe reports a power efficient encoder', () => {
const report = reconcileHardwareEncodeReport(
AMD_RDNA3_REPORT,
{...PROBE_SAYS_SOFTWARE, h264: 'hardware'},
'windows',
PCI_VENDOR_AMD,
);
expect(report.h264).toBe('hardware');
});
it('keeps hardware H.264 on Linux when the probe reports a power efficient encoder', () => {
const report = reconcileHardwareEncodeReport(
NVIDIA_REPORT,
{...PROBE_SAYS_SOFTWARE, h264: 'hardware'},
'linux',
PCI_VENDOR_NVIDIA,
);
expect(report.h264).toBe('hardware');
});
it('leaves the other codecs on the table verdict outside Linux NVIDIA', () => {
const report = reconcileHardwareEncodeReport(AMD_RDNA3_REPORT, PROBE_SAYS_SOFTWARE, 'windows', PCI_VENDOR_AMD);
expect(report.av1).toBe('hardware');
expect(report.h265).toBe('hardware');
});
});
@@ -7,6 +7,14 @@ import {
isDesktop,
type NativePlatform,
} from '@app/features/ui/utils/NativeUtils';
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
import {
getScreenShareBitrateBps,
resolveEffectiveScreenShareDimensions,
resolveStreamingModeSettings,
SCREEN_SHARE_MAX_VIDEO_BITRATE_BPS,
} from '@app/features/voice/utils/ScreenShareOptions';
import {hasHigherVideoQuality} from '@app/features/voice/utils/VideoQualityEntitlement';
import type {GpuDeviceInfo, GpuInfo} from '@app/types/electron.d';
import type {VideoCodec} from 'livekit-client';
@@ -236,19 +244,52 @@ export function reportFromGpuInfo(info: GpuInfo): HardwareEncodeReport {
};
}
export const NEGOTIABLE_H264_PROBE_CONTENT_TYPE =
'video/H264;level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f';
const WEBRTC_ENCODE_PROBE_CONTENT_TYPES: Record<VideoCodec, ReadonlyArray<string>> = {
av1: ['video/AV1'],
h265: ['video/H265'],
h264: [
'video/H264;level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f',
'video/H264;level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f',
'video/H264;level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
'video/H264',
],
h264: [NEGOTIABLE_H264_PROBE_CONTENT_TYPE],
vp9: ['video/VP9'],
vp8: ['video/VP8'],
};
interface EncodeProbeVideoConfig {
width: number;
height: number;
bitrate: number;
framerate: number;
}
const DEFAULT_ENCODE_PROBE_VIDEO_CONFIG: EncodeProbeVideoConfig = {
width: 1920,
height: 1080,
bitrate: SCREEN_SHARE_MAX_VIDEO_BITRATE_BPS,
framerate: 60,
};
function resolveEncodeProbeVideoConfig(): EncodeProbeVideoConfig {
try {
const settings = resolveStreamingModeSettings(
VoiceSettings.getStreamingMode(),
VoiceSettings.getScreenshareResolution(),
VoiceSettings.getVideoFrameRate(),
hasHigherVideoQuality(),
);
const {width, height} = resolveEffectiveScreenShareDimensions(settings.resolution);
return {
width,
height,
bitrate: getScreenShareBitrateBps(settings.resolution, settings.frameRate),
framerate: settings.frameRate,
};
} catch (error) {
logger.debug('Share settings were unavailable, probing at the largest share size instead', {error});
return DEFAULT_ENCODE_PROBE_VIDEO_CONFIG;
}
}
interface WebRtcEncodingInfoResult {
supported?: boolean;
powerEfficient?: boolean;
@@ -261,13 +302,14 @@ interface MediaCapabilitiesLike {
async function probeCodecEncodeEfficiency(
mediaCapabilities: MediaCapabilitiesLike,
contentTypes: ReadonlyArray<string>,
video: EncodeProbeVideoConfig,
): Promise<HardwareEncodeAnswer> {
let sawSupported = false;
for (const contentType of contentTypes) {
try {
const info = await mediaCapabilities.encodingInfo?.({
type: 'webrtc',
video: {contentType, width: 1920, height: 1080, bitrate: 2_500_000, framerate: 30},
video: {contentType, ...video},
});
if (!info?.supported) continue;
sawSupported = true;
@@ -282,8 +324,11 @@ export async function probeWebRtcEncodeEfficiency(): Promise<Record<VideoCodec,
const mediaCapabilities = (navigator as Navigator & {mediaCapabilities?: MediaCapabilitiesLike}).mediaCapabilities;
if (!mediaCapabilities?.encodingInfo) return null;
const codecs: ReadonlyArray<VideoCodec> = ['av1', 'h265', 'h264', 'vp9', 'vp8'];
const video = resolveEncodeProbeVideoConfig();
const answers = await Promise.all(
codecs.map((codec) => probeCodecEncodeEfficiency(mediaCapabilities, WEBRTC_ENCODE_PROBE_CONTENT_TYPES[codec])),
codecs.map((codec) =>
probeCodecEncodeEfficiency(mediaCapabilities, WEBRTC_ENCODE_PROBE_CONTENT_TYPES[codec], video),
),
);
const result = {} as Record<VideoCodec, HardwareEncodeAnswer>;
codecs.forEach((codec, index) => {
@@ -299,19 +344,21 @@ export function reconcileHardwareEncodeReport(
vendorId: number,
): HardwareEncodeReport {
const isNvidiaReport = vendorId === PCI_VENDOR_NVIDIA || report.gpuFamily?.startsWith('nvidia-') === true;
if (platform !== 'linux' || !isNvidiaReport) return report;
const adjust = (codec: VideoCodec): HardwareEncodeAnswer => {
if (report[codec] !== 'hardware') return report[codec];
return efficiency?.[codec] === 'hardware' ? 'hardware' : 'software';
};
return {
...report,
av1: adjust('av1'),
h265: adjust('h265'),
h264: adjust('h264'),
vp9: adjust('vp9'),
vp8: adjust('vp8'),
};
if (platform === 'linux' && isNvidiaReport) {
return {
...report,
av1: adjust('av1'),
h265: adjust('h265'),
h264: adjust('h264'),
vp9: adjust('vp9'),
vp8: adjust('vp8'),
};
}
return {...report, h264: adjust('h264')};
}
let cachedReport: HardwareEncodeReport | null = null;
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
const mediaPermission = vi.hoisted(() => ({
isInitialized: () => true,
isMicrophoneGranted: () => true,
isCameraGranted: () => true,
getMicrophonePermissionState: (): PermissionState => 'granted',
getCameraPermissionState: (): PermissionState => 'granted',
addChangeListener: (listener: () => void): (() => void) => {
listener();
return () => {};
},
}));
vi.mock('@app/features/permissions/system/state/MediaPermission', () => ({default: mediaPermission}));
const makeDevice = (kind: MediaDeviceKind, deviceId: string, label: string): MediaDeviceInfo =>
({
deviceId,
groupId: `${deviceId}-group`,
kind,
label,
toJSON: () => ({deviceId, kind, label}),
}) as MediaDeviceInfo;
const getUserMediaCalls: Array<MediaStreamConstraints> = [];
let browserExposesLabels = false;
const fakeMediaDevices = {
enumerateDevices: async (): Promise<Array<MediaDeviceInfo>> => [
makeDevice('audioinput', 'mic-1', browserExposesLabels ? 'Studio Mic' : ''),
makeDevice('videoinput', 'cam-1', browserExposesLabels ? 'Studio Camera' : ''),
],
getUserMedia: async (constraints: MediaStreamConstraints): Promise<MediaStream> => {
getUserMediaCalls.push(constraints);
return {getTracks: () => []} as unknown as MediaStream;
},
addEventListener: () => {},
removeEventListener: () => {},
};
let originalNavigator: PropertyDescriptor | undefined;
let originalWindow: PropertyDescriptor | undefined;
beforeEach(() => {
getUserMediaCalls.length = 0;
browserExposesLabels = false;
originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: {userAgent: 'node', mediaDevices: fakeMediaDevices},
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {matchMedia: () => ({matches: false})},
});
vi.resetModules();
});
afterEach(() => {
if (originalNavigator) {
Object.defineProperty(globalThis, 'navigator', originalNavigator);
} else {
Reflect.deleteProperty(globalThis, 'navigator');
}
if (originalWindow) {
Object.defineProperty(globalThis, 'window', originalWindow);
} else {
Reflect.deleteProperty(globalThis, 'window');
}
});
const runStartupPreload = async () => {
const {startMediaDeviceStartupPreload} = await import('@app/features/voice/utils/MediaDeviceStartupPreload');
const {default: VoiceDevicePermissionState} = await import('@app/features/voice/engine/VoiceDevicePermissionState');
const stopPreload = startMediaDeviceStartupPreload();
const state = await VoiceDevicePermissionState.ensureDevices();
stopPreload();
return state;
};
describe('startMediaDeviceStartupPreload', () => {
test('never opens capture for a granted microphone and camera when the browser hides device labels', async () => {
const state = await runStartupPreload();
expect(getUserMediaCalls).toEqual([]);
expect(state.permissionStatus).toEqual({audio: 'idle', video: 'idle'});
});
test('confirms a granted microphone and camera from exposed device labels without opening capture', async () => {
browserExposesLabels = true;
const state = await runStartupPreload();
expect(getUserMediaCalls).toEqual([]);
expect(state.permissionStatus).toEqual({audio: 'granted', video: 'granted'});
});
});
@@ -12,9 +12,9 @@ export function startMediaDeviceStartupPreload(): () => void {
let lastPermissionStateKey: string | null = null;
const preloadDevices = () => {
if (stopped) return;
const requestPermissionTypes: Array<VoiceMediaPermissionType> = [];
if (MediaPermission.isMicrophoneGranted()) requestPermissionTypes.push('audio');
if (MediaPermission.isCameraGranted()) requestPermissionTypes.push('video');
const grantedPermissionTypes: Array<VoiceMediaPermissionType> = [];
if (MediaPermission.isMicrophoneGranted()) grantedPermissionTypes.push('audio');
if (MediaPermission.isCameraGranted()) grantedPermissionTypes.push('video');
const permissionStateKey = [
MediaPermission.isInitialized() ? 'initialized' : 'pending',
MediaPermission.getMicrophonePermissionState() ?? 'unknown',
@@ -22,16 +22,18 @@ export function startMediaDeviceStartupPreload(): () => void {
].join(':');
const deviceState = VoiceDevicePermissionState.getState();
const forceRefresh = lastPermissionStateKey !== null && lastPermissionStateKey !== permissionStateKey;
const requestedPermissionStatesSettled = requestPermissionTypes.every(
const grantedPermissionStatesSettled = grantedPermissionTypes.every(
(type) => deviceState.permissionStatus[type] !== 'idle',
);
if (!forceRefresh && lastPermissionStateKey === permissionStateKey && requestedPermissionStatesSettled) {
if (!forceRefresh && lastPermissionStateKey === permissionStateKey && grantedPermissionStatesSettled) {
return;
}
lastPermissionStateKey = permissionStateKey;
void VoiceDevicePermissionState.ensureDevices({forceRefresh, requestPermissionTypes}).catch((error) => {
logger.debug('Failed to preload media devices', {error});
});
void VoiceDevicePermissionState.ensureDevices({forceRefresh, confirmPermissionTypes: grantedPermissionTypes}).catch(
(error) => {
logger.debug('Failed to preload media devices', {error});
},
);
};
const disposePermissionListener = MediaPermission.addChangeListener(preloadDevices);
return () => {
@@ -1,12 +1,138 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getNativeAudioCaptureDiagnosticState} from '@app/features/voice/utils/NativeAudioCaptureBridge';
import type {VoiceEngineV2AppSourceLifecycleBridge} from '@app/features/voice/engine/v2/VoiceEngineV2AppSourceLifecycleBridge';
import {
armNativeSystemAudioForNextCapture,
captureNativeAudioTrackForLinuxRouting,
getNativeAudioCaptureDiagnosticState,
resetNativeAudioCaptureBridgeForTests,
setNativeAudioCaptureBridgeLifecycleBridge,
} from '@app/features/voice/utils/NativeAudioCaptureBridge';
import {recordBridgeFrame, startBridgeStats} from '@app/features/voice/utils/native_audio_capture_bridge/bridgeStats';
import type {
NativeAudioBridgeEndedCapture,
NativeAudioBridgeStats,
} from '@app/features/voice/utils/native_audio_capture_bridge/shared';
import {describe, expect, it} from 'vitest';
import {beforeEach, describe, expect, it, vi} from 'vitest';
const electronApi = {current: null as Record<string, unknown> | null};
vi.mock('@app/features/ui/utils/NativeUtils', () => ({
getElectronAPI: () => electronApi.current,
}));
vi.mock('@app/features/voice/utils/SelfWindowScreenShareAudioMix', () => ({
mixTrackWithSelfWindowScreenShareAudio: async (primaryTrack: MediaStreamTrack) => ({
track: primaryTrack,
cleanup: async () => undefined,
}),
}));
const CAPTURE_ID = 'native-audio:stop-cause';
class FakeMediaStreamTrack extends EventTarget {
readyState: 'live' | 'ended' = 'live';
constructor(readonly kind: 'audio' | 'video') {
super();
}
stop(): void {
this.readyState = 'ended';
}
}
class FakeGeneratorTrack extends FakeMediaStreamTrack {
readonly writable = new WritableStream();
constructor(_options: {kind: 'audio'}) {
super('audio');
}
}
class FakeMediaStream {
private readonly tracks: Array<FakeMediaStreamTrack>;
constructor(tracks: Array<FakeMediaStreamTrack> = []) {
this.tracks = [...tracks];
}
getTracks(): Array<FakeMediaStreamTrack> {
return [...this.tracks];
}
getVideoTracks(): Array<FakeMediaStreamTrack> {
return this.tracks.filter((track) => track.kind === 'video');
}
getAudioTracks(): Array<FakeMediaStreamTrack> {
return this.tracks.filter((track) => track.kind === 'audio');
}
addTrack(track: FakeMediaStreamTrack): void {
this.tracks.push(track);
}
removeTrack(track: FakeMediaStreamTrack): void {
const index = this.tracks.indexOf(track);
if (index >= 0) this.tracks.splice(index, 1);
}
}
const stopNativeCapture = vi.fn(async () => undefined);
const reportLifecycle = vi.fn(() => true);
function createNativeAudioApi() {
return {
getAvailability: async () => ({
available: true,
backend: 'windows-wasapi-loopback',
capabilities: {process: true, system: true, systemExcludesSelf: true},
}),
start: async () => ({captureId: CAPTURE_ID, sampleRate: 48000, channels: 2}),
stop: stopNativeCapture,
onFrame: () => () => undefined,
onEnd: () => () => undefined,
};
}
function installLifecycleBridge(): void {
setNativeAudioCaptureBridgeLifecycleBridge({
bind: () => true,
unbind: () => undefined,
reportLifecycle,
} as unknown as VoiceEngineV2AppSourceLifecycleBridge);
}
async function flushCleanup(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
}
async function startDisplayShareWithNativeAudio(): Promise<{
videoTrack: FakeMediaStreamTrack;
audioTrack: FakeMediaStreamTrack;
}> {
const videoTrack = new FakeMediaStreamTrack('video');
const capturedStream = new FakeMediaStream([videoTrack]);
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: {mediaDevices: {getDisplayMedia: async () => capturedStream}},
});
expect(await armNativeSystemAudioForNextCapture()).toBe(true);
const stream = (await navigator.mediaDevices.getDisplayMedia({video: true})) as unknown as FakeMediaStream;
const audioTrack = stream.getAudioTracks()[0];
expect(audioTrack).toBeInstanceOf(FakeGeneratorTrack);
return {videoTrack, audioTrack};
}
function currentBridgeStats(): NativeAudioBridgeStats {
return getNativeAudioCaptureDiagnosticState().bridgeStats as NativeAudioBridgeStats;
}
function currentLifecycleFaults(): Array<{message: string}> {
return getNativeAudioCaptureDiagnosticState().lifecycleFaults as Array<{message: string}>;
}
describe('getNativeAudioCaptureDiagnosticState', () => {
it('keeps the six fields real dumps already carry and appends the retained histories', () => {
@@ -41,3 +167,93 @@ describe('getNativeAudioCaptureDiagnosticState', () => {
expect(Array.isArray(getNativeAudioCaptureDiagnosticState().lifecycleFaults)).toBe(true);
});
});
describe('native audio capture teardown cause', () => {
beforeEach(() => {
resetNativeAudioCaptureBridgeForTests();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {MediaStreamTrackGenerator: FakeGeneratorTrack, AudioData: class {}},
});
Object.defineProperty(globalThis, 'MediaStream', {configurable: true, value: FakeMediaStream});
electronApi.current = {platform: 'win32', nativeAudio: createNativeAudioApi()};
stopNativeCapture.mockClear();
reportLifecycle.mockClear();
installLifecycleBridge();
});
it('records caller-stopped with no fault when the share is stopped by hand', async () => {
const {audioTrack} = await startDisplayShareWithNativeAudio();
audioTrack.stop();
await flushCleanup();
expect(currentLifecycleFaults()).toEqual([]);
expect(reportLifecycle).not.toHaveBeenCalled();
expect(currentBridgeStats().endDetail).toBe('caller-stopped');
expect(stopNativeCapture).toHaveBeenCalledTimes(1);
});
it('names the audio track when the capture track ends on its own', async () => {
const {audioTrack} = await startDisplayShareWithNativeAudio();
audioTrack.dispatchEvent(new Event('ended'));
await flushCleanup();
expect(currentLifecycleFaults().map((fault) => fault.message)).toEqual(['native-audio-tap-audio-track-ended']);
expect(reportLifecycle).toHaveBeenCalledWith({
captureId: CAPTURE_ID,
kind: 'error',
message: 'native-audio-tap-audio-track-ended',
});
expect(currentBridgeStats().endDetail).toBe('audio-track-ended');
});
it('names the video track when the shared screen stops sending frames', async () => {
const {videoTrack} = await startDisplayShareWithNativeAudio();
videoTrack.dispatchEvent(new Event('ended'));
await flushCleanup();
expect(currentLifecycleFaults().map((fault) => fault.message)).toEqual(['native-audio-tap-video-track-ended']);
expect(currentBridgeStats().endDetail).toBe('video-track-ended');
});
it('tears down once and keeps the track-ended cause when a stop follows it', async () => {
const {audioTrack} = await startDisplayShareWithNativeAudio();
audioTrack.dispatchEvent(new Event('ended'));
audioTrack.stop();
await flushCleanup();
expect(currentLifecycleFaults()).toHaveLength(1);
expect(currentBridgeStats().endDetail).toBe('audio-track-ended');
expect(stopNativeCapture).toHaveBeenCalledTimes(1);
});
it('names the audio track when a capture mixed with self-window audio ends on its own', async () => {
electronApi.current = {platform: 'linux', nativeAudio: createNativeAudioApi()};
const track = (await captureNativeAudioTrackForLinuxRouting(
{include: []},
{includeSelfWindowAudio: true},
)) as unknown as FakeMediaStreamTrack | null;
expect(track).toBeInstanceOf(FakeGeneratorTrack);
track?.dispatchEvent(new Event('ended'));
await flushCleanup();
expect(currentBridgeStats().endDetail).toBe('audio-track-ended');
});
it('tears down once and keeps caller-stopped when the track ends after the stop', async () => {
const {audioTrack} = await startDisplayShareWithNativeAudio();
audioTrack.stop();
audioTrack.dispatchEvent(new Event('ended'));
await flushCleanup();
expect(currentLifecycleFaults()).toEqual([]);
expect(currentBridgeStats().endDetail).toBe('caller-stopped');
expect(stopNativeCapture).toHaveBeenCalledTimes(1);
});
});
@@ -113,15 +113,15 @@ function recordLifecycleFault(captureId: string, message: string): void {
}
}
function unbindNativeAudioCaptureLifecycle(captureId: string, faulted: boolean): void {
function unbindNativeAudioCaptureLifecycle(captureId: string, fault: string | null): void {
if (!lifecycleBoundCaptureIds.has(captureId)) return;
const bridge = sourceLifecycleBridge;
if (faulted) {
recordLifecycleFault(captureId, 'native-audio-tap-track-ended');
if (fault) {
recordLifecycleFault(captureId, fault);
}
if (bridge) {
if (faulted) {
bridge.reportLifecycle({captureId, kind: 'error', message: 'native-audio-tap-track-ended'});
if (fault) {
bridge.reportLifecycle({captureId, kind: 'error', message: fault});
}
bridge.unbind(captureId);
}
@@ -195,8 +195,13 @@ export function getNativeAudioCaptureDiagnosticState(): Record<string, unknown>
};
}
function cleanupBridgeAsync(bridge: ActiveNativeAudioBridge, stopRemote: boolean, logContext: string): void {
void bridge.cleanup(stopRemote).catch((error) => {
function cleanupBridgeAsync(
bridge: ActiveNativeAudioBridge,
stopRemote: boolean,
logContext: string,
endDetail?: string,
): void {
void bridge.cleanup(stopRemote, endDetail).catch((error) => {
logger.warn(`Failed to clean up native audio bridge after ${logContext}`, {
captureId: bridge.captureId,
error,
@@ -204,18 +209,23 @@ function cleanupBridgeAsync(bridge: ActiveNativeAudioBridge, stopRemote: boolean
});
}
function cleanupManagedBridgeById(captureId: string, stopRemote: boolean, logContext: string): boolean {
function cleanupManagedBridgeById(
captureId: string,
stopRemote: boolean,
logContext: string,
endDetail?: string,
): boolean {
if (activeBridge?.captureId === captureId) {
const bridge = activeBridge;
activeBridge = supersededBridge;
supersededBridge = null;
cleanupBridgeAsync(bridge, stopRemote, logContext);
cleanupBridgeAsync(bridge, stopRemote, logContext, endDetail);
return true;
}
if (supersededBridge?.captureId === captureId) {
const bridge = supersededBridge;
supersededBridge = null;
cleanupBridgeAsync(bridge, stopRemote, logContext);
cleanupBridgeAsync(bridge, stopRemote, logContext, endDetail);
return true;
}
return false;
@@ -242,12 +252,13 @@ function attachNativeAudioCleanup(
captureId: string,
handle: NativeAudioBridgeHandle,
): ActiveNativeAudioBridge['cleanup'] {
const tracks = [...stream.getVideoTracks(), handle.track];
const videoTracks = stream.getVideoTracks();
const tracks = [...videoTracks, handle.track];
const cleanupListeners: Array<() => void> = [];
const restoreStops: Array<() => void> = [];
let cleanedUp = false;
bindNativeAudioCaptureLifecycle(captureId);
const cleanup = async (stopRemote: boolean = true): Promise<void> => {
const cleanup = async (stopRemote: boolean = true, endDetail?: string): Promise<void> => {
if (cleanedUp) return;
cleanedUp = true;
for (const removeListener of cleanupListeners.splice(0)) {
@@ -259,20 +270,22 @@ function attachNativeAudioCleanup(
if (activeBridge?.captureId === captureId) {
activeBridge = null;
}
unbindNativeAudioCaptureLifecycle(captureId, false);
await handle.cleanup(stopRemote);
unbindNativeAudioCaptureLifecycle(captureId, null);
await handle.cleanup(stopRemote, endDetail);
};
const requestCleanup = (): void => {
unbindNativeAudioCaptureLifecycle(captureId, true);
if (!cleanupManagedBridgeById(captureId, true, 'track ended')) {
cleanupBridgeAsync({captureId, cleanup}, true, 'track ended');
const requestCleanup = (fault: string | null, endDetail: string, logContext: string): void => {
unbindNativeAudioCaptureLifecycle(captureId, fault);
if (!cleanupManagedBridgeById(captureId, true, logContext, endDetail)) {
cleanupBridgeAsync({captureId, cleanup}, true, logContext, endDetail);
}
};
for (const track of tracks) {
const onEnded = (): void => requestCleanup();
const trackKind = track === handle.track ? 'audio' : 'video';
const onEnded = (): void =>
requestCleanup(`native-audio-tap-${trackKind}-track-ended`, `${trackKind}-track-ended`, 'track ended');
track.addEventListener('ended', onEnded);
cleanupListeners.push(() => track.removeEventListener('ended', onEnded));
restoreStops.push(patchTrackStopForCleanup(track, requestCleanup));
restoreStops.push(patchTrackStopForCleanup(track, () => requestCleanup(null, 'caller-stopped', 'caller stop')));
}
return cleanup;
}
@@ -303,11 +316,11 @@ async function createNativeAudioBridgeWithSelfWindowAudio(captureId: string): Pr
throw error;
}
let cleanedUp = false;
const cleanup = async (stopRemote: boolean = true): Promise<void> => {
const cleanup = async (stopRemote: boolean = true, endDetail?: string): Promise<void> => {
if (cleanedUp) return;
cleanedUp = true;
await mixedTrack.cleanup();
await nativeHandle.cleanup(stopRemote);
await nativeHandle.cleanup(stopRemote, endDetail);
};
return {
track: mixedTrack.track,
@@ -421,7 +434,7 @@ function createDirectNativeAudioFramePump(
cleanedUp = true;
unsubscribeFrame();
unsubscribeEnd();
unbindNativeAudioCaptureLifecycle(captureId, true);
unbindNativeAudioCaptureLifecycle(captureId, 'native-audio-tap-track-ended');
onEnd?.(message);
});
const cleanup = async (stopRemote: boolean = true): Promise<void> => {
@@ -429,7 +442,7 @@ function createDirectNativeAudioFramePump(
cleanedUp = true;
unsubscribeFrame();
unsubscribeEnd();
unbindNativeAudioCaptureLifecycle(captureId, false);
unbindNativeAudioCaptureLifecycle(captureId, null);
if (stopRemote) {
await nativeAudioApi.stop(captureId).catch((error) => {
logger.warn('Failed to stop native screen-share audio frame pump', {captureId, error});
@@ -2,15 +2,29 @@
import {describe, expect, it} from 'vitest';
import {
confirmDecodeStall,
findSoftwareVideoDecoder,
findStalledVideoDecoder,
isSoftwareVideoImplementation,
type StalledVideoDecoderInfo,
} from './ScreenShareCodecDiagnostics';
function createStatsReport(entries: Array<Record<string, unknown>>): RTCStatsReport {
return new Map(entries.map((entry) => [entry.id as string, entry])) as unknown as RTCStatsReport;
}
function createStallInfo(overrides: Partial<StalledVideoDecoderInfo> = {}): StalledVideoDecoderInfo {
return {
codec: 'h264',
mimeType: 'video/H264',
packetsReceived: 400,
bytesReceived: 250_000,
framesDecoded: 0,
framesReceived: 20,
...overrides,
};
}
describe('isSoftwareVideoImplementation', () => {
it('detects common software encoder and decoder implementations', () => {
expect(isSoftwareVideoImplementation('libvpx')).toBe(true);
@@ -133,4 +147,76 @@ describe('findStalledVideoDecoder', () => {
]);
expect(findStalledVideoDecoder(stats)).toBeNull();
});
it('does not treat a first keyframe that is still arriving as a decoder stall', () => {
const stats = createStatsReport([
{id: 'codec-1', type: 'codec', mimeType: 'video/H264'},
{
id: 'inbound-1',
type: 'inbound-rtp',
kind: 'video',
codecId: 'codec-1',
packetsReceived: 40,
bytesReceived: 60_000,
framesReceived: 0,
framesDecoded: 0,
},
]);
expect(findStalledVideoDecoder(stats)).toBeNull();
});
it('does not treat packets that carry no video frames as a decoder stall', () => {
const stats = createStatsReport([
{id: 'codec-1', type: 'codec', mimeType: 'video/H264'},
{
id: 'inbound-1',
type: 'inbound-rtp',
kind: 'video',
codecId: 'codec-1',
packetsReceived: 50,
bytesReceived: 0,
framesReceived: 0,
framesDecoded: 0,
},
]);
expect(findStalledVideoDecoder(stats)).toBeNull();
});
it('treats whole frames that never decode as a decoder stall', () => {
const stats = createStatsReport([
{id: 'codec-1', type: 'codec', mimeType: 'video/H264'},
{
id: 'inbound-1',
type: 'inbound-rtp',
kind: 'video',
codecId: 'codec-1',
packetsReceived: 40,
bytesReceived: 60_000,
framesReceived: 40,
framesDecoded: 0,
},
]);
expect(findStalledVideoDecoder(stats)).toMatchObject({codec: 'h264', framesReceived: 40, framesDecoded: 0});
});
});
describe('confirmDecodeStall', () => {
it('confirms a stall when more frames arrived and none of them decoded', () => {
const second = createStallInfo({framesReceived: 45});
expect(confirmDecodeStall(createStallInfo({framesReceived: 20}), second)).toBe(second);
});
it('does not confirm a stall when the second sample decoded a frame', () => {
expect(confirmDecodeStall(createStallInfo(), createStallInfo({framesReceived: 45, framesDecoded: 3}))).toBeNull();
});
it('does not confirm a stall when the second sample is missing', () => {
expect(confirmDecodeStall(createStallInfo(), null)).toBeNull();
});
it('does not confirm a stall when no new frames arrived between the samples', () => {
expect(confirmDecodeStall(createStallInfo({framesReceived: 20}), createStallInfo({framesReceived: 20}))).toBeNull();
});
it('does not confirm a stall across two different codecs', () => {
expect(
confirmDecodeStall(
createStallInfo({framesReceived: 20}),
createStallInfo({codec: 'vp9', mimeType: 'video/VP9', framesReceived: 45}),
),
).toBeNull();
});
});
@@ -46,7 +46,7 @@ export interface StalledVideoDecoderInfo {
packetsReceived: number;
bytesReceived: number;
framesDecoded: number;
framesReceived: number | null;
framesReceived: number;
}
function isSoftwareVideoStats(implementation: string | null, powerEfficient: boolean | null): boolean {
@@ -127,11 +127,10 @@ export function findStalledVideoDecoder(stats: RTCStatsReport): StalledVideoDeco
if (getStatsKind(report, codecs) !== 'video') continue;
const framesDecoded = finiteNumber(report.framesDecoded);
if (framesDecoded === null || framesDecoded > 0) continue;
const framesReceived = finiteNumber(report.framesReceived);
if (framesReceived === null || framesReceived < 1) continue;
const packetsReceived = finiteNumber(report.packetsReceived) ?? 0;
const bytesReceived = finiteNumber(report.bytesReceived) ?? 0;
const framesReceived = finiteNumber(report.framesReceived);
const hasVideoPayload = packetsReceived >= 10 || bytesReceived >= 8192 || (framesReceived ?? 0) >= 2;
if (!hasVideoPayload) continue;
const mimeType = report.codecId ? codecs.get(report.codecId)?.mimeType : undefined;
const codec = getVideoCodecFromMimeType(mimeType);
if (!codec) continue;
@@ -147,20 +146,49 @@ export function findStalledVideoDecoder(stats: RTCStatsReport): StalledVideoDeco
return null;
}
export function confirmDecodeStall(
first: StalledVideoDecoderInfo | null,
second: StalledVideoDecoderInfo | null,
): StalledVideoDecoderInfo | null {
if (!first || !second) return null;
if (first.codec !== second.codec || first.mimeType !== second.mimeType) return null;
if (first.framesDecoded !== 0 || second.framesDecoded !== 0) return null;
if (second.framesReceived <= first.framesReceived) return null;
return second;
}
export function scheduleScreenShareDecoderVerification(
getStats: () => Promise<RTCStatsReport | undefined>,
onComplete?: () => void,
onDecodeFailure?: (failure: StalledVideoDecoderInfo) => void,
): NodeJS.Timeout {
return setTimeout(async () => {
): () => void {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const scheduleConfirmation = (first: StalledVideoDecoderInfo): void => {
timer = setTimeout(async () => {
timer = null;
try {
const stats = await getStats();
const confirmed = confirmDecodeStall(first, stats ? findStalledVideoDecoder(stats) : null);
if (cancelled || !confirmed) return;
logger.warn('Screen share video decode is stalled', confirmed);
onDecodeFailure?.(confirmed);
} catch (error) {
logger.debug('Failed to confirm the screen share decode stall', {error});
} finally {
if (!cancelled) {
onComplete?.();
}
}
}, DECODER_VERIFICATION_DELAY_MS);
};
timer = setTimeout(async () => {
timer = null;
let firstStall: StalledVideoDecoderInfo | null = null;
try {
const stats = await getStats();
if (!stats) return;
const stalledDecoder = findStalledVideoDecoder(stats);
if (stalledDecoder) {
logger.warn('Screen share video decode is stalled', stalledDecoder);
onDecodeFailure?.(stalledDecoder);
}
firstStall = findStalledVideoDecoder(stats);
const decoder = findSoftwareVideoDecoder(stats);
if (!decoder) return;
logger.warn('Screen share is using a software decoder', decoder);
@@ -168,7 +196,19 @@ export function scheduleScreenShareDecoderVerification(
} catch (error) {
logger.debug('Failed to verify screen share decoder', {error});
} finally {
onComplete?.();
if (!cancelled) {
if (firstStall) {
scheduleConfirmation(firstStall);
} else {
onComplete?.();
}
}
}
}, DECODER_VERIFICATION_DELAY_MS);
return () => {
cancelled = true;
if (!timer) return;
clearTimeout(timer);
timer = null;
};
}
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource';
import {
getRecentScreenShares,
recordScreenShareEncoderVerification,
recordScreenShareEndedModal,
recordScreenShareRequestedCodec,
recordScreenShareStartError,
recordScreenShareStarted,
recordScreenShareStopped,
resetRecentScreenSharesForTests,
} from '@app/features/voice/utils/ScreenShareLifecycleLog';
import {beforeEach, describe, expect, it} from 'vitest';
describe('ScreenShareLifecycleLog', () => {
beforeEach(() => {
resetRecentScreenSharesForTests();
ActiveScreenShareSource.clear();
});
it('keeps the first trigger that stopped a share instead of the later unpublish', () => {
recordScreenShareStarted();
recordScreenShareStopped('media-track-ended');
recordScreenShareStopped('server-unpublish');
const [entry] = getRecentScreenShares();
expect(entry.stopTrigger).toBe('media-track-ended');
expect(entry.stoppedAt).not.toBeNull();
});
it('tells a user stop apart from a share that stopped on its own', () => {
recordScreenShareStarted();
recordScreenShareStopped('user');
recordScreenShareStarted();
recordScreenShareStopped('gateway-echo');
expect(getRecentScreenShares().map((entry) => entry.stopTrigger)).toEqual(['user', 'gateway-echo']);
});
it('records the codec verification and the modal a stopped share showed', () => {
recordScreenShareStarted();
recordScreenShareRequestedCodec('h264');
recordScreenShareEncoderVerification('correct-negotiated', ['vp9']);
recordScreenShareEndedModal('codec-policy-failed');
recordScreenShareStopped('codec-republish-failed');
const [entry] = getRecentScreenShares();
expect(entry.requestedCodec).toBe('h264');
expect(entry.negotiatedCodecs).toEqual(['vp9']);
expect(entry.encoderVerification).toBe('correct-negotiated');
expect(entry.modalShown).toBe('codec-policy-failed');
expect(entry.stopTrigger).toBe('codec-republish-failed');
});
it('freezes the published source when the share stops', () => {
ActiveScreenShareSource.setPublishedSource('app', 'window:7');
recordScreenShareStarted();
recordScreenShareStopped('user');
ActiveScreenShareSource.clear();
expect(getRecentScreenShares()[0].sourceKind).toBe('app');
});
it('reports the live published source while a share is still running', () => {
recordScreenShareStarted();
ActiveScreenShareSource.setPublishedSource('display', 'screen:1');
expect(getRecentScreenShares()[0].sourceKind).toBe('display');
});
it('records a start failure without marking it as a stop', () => {
recordScreenShareStarted();
recordScreenShareStartError(new Error('portal refused'));
const [entry] = getRecentScreenShares();
expect(entry.startError).toBe('Error: portal refused');
expect(entry.stopTrigger).toBeNull();
expect(entry.stoppedAt).toBeNull();
});
it('leaves a closed share untouched when a later trigger arrives', () => {
recordScreenShareStarted();
recordScreenShareStartError(new Error('portal refused'));
recordScreenShareStopped('user');
expect(getRecentScreenShares()[0].stopTrigger).toBeNull();
});
it('keeps only the eight most recent shares', () => {
for (let index = 0; index < 11; index += 1) {
recordScreenShareStarted();
recordScreenShareStopped('user');
}
expect(getRecentScreenShares()).toHaveLength(8);
});
});
@@ -0,0 +1,109 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import ActiveScreenShareSource, {
type PublishedScreenShareSource,
} from '@app/features/voice/state/ActiveScreenShareSource';
import type {VideoCodec} from 'livekit-client';
const MAX_RETAINED_SCREEN_SHARES = 8;
export type ScreenShareStopTrigger =
| 'user'
| 'media-track-ended'
| 'server-unpublish'
| 'gateway-echo'
| 'codec-republish-failed';
export type ScreenShareEndedModal = 'source-stopped' | 'encoder-failed' | 'codec-policy-failed';
export type ScreenShareEncoderVerification =
| 'recover-stalled'
| 'ignore-repeated-stall'
| 'accept-negotiated'
| 'correct-negotiated';
export interface ScreenShareLifecycleEntry {
startedAt: number;
sourceKind: PublishedScreenShareSource | null;
requestedCodec: VideoCodec | null;
negotiatedCodecs: ReadonlyArray<VideoCodec> | null;
encoderVerification: ScreenShareEncoderVerification | null;
startError: string | null;
stoppedAt: number | null;
stopTrigger: ScreenShareStopTrigger | null;
modalShown: ScreenShareEndedModal | null;
}
let entries: Array<ScreenShareLifecycleEntry> = [];
function getOpenEntry(): ScreenShareLifecycleEntry | null {
const entry = entries.at(-1);
if (!entry || entry.stoppedAt != null || entry.startError != null) return null;
return entry;
}
export function recordScreenShareStarted(): void {
entries.push({
startedAt: Date.now(),
sourceKind: null,
requestedCodec: null,
negotiatedCodecs: null,
encoderVerification: null,
startError: null,
stoppedAt: null,
stopTrigger: null,
modalShown: null,
});
if (entries.length > MAX_RETAINED_SCREEN_SHARES) {
entries = entries.slice(-MAX_RETAINED_SCREEN_SHARES);
}
}
export function recordScreenShareStartError(error: unknown): void {
const entry = getOpenEntry();
if (!entry) return;
entry.sourceKind = ActiveScreenShareSource.getPublishedSource();
entry.startError = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
}
export function recordScreenShareRequestedCodec(codec: VideoCodec): void {
const entry = getOpenEntry();
if (!entry) return;
entry.requestedCodec = codec;
}
export function recordScreenShareEncoderVerification(
verification: ScreenShareEncoderVerification,
negotiatedCodecs: ReadonlyArray<VideoCodec> | null,
): void {
const entry = getOpenEntry();
if (!entry) return;
entry.encoderVerification = verification;
entry.negotiatedCodecs = negotiatedCodecs;
}
export function recordScreenShareEndedModal(modal: ScreenShareEndedModal): void {
const entry = getOpenEntry();
if (!entry) return;
entry.modalShown = modal;
}
export function recordScreenShareStopped(trigger: ScreenShareStopTrigger): void {
const entry = getOpenEntry();
if (!entry) return;
entry.sourceKind = ActiveScreenShareSource.getPublishedSource();
entry.stoppedAt = Date.now();
entry.stopTrigger = trigger;
}
export function getRecentScreenShares(): Array<ScreenShareLifecycleEntry> {
const open = getOpenEntry();
return entries.map((entry) => ({
...entry,
sourceKind: entry === open ? ActiveScreenShareSource.getPublishedSource() : entry.sourceKind,
}));
}
export function resetRecentScreenSharesForTests(): void {
entries = [];
}
@@ -0,0 +1,164 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {StatsForNerdsData} from '@app/features/voice/utils/VoiceStatsForNerdsPresenter';
import {describe, expect, it, vi} from 'vitest';
const failureRecord = {
code: -2303,
reason: 'first-frame-timeout',
reportedAt: 1000,
streamKey: 'guild-a:channel-a:connection-a',
trackSid: 'TR_screen_share',
participantIdentity: 'user_1_connection-a',
inbound: {
packetsReceived: 4210,
bytesReceived: 3_500_000,
framesReceived: 0,
framesDecoded: 0,
keyFramesDecoded: 0,
framesDropped: 0,
pliCount: 7,
firCount: 0,
nackCount: 2,
freezeCount: 0,
decoderImplementation: 'ExternalDecoder (D3D11VideoDecoder)',
powerEfficientDecoder: true,
mimeType: 'video/H264',
sdpFmtpLine: 'profile-level-id=42e01f',
frameWidth: 1920,
frameHeight: 1080,
},
tile: {
hasVideoElement: true,
readyState: 0,
videoWidth: 0,
videoHeight: 0,
clientWidth: 960,
clientHeight: 540,
visibilityState: 'visible',
},
};
const localCodecs = [{name: 'h264', type: 'video', payload_type: 108, priority: 900, encode: true, decode: true}];
const subscriptionDebugReport = {capturedAt: '2026-09-11T00:00:00.000Z', failures: []};
vi.mock('@app/features/app/config/Config', () => ({default: {PUBLIC_BUILD_VERSION: 'test'}}));
vi.mock('@app/features/devtools/utils/DesktopTroubleshootingUtils', () => ({
getCachedDesktopTroubleshootingSettings: () => null,
getDesktopTroubleshootingSettings: async () => ({}),
}));
vi.mock('@app/features/ui/utils/NativeUtils', () => ({
getElectronAPI: () => null,
getNativePlatform: async () => 'web',
isDesktop: () => false,
supportsDesktopScreenShareAudioCapture: () => false,
}));
vi.mock('@app/features/voice/diagnostics/VoiceSubscriptionDebugReport', () => ({
collectVoiceSubscriptionDebugReport: () => subscriptionDebugReport,
}));
vi.mock('@app/features/voice/engine/MediaEngineFacade', () => ({
default: {
room: null,
connected: false,
connecting: false,
guildId: null,
channelId: null,
connectionId: null,
voiceServerEndpoint: null,
voiceStats: null,
perTrackStats: [],
statsTimeSeries: [],
publisherTransport: null,
subscriberTransport: null,
},
}));
vi.mock('@app/features/voice/engine/ScreenShareCodecNegotiation', () => ({
default: {
getSelectedCodec: () => 'h264',
getLocalCodecAdvertisements: () => localCodecs,
getRemoteDecodeCodecsByIdentity: () => ({'user_1_connection-a': ['h264', 'vp8']}),
},
getScreenShareCodecPreferenceOrder: () => ['h264', 'vp8'],
}));
vi.mock('@app/features/voice/engine/voice_screen_share_manager/NativeEngineAudioTrackPump', () => ({
getNativeEngineAudioTrackPumpStats: () => null,
}));
vi.mock('@app/features/voice/engine/voice_screen_share_manager/shared', () => ({
getPublishedScreenShareMaxBitrateBps: () => null,
}));
vi.mock('@app/features/voice/state/ScreenShareWatchFailures', () => ({
ScreenShareWatchFailures: {getFailureHistory: () => [failureRecord]},
}));
vi.mock('@app/features/voice/state/VoiceSettings', () => ({
default: new Proxy({}, {get: () => () => null}),
}));
vi.mock('@app/features/voice/utils/CodecCapabilityDetector', () => ({
getCameraPublishCodecPolicy: () => ({primary: 'h264'}),
getCodecCapabilityReport: () => ({}),
getLiveKitSupportedCodecs: () => ['h264', 'vp8'],
selectOptimalScreenShareCodec: () => 'h264',
}));
vi.mock('@app/features/voice/utils/GpuEncoderCapabilities', () => ({loadGpuEncoderReport: async () => null}));
vi.mock('@app/features/voice/utils/NativeAudioCaptureBridge', () => ({
getNativeAudioBridgeStats: () => null,
getNativeAudioCaptureDiagnosticState: () => null,
}));
vi.mock('@app/features/voice/utils/ScreenShareEnvironment', () => ({getDisplayShareEnvironment: async () => ({})}));
vi.mock('@app/features/voice/utils/ScreenShareOptions', () => ({
getScreenShareBitrateBps: () => 2_500_000,
resolveStreamingModeSettings: () => ({resolution: 1080, frameRate: 60}),
}));
vi.mock('@app/features/voice/utils/VideoDecoderCapabilities', () => ({
getScreenShareDecodeFailures: () => new Set(['vp9']),
}));
vi.mock('@app/features/voice/utils/VideoQualityEntitlement', () => ({hasHigherVideoQuality: () => false}));
vi.mock('@app/features/voice/utils/VoiceStatsForNerdsPresenter', () => ({
buildVoiceStatsForNerdsPresentation: () => ({}),
collectScreenShareAudioPublicationDiagnostics: () => [],
}));
const {buildStatsForNerdsCopyPayload} = await import('@app/features/voice/utils/StatsForNerdsCopy');
describe('buildStatsForNerdsCopyPayload watch failure diagnostics', () => {
it('copies the screen share failure ring with its codes, track sids and inbound counters', async () => {
const payload = await buildStatsForNerdsCopyPayload({} as StatsForNerdsData);
expect(payload.screenShareWatchFailures).toEqual([failureRecord]);
});
it('copies the screen share negotiation block with the selected codec and decode sets', async () => {
const payload = await buildStatsForNerdsCopyPayload({} as StatsForNerdsData);
expect(payload.screenShareNegotiation).toEqual({
selectedCodec: 'h264',
localCodecs,
remoteDecodeCodecsByIdentity: {'user_1_connection-a': ['h264', 'vp8']},
decodeFailures: ['vp9'],
});
});
it('copies the voice subscription debug report that was only reachable from the debug console', async () => {
const payload = await buildStatsForNerdsCopyPayload({} as StatsForNerdsData);
expect(payload.voiceSubscriptionDebug).toEqual(subscriptionDebugReport);
});
});
@@ -11,12 +11,14 @@ import {
isDesktop,
supportsDesktopScreenShareAudioCapture,
} from '@app/features/ui/utils/NativeUtils';
import {collectVoiceSubscriptionDebugReport} from '@app/features/voice/diagnostics/VoiceSubscriptionDebugReport';
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
import ScreenShareCodecNegotiation, {
getScreenShareCodecPreferenceOrder,
} from '@app/features/voice/engine/ScreenShareCodecNegotiation';
import {getNativeEngineAudioTrackPumpStats} from '@app/features/voice/engine/voice_screen_share_manager/NativeEngineAudioTrackPump';
import {getPublishedScreenShareMaxBitrateBps} from '@app/features/voice/engine/voice_screen_share_manager/shared';
import {ScreenShareWatchFailures} from '@app/features/voice/state/ScreenShareWatchFailures';
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
import {
type CodecCapabilityReport,
@@ -31,7 +33,9 @@ import {
getNativeAudioCaptureDiagnosticState,
} from '@app/features/voice/utils/NativeAudioCaptureBridge';
import {getDisplayShareEnvironment} from '@app/features/voice/utils/ScreenShareEnvironment';
import {getRecentScreenShares} from '@app/features/voice/utils/ScreenShareLifecycleLog';
import {getScreenShareBitrateBps, resolveStreamingModeSettings} from '@app/features/voice/utils/ScreenShareOptions';
import {getScreenShareDecodeFailures} from '@app/features/voice/utils/VideoDecoderCapabilities';
import {hasHigherVideoQuality} from '@app/features/voice/utils/VideoQualityEntitlement';
import {
buildVoiceStatsForNerdsPresentation,
@@ -54,6 +58,14 @@ function safeError(error: unknown): Record<string, unknown> {
return {message: String(error)};
}
function safeCollect<T>(collect: () => T): T | {error: Record<string, unknown>} {
try {
return collect();
} catch (error) {
return {error: safeError(error)};
}
}
async function withTimeout<T>(
label: string,
task: Promise<T>,
@@ -584,6 +596,15 @@ export async function buildStatsForNerdsCopyPayload(data: StatsForNerdsData): Pr
mediaDevices,
voiceSettings,
voiceSession: summarizeRoom(),
recentScreenShares: getRecentScreenShares(),
screenShareWatchFailures: safeCollect(() => ScreenShareWatchFailures.getFailureHistory()),
screenShareNegotiation: safeCollect(() => ({
selectedCodec: ScreenShareCodecNegotiation.getSelectedCodec(),
localCodecs: ScreenShareCodecNegotiation.getLocalCodecAdvertisements(),
remoteDecodeCodecsByIdentity: ScreenShareCodecNegotiation.getRemoteDecodeCodecsByIdentity(),
decodeFailures: [...getScreenShareDecodeFailures()],
})),
voiceSubscriptionDebug: safeCollect(() => collectVoiceSubscriptionDebugReport()),
desktop,
};
}
@@ -28,12 +28,16 @@ export function hasDeviceLabels(devices: ReadonlyArray<MediaDeviceInfo>): boolea
export type VoiceMediaPermissionType = 'audio' | 'video';
export type VoiceMediaPermissionStatus = 'idle' | 'loading' | 'granted' | 'denied';
type VoiceMediaPermissionIntent = 'confirm' | 'request';
const MAX_ENUMERATION_CHAIN_PASSES = 3;
const VOICE_MEDIA_PERMISSION_TYPES: ReadonlyArray<VoiceMediaPermissionType> = ['audio', 'video'];
const VOICE_MEDIA_PERMISSION_INTENTS: ReadonlyArray<VoiceMediaPermissionIntent> = ['confirm', 'request'];
const MAX_ENUMERATION_CHAIN_PASSES = 1 + VOICE_MEDIA_PERMISSION_TYPES.length * VOICE_MEDIA_PERMISSION_INTENTS.length;
export interface EnsureVoiceDevicesOptions {
requestPermissions?: boolean;
requestPermissionTypes?: ReadonlyArray<VoiceMediaPermissionType>;
confirmPermissionTypes?: ReadonlyArray<VoiceMediaPermissionType>;
forceRefresh?: boolean;
}
@@ -393,8 +397,8 @@ class VoiceDeviceManager {
};
private listeners = new Set<Listener>();
private enumerationChainPromise: Promise<VoiceDeviceState> | null = null;
private scheduledEnumerationPermissionTypes = new Set<VoiceMediaPermissionType>();
private enumerationChainPermissionTypes = new Set<VoiceMediaPermissionType>();
private scheduledEnumerationPermissionIntents = new Map<VoiceMediaPermissionType, VoiceMediaPermissionIntent>();
private enumerationChainPermissionIntents = new Map<VoiceMediaPermissionType, VoiceMediaPermissionIntent>();
private hasEnumeratedDevices = false;
constructor() {
@@ -416,11 +420,10 @@ class VoiceDeviceManager {
}
public async ensureDevices(options: EnsureVoiceDevicesOptions = {}): Promise<VoiceDeviceState> {
const requestPermissionTypes = this.resolveRequestedPermissionTypes(options);
const requestPermissions = requestPermissionTypes.length > 0;
const permissionIntents = this.resolvePermissionIntents(options);
const forceRefresh = options.forceRefresh ?? false;
logger.debug('ensureDevices called', {
requestPermissionTypes,
permissionIntents: Object.fromEntries(permissionIntents),
forceRefresh,
hasEnumeratingPromise: !!this.enumerationChainPromise,
currentState: {
@@ -428,39 +431,50 @@ class VoiceDeviceManager {
permissionStatus: this.state.permissionStatus,
},
});
if (!forceRefresh && !this.enumerationChainPromise && this.canUseCachedState(requestPermissions)) {
if (!forceRefresh && !this.enumerationChainPromise && this.canUseCachedState(permissionIntents.size > 0)) {
logger.debug('Using cached device state');
return this.state;
}
if (this.enumerationChainPromise) {
this.scheduleMissingPermissionTypes(requestPermissionTypes);
this.scheduleMissingPermissionIntents(permissionIntents);
logger.debug('Joining existing enumeration promise');
return this.enumerationChainPromise;
}
logger.debug('Creating new enumeration promise');
return this.startEnumerationChain(requestPermissionTypes);
return this.startEnumerationChain(permissionIntents);
}
private scheduleMissingPermissionTypes(requestPermissionTypes: ReadonlyArray<VoiceMediaPermissionType>): void {
private scheduleMissingPermissionIntents(
permissionIntents: ReadonlyMap<VoiceMediaPermissionType, VoiceMediaPermissionIntent>,
): void {
for (const [type, intent] of permissionIntents) {
const chainIntent = this.enumerationChainPermissionIntents.get(type);
if (chainIntent === 'request' || chainIntent === intent) continue;
this.enumerationChainPermissionIntents.set(type, intent);
this.scheduledEnumerationPermissionIntents.set(type, intent);
}
}
private resolvePermissionIntents(
options: EnsureVoiceDevicesOptions,
): Map<VoiceMediaPermissionType, VoiceMediaPermissionIntent> {
const permissionIntents = new Map<VoiceMediaPermissionType, VoiceMediaPermissionIntent>();
for (const type of options.confirmPermissionTypes ?? []) {
permissionIntents.set(type, 'confirm');
}
const requestPermissionTypes: ReadonlyArray<VoiceMediaPermissionType> =
options.requestPermissionTypes ?? (options.requestPermissions === true ? VOICE_MEDIA_PERMISSION_TYPES : []);
for (const type of requestPermissionTypes) {
if (this.enumerationChainPermissionTypes.has(type)) continue;
this.enumerationChainPermissionTypes.add(type);
this.scheduledEnumerationPermissionTypes.add(type);
permissionIntents.set(type, 'request');
}
return permissionIntents;
}
private resolveRequestedPermissionTypes(options: EnsureVoiceDevicesOptions): Array<VoiceMediaPermissionType> {
if (options.requestPermissionTypes) {
return [...new Set(options.requestPermissionTypes)];
}
return options.requestPermissions === true ? ['audio', 'video'] : [];
}
private canUseCachedState(requestPermissions: boolean): boolean {
private canUseCachedState(hasPermissionIntents: boolean): boolean {
if (!this.hasEnumeratedDevices) {
return false;
}
return !requestPermissions;
return !hasPermissionIntents;
}
private updatePermissionStatusForTypes(
@@ -475,42 +489,44 @@ class VoiceDeviceManager {
}
private startEnumerationChain(
requestPermissionTypes: ReadonlyArray<VoiceMediaPermissionType>,
permissionIntents: ReadonlyMap<VoiceMediaPermissionType, VoiceMediaPermissionIntent>,
): Promise<VoiceDeviceState> {
this.enumerationChainPermissionTypes = new Set(requestPermissionTypes);
const pendingPromise = this.runEnumerationChain(requestPermissionTypes).finally(() => {
this.enumerationChainPermissionIntents = new Map(permissionIntents);
const pendingPromise = this.runEnumerationChain(permissionIntents).finally(() => {
if (this.enumerationChainPromise !== pendingPromise) return;
logger.debug('Enumeration promise completed');
this.enumerationChainPromise = null;
this.scheduledEnumerationPermissionTypes.clear();
this.enumerationChainPermissionTypes.clear();
this.scheduledEnumerationPermissionIntents.clear();
this.enumerationChainPermissionIntents.clear();
});
this.enumerationChainPromise = pendingPromise;
return pendingPromise;
}
private async runEnumerationChain(
initialPermissionTypes: ReadonlyArray<VoiceMediaPermissionType>,
initialPermissionIntents: ReadonlyMap<VoiceMediaPermissionType, VoiceMediaPermissionIntent>,
): Promise<VoiceDeviceState> {
let requestPermissionTypes = [...initialPermissionTypes];
let permissionIntents = initialPermissionIntents;
let state = this.state;
for (let pass = 0; pass < MAX_ENUMERATION_CHAIN_PASSES; pass += 1) {
state = await this.enumerateDevices(requestPermissionTypes);
if (this.scheduledEnumerationPermissionTypes.size === 0) return state;
requestPermissionTypes = [...this.scheduledEnumerationPermissionTypes];
this.scheduledEnumerationPermissionTypes.clear();
state = await this.enumerateDevices(permissionIntents);
if (this.scheduledEnumerationPermissionIntents.size === 0) return state;
permissionIntents = new Map(this.scheduledEnumerationPermissionIntents);
this.scheduledEnumerationPermissionIntents.clear();
}
if (this.scheduledEnumerationPermissionTypes.size > 0) {
if (this.scheduledEnumerationPermissionIntents.size > 0) {
throw new Error(`Voice device enumeration exceeded ${MAX_ENUMERATION_CHAIN_PASSES} bounded passes`);
}
return state;
}
private async enumerateDevices(
requestPermissionTypes: ReadonlyArray<VoiceMediaPermissionType>,
permissionIntents: ReadonlyMap<VoiceMediaPermissionType, VoiceMediaPermissionIntent>,
): Promise<VoiceDeviceState> {
const permissionTypes = [...permissionIntents.keys()];
const requestPermissionTypes = permissionTypes.filter((type) => permissionIntents.get(type) === 'request');
const requestPermissions = requestPermissionTypes.length > 0;
logger.debug('enumerateDevices started', {requestPermissionTypes});
logger.debug('enumerateDevices started', {permissionIntents: Object.fromEntries(permissionIntents)});
if (!navigator.mediaDevices?.enumerateDevices) {
logger.debug('Navigator or mediaDevices API not available');
return this.state;
@@ -535,20 +551,20 @@ class VoiceDeviceManager {
hasLabel: !!d.label,
})),
});
const permissionTypesWithLabels = requestPermissionTypes.filter((type) => {
const permissionTypesWithLabels = permissionTypes.filter((type) => {
const requiredKind = type === 'audio' ? 'audioinput' : 'videoinput';
return devices.some((device) => device.kind === requiredKind && device.label !== '');
});
for (const type of permissionTypesWithLabels) {
permissionStatus[type] = 'granted';
}
if (requestPermissions && permissionTypesWithLabels.length === requestPermissionTypes.length) {
const unresolvedRequestTypes = requestPermissionTypes.filter((type) => permissionStatus[type] !== 'granted');
if (requestPermissions && unresolvedRequestTypes.length === 0) {
logger.debug('Devices have labels, permissions already granted');
} else if (requestPermissions && isDesktop()) {
} else if (unresolvedRequestTypes.length > 0 && isDesktop()) {
logger.debug('No labels detected; attempting native permission flow');
const unresolvedNativeTypes = requestPermissionTypes.filter((type) => permissionStatus[type] !== 'granted');
const nativeResults = await Promise.all(
unresolvedNativeTypes.map(async (type) => {
unresolvedRequestTypes.map(async (type) => {
try {
return {
type,

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