mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 04:02:41 +09:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c541b86c00 | ||
|
|
53b3fa2f4a | ||
|
|
67e01be34a | ||
|
|
81fd8c9aad | ||
|
|
bcef7b3123 | ||
|
|
a98d8ef679 | ||
|
|
a5af857564 | ||
|
|
2830221949 | ||
|
|
84aa8880f5 | ||
|
|
395ec1d60f | ||
|
|
e6ee3b8059 | ||
|
|
61a13e1c1a | ||
|
|
fc0e2628a4 | ||
|
|
87fdfd9c34 | ||
|
|
88a5ff9c45 | ||
|
|
320949a79d | ||
|
|
3a862f1484 | ||
|
|
5da256df12 | ||
|
|
baf2cbf3fd | ||
|
|
74782dc4f2 | ||
|
|
7d8778495f | ||
|
|
53399ffb44 | ||
|
|
35d73eae76 | ||
|
|
54128e049a | ||
|
|
7d8d0ff804 | ||
|
|
2e8f381efc | ||
|
|
b29da84282 | ||
|
|
2988c846c8 | ||
|
|
8e91c1412b | ||
|
|
5b2099c777 | ||
|
|
f97841a58f |
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
+94
-14
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+74
-24
@@ -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)();
|
||||
}
|
||||
}
|
||||
|
||||
+81
@@ -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');
|
||||
});
|
||||
});
|
||||
+2
@@ -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, {
|
||||
|
||||
+39
-4
@@ -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
Reference in New Issue
Block a user