Compare commits

...
Author SHA1 Message Date
HampusandGitHub b57545b1a4 refactor(api): replace stripe mock currency ternary chains (#2688) 2026-09-11 16:02:09 +02:00
HampusandGitHub e490be2f35 feat(app): prompt to delete when clearing a message edit (#2687) 2026-09-11 15:56:05 +02:00
fluxer-ci[bot]andGitHub ab07fd23cf chore(i18n): update public marketing catalogs (#2686) 2026-09-11 15:50:16 +02:00
fluxer-ci[bot]andGitHub c1fd2234b8 chore(marketing): advance pointer 7867cf8 → 23cd1c9 (#2685) 2026-09-11 15:50:05 +02:00
HampusandGitHub 3af43b3366 feat(api): add SEK, DKK and NOK as localized currencies (#2684) 2026-09-11 15:48:56 +02:00
HampusandGitHub 0e470f532e test(voice): rename the watch failure deadline test file (#2683) 2026-09-11 15:18:08 +02:00
HampusandGitHub c541b86c00 fix(voice): show buffering while screen share recovery runs (#2682) 2026-09-11 15:03:21 +02:00
HampusandGitHub 53b3fa2f4a fix(voice): key watch attempts by published track (#2681) 2026-09-11 15:01:14 +02:00
HampusandGitHub 67e01be34a fix(voice): judge H.264 hardware support by negotiated format (#2680) 2026-09-11 14:59:04 +02:00
HampusandGitHub 81fd8c9aad fix(gateway): skip empty dm partner registration casts (#2677) 2026-09-11 13:49:03 +02:00
HampusandGitHub bcef7b3123 feat(app): edit blockquote lines in the composer (#2676) 2026-09-11 13:27:50 +02:00
HampusandGitHub a98d8ef679 fix(app): wrap multiline selections in code blocks (#2675) 2026-09-11 13:22:03 +02:00
HampusandGitHub a5af857564 fix(app): insert a newline on Enter inside code blocks (#2674) 2026-09-11 13:20:26 +02:00
HampusandGitHub 2830221949 fix(desktop): download the version a linux update prompt names (#2673) 2026-09-11 13:19:25 +02:00
HampusandGitHub 84aa8880f5 fix(app): format typed @everyone and @here in the composer (#2672) 2026-09-11 13:18:48 +02:00
HampusandGitHub 395ec1d60f fix(ci): publish desktop update feeds only after the release (#2671) 2026-09-11 13:18:15 +02:00
HampusandGitHub e6ee3b8059 fix(api): only offer desktop builds whose release is published (#2670) 2026-09-11 13:17:41 +02:00
HampusandGitHub 61a13e1c1a fix(app): download the version a linux update prompt names (#2669) 2026-09-11 13:16:27 +02:00
HampusandGitHub fc0e2628a4 fix(app): honour @silent in the message composer (#2668) 2026-09-11 13:16:13 +02:00
HampusandGitHub 87fdfd9c34 fix(app): show DMs opened by an incoming message as unread (#2667) 2026-09-11 13:14:06 +02:00
HampusandGitHub 88a5ff9c45 feat(voice): record watch failures and decode counters (#2666) 2026-09-11 13:05:34 +02:00
HampusandGitHub 320949a79d fix(gateway): drop dead clauses in dm partner visibility (#2665) 2026-09-11 13:00:23 +02:00
HampusandGitHub 3a862f1484 fix(voice): record why a screen share stopped (#2664) 2026-09-11 12:59:51 +02:00
HampusandGitHub 5da256df12 fix(voice): poll the current video element for a first frame (#2663) 2026-09-11 12:57:52 +02:00
HampusandGitHub baf2cbf3fd fix(voice): rebind codec negotiation after a region hot swap (#2662) 2026-09-11 12:55:50 +02:00
HampusandGitHub 74782dc4f2 fix(voice): confirm a decode stall before withdrawing a codec (#2661) 2026-09-11 12:53:29 +02:00
HampusandGitHub 7d8778495f chore(desktop): drop Chromium switches that no longer exist (#2660) 2026-09-11 12:50:54 +02:00
HampusandGitHub 53399ffb44 fix(gateway): track dm partner presence in mutual guilds (#2658) 2026-09-11 04:23:20 +02:00
HampusandGitHub 35d73eae76 fix(voice): stop asking for camera and mic access on page load (#2657) 2026-09-11 02:00:48 +02:00
HampusandGitHub 54128e049a test(api): restore the stripe webhook secret after mocking it (#2656) 2026-09-11 01:36:26 +02:00
HampusandGitHub 7d8d0ff804 fix(ci): retry release publish after transient GitHub failures (#2655) 2026-09-11 01:35:24 +02:00
HampusandGitHub 2e8f381efc fix(gateway): keep ets tids opaque in the permission cache (#2654) 2026-09-11 01:31:58 +02:00
HampusandGitHub b29da84282 perf(gateway): trim large guild connect snapshots by default (#2653) 2026-09-11 01:03:22 +02:00
HampusandGitHub 2988c846c8 perf(gateway): read cached members from the guild member table (#2652) 2026-09-11 00:58:17 +02:00
fluxer-ci[bot]andGitHub 8e91c1412b chore(marketing): advance pointer 5908507 → 7867cf8 (#2650) 2026-09-11 00:51:33 +02:00
fluxer-ci[bot]andGitHub 5b2099c777 chore(i18n): update public marketing catalogs (#2651) 2026-09-11 00:51:25 +02:00
HampusandGitHub f97841a58f fix(installer): resolve the compose file name Compose loads (#2649) 2026-09-11 00:38:09 +02:00
HampusandGitHub 0421c86039 fix(api): price gifts in the base currency everywhere (#2648) 2026-09-11 00:28:14 +02:00
HampusandGitHub d17f320bd7 fix(app): tidy the Plutonium billing and pricing layout (#2647) 2026-09-10 23:06:18 +02:00
HampusandGitHub f708586c59 feat(api)!: always use localized pricing where it is offered (#2646) 2026-09-10 21:22:32 +02:00
252 changed files with 23223 additions and 17184 deletions
+8
View File
@@ -525,6 +525,7 @@ jobs:
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
PUBLIC_DL_BASE: https://api.fluxer.app/dl
@@ -602,7 +603,9 @@ jobs:
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
@@ -660,3 +663,8 @@ jobs:
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_release_marker
- name: Publish payload metadata to S3
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_payload_metadata
+12
View File
@@ -372,17 +372,29 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
monthlyUsd: master.integrations.stripe.prices.monthly_usd,
monthlyEur: master.integrations.stripe.prices.monthly_eur,
monthlyBrl: master.integrations.stripe.prices.monthly_brl,
monthlyDkk: master.integrations.stripe.prices.monthly_dkk,
monthlyInr: master.integrations.stripe.prices.monthly_inr,
monthlyNok: master.integrations.stripe.prices.monthly_nok,
monthlyPln: master.integrations.stripe.prices.monthly_pln,
monthlySek: master.integrations.stripe.prices.monthly_sek,
monthlyTry: master.integrations.stripe.prices.monthly_try,
yearlyUsd: master.integrations.stripe.prices.yearly_usd,
yearlyEur: master.integrations.stripe.prices.yearly_eur,
yearlyBrl: master.integrations.stripe.prices.yearly_brl,
yearlyDkk: master.integrations.stripe.prices.yearly_dkk,
yearlyInr: master.integrations.stripe.prices.yearly_inr,
yearlyNok: master.integrations.stripe.prices.yearly_nok,
yearlyPln: master.integrations.stripe.prices.yearly_pln,
yearlySek: master.integrations.stripe.prices.yearly_sek,
yearlyTry: master.integrations.stripe.prices.yearly_try,
gift1MonthUsd: master.integrations.stripe.prices.gift_1_month_usd,
gift1MonthEur: master.integrations.stripe.prices.gift_1_month_eur,
gift1MonthSek: master.integrations.stripe.prices.gift_1_month_sek,
gift1YearSek: master.integrations.stripe.prices.gift_1_year_sek,
gift1MonthDkk: master.integrations.stripe.prices.gift_1_month_dkk,
gift1YearDkk: master.integrations.stripe.prices.gift_1_year_dkk,
gift1MonthNok: master.integrations.stripe.prices.gift_1_month_nok,
gift1YearNok: master.integrations.stripe.prices.gift_1_year_nok,
gift1MonthBrl: master.integrations.stripe.prices.gift_1_month_brl,
gift1MonthInr: master.integrations.stripe.prices.gift_1_month_inr,
gift1MonthPln: master.integrations.stripe.prices.gift_1_month_pln,
+12
View File
@@ -226,17 +226,29 @@ export interface APIConfig {
monthlyUsd?: string;
monthlyEur?: string;
monthlyBrl?: string;
monthlyDkk?: string;
monthlyInr?: string;
monthlyNok?: string;
monthlyPln?: string;
monthlySek?: string;
monthlyTry?: string;
yearlyUsd?: string;
yearlyEur?: string;
yearlyBrl?: string;
yearlyDkk?: string;
yearlyInr?: string;
yearlyNok?: string;
yearlyPln?: string;
yearlySek?: string;
yearlyTry?: string;
gift1MonthUsd?: string;
gift1MonthEur?: string;
gift1MonthSek?: string;
gift1YearSek?: string;
gift1MonthDkk?: string;
gift1YearDkk?: string;
gift1MonthNok?: string;
gift1YearNok?: string;
gift1MonthBrl?: string;
gift1MonthInr?: string;
gift1MonthPln?: string;
+302 -239
View File
@@ -17,7 +17,7 @@ import {
StorageObjectRangeNotSatisfiableError,
} from '../infrastructure/IStorageService';
import {Logger} from '../Logger';
import {isJsonRecord, parseJsonRecord, parseJsonUnknown} from '../utils/JsonBoundaryUtils';
import {isJsonRecord, parseJsonRecord} from '../utils/JsonBoundaryUtils';
import {
parseDesktopArtifactScope,
parseDesktopReleaseDescriptor,
@@ -58,6 +58,7 @@ function isUnsatisfiableRangeError(error: unknown): boolean {
);
}
const MAX_DESKTOP_OBJECTS_PER_PREFIX = 10_000;
const MAX_DESKTOP_RELEASE_CANDIDATES = 10;
const DESKTOP_BUCKET_PREFIX = 'desktop';
const DESKTOP_TEST_BUCKET_PREFIX = 'desktop-test';
const DOWNLOAD_KEY_ALLOWED_PREFIXES = [`${DESKTOP_BUCKET_PREFIX}/`, `${DESKTOP_TEST_BUCKET_PREFIX}/`];
@@ -198,6 +199,14 @@ export type GitHubDesktopReleaseResolution =
| {kind: 'awaiting_release'}
| {kind: 'ready'; location: string};
type DesktopReleaseState = {kind: 'untracked'} | {kind: 'unpublished'} | {kind: 'published'; descriptorText: string};
type ListedDesktopVersion = {
version: string;
pub_date: Date;
files: Map<DesktopFormat, {filename: string; sha256Key: string | null}>;
};
export class DownloadService {
constructor(private readonly storageService: IStorageService) {}
@@ -216,41 +225,28 @@ export class DownloadService {
) {
return {kind: 'not_current'};
}
const descriptorKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.json`;
const descriptorText = await this.readOptionalTextFromStorage(descriptorKey);
if (descriptorText == null) {
const release = await this.readDesktopReleaseState(scope.channel, manifest.version);
if (release.kind === 'untracked') {
return {kind: 'not_current'};
}
const descriptor = parseDesktopReleaseDescriptor(parseJsonUnknown(descriptorText));
if (release.kind === 'unpublished') {
return {kind: 'awaiting_release'};
}
const descriptor = parseDesktopReleaseDescriptor(parseJsonRecord(release.descriptorText));
if (
!descriptor ||
descriptor.channel !== scope.channel ||
descriptor.version !== manifest.version ||
descriptor.release_tag !== `fluxer-desktop-${scope.channel}@${manifest.version}`
) {
throw new Error(`Invalid GitHub desktop release descriptor: ${descriptorKey}`);
throw new Error(
`Invalid GitHub desktop release descriptor: ${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.json`,
);
}
const releaseAsset = descriptor.assets.find((asset) => asset.storage_key === key);
if (!releaseAsset) {
return {kind: 'not_current'};
}
const markerKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.ready.json`;
const marker = await this.readOptionalJsonObjectFromStorage(markerKey);
if (marker == null) {
return {kind: 'awaiting_release'};
}
const readiness = parseDesktopReleaseReadiness(marker);
const descriptorSha256 = createHash('sha256').update(descriptorText).digest('hex');
if (
!readiness ||
readiness.channel !== descriptor.channel ||
readiness.version !== descriptor.version ||
readiness.release_tag !== descriptor.release_tag ||
readiness.source_sha !== descriptor.source_sha ||
readiness.descriptor_sha256 !== descriptorSha256
) {
throw new Error(`Invalid GitHub desktop release readiness marker: ${markerKey}`);
}
return {
kind: 'ready',
location: `${GITHUB_RELEASE_DOWNLOAD_BASE_URL}/${encodeURIComponent(descriptor.release_tag)}/${encodeURIComponent(releaseAsset.release_asset)}`,
@@ -269,18 +265,22 @@ export class DownloadService {
return null;
}
const manifestKey = `${prefix}/manifest.json`;
const releasability = new Map<string, boolean>();
try {
const manifest = await this.readJsonObjectFromStorage(manifestKey);
if (!isDesktopManifest(manifest)) {
return this.resolveLatestDesktopKeyFromObjects(params);
if (
!isDesktopManifest(manifest) ||
!(await this.isReleasableDesktopVersion(params, manifest.version, releasability))
) {
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
}
const entry = manifest.files[params.format];
if (!entry) {
return this.resolveLatestDesktopKeyFromObjects(params);
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
}
const filename = this.extractFilename(entry);
if (filename.trim().length === 0) {
return this.resolveLatestDesktopKeyFromObjects(params);
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
}
const resolvedFilename = await this.resolveManifestFilename({
channel: params.channel,
@@ -291,7 +291,11 @@ export class DownloadService {
test: params.test,
});
if (!resolvedFilename) {
return this.resolveLatestDesktopKeyFromObjects(params);
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
}
const parsed = this.parseVersionFromFilename(resolvedFilename, params.channel, params.plat, params.arch);
if (parsed && parsed.version !== manifest.version) {
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
}
return this.buildDesktopArtifactKey({
channel: params.channel,
@@ -302,7 +306,7 @@ export class DownloadService {
});
} catch (error) {
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
return this.resolveLatestDesktopKeyFromObjects(params);
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
}
throw error;
}
@@ -320,10 +324,14 @@ export class DownloadService {
return null;
}
const manifestKey = `${prefix}/manifest.json`;
const releasability = new Map<string, boolean>();
try {
const manifest = await this.readJsonObjectFromStorage(manifestKey);
if (!isDesktopManifest(manifest)) {
return this.getLatestDesktopVersionFromObjects(params);
if (
!isDesktopManifest(manifest) ||
!(await this.isReleasableDesktopVersion(params, manifest.version, releasability))
) {
return this.getLatestDesktopVersionFromObjects(params, releasability);
}
const result = await this.getLatestDesktopVersionFromManifest(params, manifest);
if (result) {
@@ -331,11 +339,11 @@ export class DownloadService {
}
} catch (error) {
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
return this.getLatestDesktopVersionFromObjects(params);
return this.getLatestDesktopVersionFromObjects(params, releasability);
}
throw error;
}
return this.getLatestDesktopVersionFromObjects(params);
return this.getLatestDesktopVersionFromObjects(params, releasability);
}
async listDesktopVersions(params: {
@@ -351,158 +359,17 @@ export class DownloadService {
versions: Array<VersionInfo>;
hasMore: boolean;
}> {
const basePrefix = desktopArtifactPrefix(params);
if (!basePrefix) {
return {versions: [], hasMore: false};
let listedVersions = await this.listDesktopVersionFiles(params);
if (params.before) {
listedVersions = listedVersions.filter((entry) => this.compareVersions(entry.version, params.before ?? '') > 0);
}
const prefix = `${basePrefix}/`;
try {
const objects = await this.listDesktopArtifacts(prefix);
if (objects.length === 0) {
return {versions: [], hasMore: false};
}
const versionMap = new Map<
string,
{
pub_date: Date;
files: Map<
DesktopFormat,
{
filename: string;
sha256Key: string | null;
}
>;
}
>();
const sha256Files = new Set<string>();
for (const obj of objects) {
if (obj.key.endsWith('.sha256')) {
sha256Files.add(obj.key);
}
}
for (const obj of objects) {
const filename = obj.key.slice(prefix.length);
if (filename.includes('/') || filename.endsWith('.sha256') || filename === 'manifest.json') {
continue;
}
const parsed = this.parseVersionFromFilename(filename, params.channel, params.plat, params.arch);
if (!parsed) {
continue;
}
const {version, format} = parsed;
const sha256Key = sha256Files.has(`${obj.key}.sha256`) ? `${obj.key}.sha256` : null;
if (!versionMap.has(version)) {
versionMap.set(version, {
pub_date: obj.lastModified ?? new Date(),
files: new Map(),
});
}
const entry = versionMap.get(version);
if (entry) {
if (!entry.files.has(format)) {
entry.files.set(format, {filename, sha256Key});
}
if (obj.lastModified && obj.lastModified > entry.pub_date) {
entry.pub_date = obj.lastModified;
}
}
}
const sortedVersions = Array.from(versionMap.keys()).sort(this.compareVersions);
let filteredVersions = sortedVersions;
if (params.before) {
filteredVersions = filteredVersions.filter((v) => this.compareVersions(v, params.before ?? '') > 0);
}
if (params.after) {
filteredVersions = filteredVersions.filter((v) => this.compareVersions(v, params.after ?? '') < 0);
}
const hasMore = filteredVersions.length > params.limit;
const paginatedVersions = filteredVersions.slice(0, params.limit);
const sha256Promises: Array<
Promise<{
key: string;
hash: string | null;
}>
> = [];
for (const version of paginatedVersions) {
const entry = versionMap.get(version);
if (!entry) {
continue;
}
for (const [, fileInfo] of entry.files) {
if (fileInfo.sha256Key) {
sha256Promises.push(
(async () => {
try {
const streamResult = await this.storageService.streamObject({
bucket: Config.s3.buckets.downloads,
key: fileInfo.sha256Key as string,
});
if (streamResult) {
const body = Readable.toWeb(streamResult.body);
const text = await new Response(body as ReadableStream).text();
return {key: fileInfo.sha256Key as string, hash: text.trim().split(/\s+/u)[0]};
}
} catch {
return {key: fileInfo.sha256Key as string, hash: null};
}
return {key: fileInfo.sha256Key as string, hash: null};
})(),
);
}
}
}
const sha256Results = await Promise.all(sha256Promises);
const sha256Map = new Map<string, string | null>();
for (const result of sha256Results) {
sha256Map.set(result.key, result.hash);
}
const versions: Array<VersionInfo> = [];
for (const version of paginatedVersions) {
const entry = versionMap.get(version);
if (!entry) {
continue;
}
const files: Record<string, VersionFile> = {};
for (const [format, fileInfo] of entry.files) {
const sha256 = fileInfo.sha256Key ? (sha256Map.get(fileInfo.sha256Key) ?? null) : null;
const validSha256 = sha256 && this.isValidSha256(sha256) ? sha256 : null;
files[format] = {
url: this.buildDesktopVersionUrl({
channel: params.channel,
plat: params.plat,
arch: params.arch,
version,
format,
baseUrl: params.baseUrl,
test: params.test,
}),
sha256: validSha256,
checksum_url: validSha256
? this.buildDesktopVersionChecksumUrl({
channel: params.channel,
plat: params.plat,
arch: params.arch,
version,
format,
baseUrl: params.baseUrl,
test: params.test,
})
: null,
};
}
versions.push({
version,
pub_date: entry.pub_date.toISOString(),
files,
});
}
return {versions, hasMore};
} catch (error) {
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
return {versions: [], hasMore: false};
}
throw error;
if (params.after) {
listedVersions = listedVersions.filter((entry) => this.compareVersions(entry.version, params.after ?? '') < 0);
}
return {
versions: await this.buildListedDesktopVersions(params, listedVersions.slice(0, params.limit)),
hasMore: listedVersions.length > params.limit,
};
}
async resolveVersionedDesktopKey(params: {
@@ -558,10 +425,10 @@ export class DownloadService {
}): Promise<DesktopChecksumFile | null> {
const version = await this.getLatestDesktopVersion(params);
const file = version?.files[params.format];
if (!file?.sha256 || !this.isValidSha256(file.sha256)) {
if (!version || !file?.sha256 || !this.isValidSha256(file.sha256)) {
return null;
}
const key = await this.resolveLatestDesktopKey(params);
const key = await this.resolveVersionedDesktopKey({...params, version: version.version});
if (!key) {
return null;
}
@@ -586,8 +453,16 @@ export class DownloadService {
if (objectSha256) {
return this.buildDesktopChecksumFile(key, filename, objectSha256);
}
const latest = await this.getLatestDesktopVersion(params);
const file = latest?.version === params.version ? latest.files[params.format] : undefined;
const prefix = desktopArtifactPrefix(params);
if (!prefix) {
return null;
}
const manifest = await this.readOptionalJsonObjectFromStorage(`${prefix}/manifest.json`);
const versionInfo =
isDesktopManifest(manifest) && manifest.version === params.version
? await this.getLatestDesktopVersionFromManifest(params, manifest)
: null;
const file = versionInfo?.files[params.format];
if (!file?.sha256 || !this.isValidSha256(file.sha256)) {
return null;
}
@@ -768,6 +643,77 @@ export class DownloadService {
}
}
private async readDesktopReleaseState(channel: DesktopChannel, version: string): Promise<DesktopReleaseState> {
const descriptorKey = `${DESKTOP_BUCKET_PREFIX}/${channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${version}.json`;
const markerKey = `${DESKTOP_BUCKET_PREFIX}/${channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${version}.ready.json`;
const [descriptorText, markerText] = await Promise.all([
this.readOptionalTextFromStorage(descriptorKey),
this.readOptionalTextFromStorage(markerKey),
]);
if (descriptorText == null) {
return {kind: 'untracked'};
}
if (markerText == null) {
return {kind: 'unpublished'};
}
const readiness = parseDesktopReleaseReadiness(parseJsonRecord(markerText));
if (
!readiness ||
readiness.channel !== channel ||
readiness.version !== version ||
readiness.release_tag !== `fluxer-desktop-${channel}@${version}` ||
readiness.descriptor_sha256 !== createHash('sha256').update(descriptorText).digest('hex')
) {
Logger.error({key: markerKey}, 'Invalid GitHub desktop release readiness marker');
return {kind: 'unpublished'};
}
return {kind: 'published', descriptorText};
}
private async isReleasableDesktopVersion(
params: {channel: DesktopChannel; test?: boolean},
version: string,
releasability: Map<string, boolean>,
): Promise<boolean> {
if (params.test || Config.instance.selfHosted) {
return true;
}
const checked = releasability.get(version);
if (checked !== undefined) {
return checked;
}
let releasable = true;
try {
releasable = (await this.readDesktopReleaseState(params.channel, version)).kind !== 'unpublished';
} catch (error) {
Logger.error({error, channel: params.channel, version}, 'Failed to read desktop release readiness');
}
releasability.set(version, releasable);
return releasable;
}
private async findNewestReleasableDesktopVersion<T extends {version: string}>(
params: {channel: DesktopChannel; test?: boolean},
candidates: ReadonlyArray<T>,
releasability: Map<string, boolean>,
): Promise<T | null> {
const newestCandidates = candidates.slice(0, MAX_DESKTOP_RELEASE_CANDIDATES);
for (const candidate of newestCandidates) {
if (await this.isReleasableDesktopVersion(params, candidate.version, releasability)) {
return candidate;
}
}
const [newest] = newestCandidates;
if (!newest) {
return null;
}
Logger.error(
{channel: params.channel, version: newest.version},
'No recent desktop version has a published release',
);
return newest;
}
private isValidSha256(value: string): boolean {
return /^[a-f0-9]{64}$/u.test(value);
}
@@ -789,7 +735,13 @@ export class DownloadService {
) {
return manifestFilename;
}
return this.findLatestFilenameForRequestedArch(params);
for (const entry of await this.listDesktopVersionFiles(params)) {
const file = entry.files.get(params.format);
if (file) {
return file.filename;
}
}
return null;
}
private async listDesktopArtifacts(prefix: string): Promise<ReadonlyArray<{key: string; lastModified?: Date}>> {
@@ -826,44 +778,145 @@ export class DownloadService {
return params.filename.toLowerCase().endsWith('.exe');
}
private async findLatestFilenameForRequestedArch(params: LatestFilenameLookupParams): Promise<string | null> {
private async listDesktopVersionFiles(params: {
channel: DesktopChannel;
plat: DesktopPlatform;
arch: DesktopArch;
test?: boolean;
}): Promise<Array<ListedDesktopVersion>> {
const basePrefix = desktopArtifactPrefix(params);
if (!basePrefix) {
return null;
return [];
}
const prefix = `${basePrefix}/`;
const objects = await this.listDesktopArtifacts(prefix);
if (objects.length === 0) {
return null;
try {
const objects = await this.listDesktopArtifacts(prefix);
const versionMap = new Map<string, ListedDesktopVersion>();
const sha256Files = new Set<string>();
for (const obj of objects) {
if (obj.key.endsWith('.sha256')) {
sha256Files.add(obj.key);
}
}
for (const obj of objects) {
const filename = obj.key.slice(prefix.length);
if (filename.includes('/') || filename.endsWith('.sha256') || filename === 'manifest.json') {
continue;
}
const parsed = this.parseVersionFromFilename(filename, params.channel, params.plat, params.arch);
if (!parsed) {
continue;
}
const {version, format} = parsed;
const sha256Key = sha256Files.has(`${obj.key}.sha256`) ? `${obj.key}.sha256` : null;
if (!versionMap.has(version)) {
versionMap.set(version, {
version,
pub_date: obj.lastModified ?? new Date(),
files: new Map(),
});
}
const entry = versionMap.get(version);
if (entry) {
if (!entry.files.has(format)) {
entry.files.set(format, {filename, sha256Key});
}
if (obj.lastModified && obj.lastModified > entry.pub_date) {
entry.pub_date = obj.lastModified;
}
}
}
return Array.from(versionMap.values()).sort((left, right) => this.compareVersions(left.version, right.version));
} catch (error) {
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
return [];
}
throw error;
}
let latestFilename: string | null = null;
let latestVersion: string | null = null;
for (const obj of objects) {
const filename = obj.key.slice(prefix.length);
if (filename.length === 0) {
continue;
}
if (
filename.includes('/') ||
filename.endsWith('.sha256') ||
filename.endsWith('.blockmap') ||
filename.endsWith('.yml') ||
filename === 'manifest.json' ||
filename === 'RELEASES.json' ||
filename === 'releases.json'
) {
continue;
}
const parsed = this.parseVersionFromFilename(filename, params.channel, params.plat, params.arch);
if (!parsed || parsed.format !== params.format) {
continue;
}
if (!latestVersion || this.compareVersions(parsed.version, latestVersion) < 0) {
latestVersion = parsed.version;
latestFilename = filename;
}
private async buildListedDesktopVersions(
params: {
channel: DesktopChannel;
plat: DesktopPlatform;
arch: DesktopArch;
baseUrl?: string;
test?: boolean;
},
listedVersions: ReadonlyArray<ListedDesktopVersion>,
): Promise<Array<VersionInfo>> {
const sha256Promises: Array<
Promise<{
key: string;
hash: string | null;
}>
> = [];
for (const entry of listedVersions) {
for (const [, fileInfo] of entry.files) {
if (fileInfo.sha256Key) {
sha256Promises.push(
(async () => {
try {
const streamResult = await this.storageService.streamObject({
bucket: Config.s3.buckets.downloads,
key: fileInfo.sha256Key as string,
});
if (streamResult) {
const body = Readable.toWeb(streamResult.body);
const text = await new Response(body as ReadableStream).text();
return {key: fileInfo.sha256Key as string, hash: text.trim().split(/\s+/u)[0]};
}
} catch {
return {key: fileInfo.sha256Key as string, hash: null};
}
return {key: fileInfo.sha256Key as string, hash: null};
})(),
);
}
}
}
return latestFilename;
const sha256Results = await Promise.all(sha256Promises);
const sha256Map = new Map<string, string | null>();
for (const result of sha256Results) {
sha256Map.set(result.key, result.hash);
}
const versions: Array<VersionInfo> = [];
for (const entry of listedVersions) {
const files: Record<string, VersionFile> = {};
for (const [format, fileInfo] of entry.files) {
const sha256 = fileInfo.sha256Key ? (sha256Map.get(fileInfo.sha256Key) ?? null) : null;
const validSha256 = sha256 && this.isValidSha256(sha256) ? sha256 : null;
files[format] = {
url: this.buildDesktopVersionUrl({
channel: params.channel,
plat: params.plat,
arch: params.arch,
version: entry.version,
format,
baseUrl: params.baseUrl,
test: params.test,
}),
sha256: validSha256,
checksum_url: validSha256
? this.buildDesktopVersionChecksumUrl({
channel: params.channel,
plat: params.plat,
arch: params.arch,
version: entry.version,
format,
baseUrl: params.baseUrl,
test: params.test,
})
: null,
};
}
versions.push({
version: entry.version,
pub_date: entry.pub_date.toISOString(),
files,
});
}
return versions;
}
private escapeRegex(str: string): string {
@@ -1030,16 +1083,25 @@ export class DownloadService {
return key;
}
private async resolveLatestDesktopKeyFromObjects(params: LatestFilenameLookupParams): Promise<string | null> {
const filename = await this.findLatestFilenameForRequestedArch(params);
if (!filename) {
private async resolveLatestDesktopKeyFromObjects(
params: LatestFilenameLookupParams,
releasability: Map<string, boolean>,
): Promise<string | null> {
const listedVersions = await this.listDesktopVersionFiles(params);
const latest = await this.findNewestReleasableDesktopVersion(
params,
listedVersions.filter((entry) => entry.files.has(params.format)),
releasability,
);
const file = latest?.files.get(params.format);
if (!file) {
return null;
}
return this.buildDesktopArtifactKey({
channel: params.channel,
plat: params.plat,
arch: params.arch,
filename,
filename: file.filename,
test: params.test,
});
}
@@ -1189,22 +1251,23 @@ export class DownloadService {
};
}
private async getLatestDesktopVersionFromObjects(params: {
channel: DesktopChannel;
plat: DesktopPlatform;
arch: DesktopArch;
baseUrl?: string;
test?: boolean;
}): Promise<VersionInfo | null> {
const {versions} = await this.listDesktopVersions({
channel: params.channel,
plat: params.plat,
arch: params.arch,
limit: 1,
baseUrl: params.baseUrl,
test: params.test,
});
return versions[0] ?? null;
private async getLatestDesktopVersionFromObjects(
params: {
channel: DesktopChannel;
plat: DesktopPlatform;
arch: DesktopArch;
baseUrl?: string;
test?: boolean;
},
releasability: Map<string, boolean>,
): Promise<VersionInfo | null> {
const listedVersions = await this.listDesktopVersionFiles(params);
const latest = await this.findNewestReleasableDesktopVersion(params, listedVersions, releasability);
if (!latest) {
return null;
}
const [versionInfo] = await this.buildListedDesktopVersions(params, [latest]);
return versionInfo ?? null;
}
private async resolveDesktopFileSha256(params: {
@@ -0,0 +1,337 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {Readable} from 'node:stream';
import {S3ServiceException} from '@aws-sdk/client-s3';
import {describe, expect, it} from 'vitest';
import {getConfig} from '../../Config';
import type {IStorageService} from '../../infrastructure/IStorageService';
import {DownloadService} from '../DownloadService';
const PREFIX = 'desktop/canary/linux/x64';
const TEST_PREFIX = 'desktop-test/canary/linux/x64';
const RELEASES_PREFIX = 'desktop/canary/github-releases';
const SOURCE_SHA = 'b'.repeat(40);
const V904 = '2026.904.135113';
const V908 = '2026.908.173325';
const V909 = '2026.909.202036';
const LATEST_PARAMS = {channel: 'canary', plat: 'linux', arch: 'x64'} as const;
const APPIMAGE_PARAMS = {...LATEST_PARAMS, format: 'appimage'} as const;
const RELEASE_ROUTES: ReadonlyArray<readonly [string, string, number]> = [
['darwin', 'arm64', 4],
['darwin', 'x64', 4],
['linux', 'arm64', 4],
['linux', 'x64', 4],
['win32', 'arm64', 6],
['win32', 'x64', 6],
];
type StoredObjects = Map<string, string>;
function sha256Hex(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
function appImageFilename(version: string): string {
return `Fluxer-Canary-${version}-linux-x86_64.AppImage`;
}
function uploadBuild(objects: StoredObjects, version: string, options: {prefix?: string; checksum?: boolean} = {}) {
const prefix = options.prefix ?? PREFIX;
const filename = appImageFilename(version);
objects.set(`${prefix}/${filename}`, filename);
if (options.checksum !== false) {
objects.set(`${prefix}/${filename}.sha256`, `${sha256Hex(filename)} ${filename}`);
}
objects.set(
`${prefix}/manifest.json`,
JSON.stringify({
channel: 'canary',
platform: 'linux',
arch: 'x64',
version,
pub_date: '2026-09-08T18:06:00Z',
files: {appimage: {filename, sha256: sha256Hex(filename)}},
}),
);
}
function publishDescriptor(objects: StoredObjects, version: string, routes = RELEASE_ROUTES): string {
const assets = routes.flatMap(([plat, arch, count]) =>
Array.from({length: count}, (_, index) => {
const filename =
plat === 'linux' && arch === 'x64' && index === 0
? appImageFilename(version)
: `Fluxer-Canary-${version}-${plat}-${arch}-${index}.bin`;
return {
storage_key: `desktop/canary/${plat}/${arch}/${filename}`,
release_asset: filename,
sha256: sha256Hex(filename),
size: 1,
};
}),
);
const descriptor = JSON.stringify({
schema_version: 1,
channel: 'canary',
version,
release_tag: `fluxer-desktop-canary@${version}`,
source_sha: SOURCE_SHA,
assets,
});
objects.set(`${RELEASES_PREFIX}/${version}.json`, descriptor);
return descriptor;
}
function publishMarker(objects: StoredObjects, version: string, descriptor: string) {
objects.set(
`${RELEASES_PREFIX}/${version}.ready.json`,
JSON.stringify({
schema_version: 1,
channel: 'canary',
version,
release_tag: `fluxer-desktop-canary@${version}`,
source_sha: SOURCE_SHA,
descriptor_sha256: sha256Hex(descriptor),
}),
);
}
function releaseBuild(objects: StoredObjects, version: string) {
const descriptor = publishDescriptor(objects, version);
uploadBuild(objects, version);
publishMarker(objects, version, descriptor);
}
function incidentObjects(): StoredObjects {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
publishDescriptor(objects, V908);
uploadBuild(objects, V908);
return objects;
}
function createService(objects: StoredObjects, onRead?: (key: string) => void) {
const reads: Array<string> = [];
const listings: Array<string> = [];
const storageService = {
streamObject: async (params: {key: string}) => {
reads.push(params.key);
onRead?.(params.key);
const body = objects.get(params.key);
if (body == null) {
return null;
}
const buffer = Buffer.from(body, 'utf8');
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
},
listObjects: async (params: {prefix: string}) => {
listings.push(params.prefix);
return Array.from(objects.keys())
.filter((key) => key.startsWith(params.prefix))
.sort()
.map((key) => ({key}));
},
getObjectMetadata: async (_bucket: string, key: string) =>
objects.has(key) ? {contentLength: 1, contentType: 'application/octet-stream'} : null,
} as unknown as IStorageService;
return {service: new DownloadService(storageService), reads, listings};
}
async function resolveLatest(service: DownloadService, test?: boolean) {
const metadata = await service.getLatestDesktopVersion({...LATEST_PARAMS, test});
const key = await service.resolveLatestDesktopKey({...APPIMAGE_PARAMS, test});
const checksum = await service.resolveLatestDesktopChecksumFile({...APPIMAGE_PARAMS, test});
return {version: metadata?.version, key, checksum: checksum?.body};
}
function latestOf(version: string, prefix = PREFIX) {
const filename = appImageFilename(version);
return {version, key: `${prefix}/${filename}`, checksum: `${sha256Hex(filename)} ${filename}\n`};
}
describe('desktop release readiness', () => {
it('offers a published manifest version after reading only its release state', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
releaseBuild(objects, V909);
const {service, reads, listings} = createService(objects);
await expect(service.getLatestDesktopVersion({...LATEST_PARAMS})).resolves.toMatchObject({version: V909});
expect(reads).toEqual([
`${PREFIX}/manifest.json`,
`${RELEASES_PREFIX}/${V909}.json`,
`${RELEASES_PREFIX}/${V909}.ready.json`,
]);
expect(listings).toEqual([]);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V909));
});
it('falls back to the newest published version while the manifest version awaits its release', async () => {
const {service} = createService(incidentObjects());
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
});
it('reads each release state once and one checksum while the manifest version awaits its release', async () => {
const {service, reads, listings} = createService(incidentObjects());
await expect(service.getLatestDesktopVersion({...LATEST_PARAMS})).resolves.toMatchObject({version: V904});
expect(reads).toEqual([
`${PREFIX}/manifest.json`,
`${RELEASES_PREFIX}/${V908}.json`,
`${RELEASES_PREFIX}/${V908}.ready.json`,
`${RELEASES_PREFIX}/${V904}.json`,
`${RELEASES_PREFIX}/${V904}.ready.json`,
`${PREFIX}/${appImageFilename(V904)}.sha256`,
]);
expect(listings).toEqual([`${PREFIX}/`]);
});
it('offers a manifest version that has no release descriptor', async () => {
const objects: StoredObjects = new Map();
uploadBuild(objects, V904);
uploadBuild(objects, V908);
const {service} = createService(objects);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
});
it('treats a readiness marker that does not match the stored descriptor as unpublished', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
publishDescriptor(objects, V908);
uploadBuild(objects, V908);
publishMarker(objects, V908, 'another descriptor');
const {service} = createService(objects);
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).resolves.toEqual({
kind: 'awaiting_release',
});
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
});
it('offers a version whose descriptor the parser rejects when its readiness marker matches', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
const descriptor = publishDescriptor(
objects,
V908,
RELEASE_ROUTES.map(([plat, arch, count]) => [plat, arch, plat === 'linux' ? count - 1 : count] as const),
);
uploadBuild(objects, V908);
publishMarker(objects, V908, descriptor);
const {service} = createService(objects);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).rejects.toThrow(
'Invalid GitHub desktop release descriptor',
);
});
it.each([
['descriptor', `${RELEASES_PREFIX}/${V908}.json`],
['readiness marker', `${RELEASES_PREFIX}/${V908}.ready.json`],
])('offers the manifest version when reading its release %s fails with a storage error', async (_name, failingKey) => {
const {service} = createService(incidentObjects(), (key) => {
if (key === failingKey) {
throw new S3ServiceException({
name: 'SlowDown',
$fault: 'server',
$metadata: {httpStatusCode: 503},
message: 'Please reduce your request rate.',
});
}
});
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
});
it('still resolves the unpublished version through versioned routes', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
publishDescriptor(objects, V908);
uploadBuild(objects, V908, {checksum: false});
const {service} = createService(objects);
const params = {...APPIMAGE_PARAMS, version: V908};
const filename = appImageFilename(V908);
await expect(service.resolveVersionedDesktopKey(params)).resolves.toBe(`${PREFIX}/${filename}`);
await expect(service.resolveVersionedDesktopChecksumFile(params)).resolves.toMatchObject({
sha256: sha256Hex(filename),
});
});
it('keeps offering the manifest version on self-hosted instances', async () => {
const config = getConfig();
const originalSelfHosted = config.instance.selfHosted;
config.instance.selfHosted = true;
try {
const {service, reads} = createService(incidentObjects());
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
expect(reads.filter((key) => key.startsWith(RELEASES_PREFIX))).toEqual([]);
} finally {
config.instance.selfHosted = originalSelfHosted;
}
});
it('keeps offering the newest test build', async () => {
const objects: StoredObjects = new Map();
publishDescriptor(objects, V908);
uploadBuild(objects, V908, {prefix: TEST_PREFIX});
const {service, reads} = createService(objects);
await expect(resolveLatest(service, true)).resolves.toEqual(latestOf(V908, TEST_PREFIX));
expect(reads.filter((key) => key.startsWith(RELEASES_PREFIX))).toEqual([]);
});
it('offers 904 while 908 awaits its release, then 909 once its marker lands', async () => {
const objects = incidentObjects();
const {service} = createService(objects);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).resolves.toEqual({
kind: 'awaiting_release',
});
const descriptor = publishDescriptor(objects, V909);
uploadBuild(objects, V909);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
publishMarker(objects, V909, descriptor);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V909));
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V909)}`)).resolves.toEqual({
kind: 'ready',
location: `https://github.com/fluxerapp/fluxer/releases/download/${encodeURIComponent(`fluxer-desktop-canary@${V909}`)}/${appImageFilename(V909)}`,
});
});
it('offers the newest version when ten unpublished versions hide a published one', async () => {
const objects: StoredObjects = new Map();
releaseBuild(objects, V904);
const newest = '2026.908.170009';
for (let build = 0; build < 10; build++) {
const version = `2026.908.${170000 + build}`;
publishDescriptor(objects, version);
uploadBuild(objects, version);
}
const {service, reads} = createService(objects);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(newest));
expect(reads).not.toContain(`${RELEASES_PREFIX}/${V904}.ready.json`);
});
it('pairs the latest checksum with the filename of the same version when a marker lands mid-request', async () => {
const objects = incidentObjects();
const descriptor = publishDescriptor(objects, V909);
uploadBuild(objects, V909);
let manifestReads = 0;
const {service} = createService(objects, (key) => {
if (key !== `${PREFIX}/manifest.json`) {
return;
}
manifestReads += 1;
if (manifestReads === 2) {
publishMarker(objects, V909, descriptor);
}
});
const checksum = await service.resolveLatestDesktopChecksumFile({...APPIMAGE_PARAMS});
expect(checksum?.body).toBe(latestOf(V904).checksum);
});
it('lists an unpublished version while latest skips it', async () => {
const {service} = createService(incidentObjects());
const listed = await service.listDesktopVersions({...LATEST_PARAMS, limit: 10});
expect(listed.versions.map((entry) => entry.version)).toEqual([V908, V904]);
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
});
});
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Readable} from 'node:stream';
import {describe, expect, it} from 'vitest';
import type {IStorageService} from '../../infrastructure/IStorageService';
import {DownloadService} from '../DownloadService';
const PREFIX = 'desktop/canary/linux/x64';
const BASE_URL = 'https://api.example.test';
const V1 = '2026.901.100000';
const V2 = '2026.902.100000';
const V3 = '2026.903.100000';
const V4 = '2026.904.100000';
const V5 = '2026.905.100000';
const LIST_PARAMS = {channel: 'canary', plat: 'linux', arch: 'x64', baseUrl: BASE_URL} as const;
type StoredObject = {body?: string; lastModified?: Date};
type StoredObjects = Map<string, StoredObject>;
function appImageFilename(version: string): string {
return `Fluxer-Canary-${version}-linux-x86_64.AppImage`;
}
function debFilename(version: string): string {
return `Fluxer-Canary-${version}-linux-amd64.deb`;
}
function addArtifact(objects: StoredObjects, filename: string, options: {sha256?: string; lastModified?: Date} = {}) {
objects.set(`${PREFIX}/${filename}`, {lastModified: options.lastModified});
if (options.sha256 !== undefined) {
objects.set(`${PREFIX}/${filename}.sha256`, {body: `${options.sha256} ${filename}\n`});
}
}
function createService(objects: StoredObjects) {
const reads: Array<string> = [];
const storageService = {
streamObject: async (params: {key: string}) => {
reads.push(params.key);
const object = objects.get(params.key);
if (object?.body == null) {
return null;
}
const buffer = Buffer.from(object.body, 'utf8');
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
},
listObjects: async (params: {prefix: string}) =>
Array.from(objects.entries())
.filter(([key]) => key.startsWith(params.prefix))
.sort(([left], [right]) => (left < right ? -1 : 1))
.map(([key, object]) => ({key, lastModified: object.lastModified})),
getObjectMetadata: async () => null,
} as unknown as IStorageService;
return {service: new DownloadService(storageService), reads};
}
function versionNumbers(versions: Array<{version: string}>): Array<string> {
return versions.map((entry) => entry.version);
}
describe('desktop version listing', () => {
it('lists versions newest first with the files of each version', async () => {
const objects: StoredObjects = new Map();
addArtifact(objects, appImageFilename(V1), {lastModified: new Date('2026-09-01T10:00:00Z')});
addArtifact(objects, appImageFilename(V3), {lastModified: new Date('2026-09-03T10:00:00Z')});
addArtifact(objects, debFilename(V3), {lastModified: new Date('2026-09-03T12:00:00Z')});
addArtifact(objects, appImageFilename(V5), {lastModified: new Date('2026-09-05T10:00:00Z')});
const {service} = createService(objects);
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
expect(versionNumbers(listed.versions)).toEqual([V5, V3, V1]);
expect(listed.hasMore).toBe(false);
expect(Object.keys(listed.versions[1].files).sort()).toEqual(['appimage', 'deb']);
expect(listed.versions[1].pub_date).toBe('2026-09-03T12:00:00.000Z');
expect(listed.versions[0].files.appimage.url).toBe(`${BASE_URL}/dl/desktop/canary/linux/x64/${V5}/appimage`);
});
it('excludes names that are not artefacts for the requested coordinate', async () => {
const objects: StoredObjects = new Map();
addArtifact(objects, appImageFilename(V3), {sha256: 'a'.repeat(64)});
objects.set(`${PREFIX}/nested/${appImageFilename(V5)}`, {});
objects.set(`${PREFIX}/manifest.json`, {body: '{}'});
objects.set(`${PREFIX}/RELEASES.json`, {body: '{}'});
objects.set(`${PREFIX}/releases.json`, {body: '{}'});
objects.set(`${PREFIX}/latest-linux.yml`, {body: 'version: 1'});
objects.set(`${PREFIX}/${appImageFilename(V4)}.blockmap`, {});
objects.set(`${PREFIX}/Fluxer-Canary-${V4}-linux-aarch64.AppImage`, {});
objects.set(`${PREFIX}/Fluxer-Canary-${V4}-mac-universal.dmg`, {});
const {service} = createService(objects);
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
expect(versionNumbers(listed.versions)).toEqual([V3]);
expect(Object.keys(listed.versions[0].files)).toEqual(['appimage']);
});
it('pages with limit, before and after and reports whether more remain', async () => {
const objects: StoredObjects = new Map();
for (const version of [V1, V2, V3, V4, V5]) {
addArtifact(objects, appImageFilename(version));
}
const {service} = createService(objects);
const firstPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2});
expect(versionNumbers(firstPage.versions)).toEqual([V5, V4]);
expect(firstPage.hasMore).toBe(true);
const olderPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2, before: V3});
expect(versionNumbers(olderPage.versions)).toEqual([V2, V1]);
expect(olderPage.hasMore).toBe(false);
const newerPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2, after: V3});
expect(versionNumbers(newerPage.versions)).toEqual([V5, V4]);
expect(newerPage.hasMore).toBe(false);
const between = await service.listDesktopVersions({...LIST_PARAMS, limit: 1, before: V5, after: V1});
expect(versionNumbers(between.versions)).toEqual([V4]);
expect(between.hasMore).toBe(true);
});
it('reports the sibling hash and treats a missing or malformed one as absent', async () => {
const hash = 'b'.repeat(64);
const objects: StoredObjects = new Map();
addArtifact(objects, appImageFilename(V3), {sha256: hash});
addArtifact(objects, appImageFilename(V2));
addArtifact(objects, appImageFilename(V1), {sha256: 'C'.repeat(64)});
const {service} = createService(objects);
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
expect(listed.versions[0].files.appimage).toEqual({
url: `${BASE_URL}/dl/desktop/canary/linux/x64/${V3}/appimage`,
sha256: hash,
checksum_url: `${BASE_URL}/dl/desktop/canary/linux/x64/${V3}/appimage.sha256`,
});
expect(listed.versions[1].files.appimage.sha256).toBeNull();
expect(listed.versions[1].files.appimage.checksum_url).toBeNull();
expect(listed.versions[2].files.appimage.sha256).toBeNull();
expect(listed.versions[2].files.appimage.checksum_url).toBeNull();
});
it('reads a checksum only for the versions it returns', async () => {
const objects: StoredObjects = new Map();
for (const version of [V1, V2, V3, V4, V5]) {
addArtifact(objects, appImageFilename(version), {sha256: 'd'.repeat(64)});
}
const {service, reads} = createService(objects);
await service.listDesktopVersions({...LIST_PARAMS, limit: 2});
expect(reads).toEqual([`${PREFIX}/${appImageFilename(V5)}.sha256`, `${PREFIX}/${appImageFilename(V4)}.sha256`]);
});
});
+28 -31
View File
@@ -13342,13 +13342,10 @@
"name": "country_code",
"in": "query",
"required": false,
"schema": {"type": "string", "description": "Two-letter country code for regional pricing"}
},
{
"name": "pricing_mode",
"in": "query",
"required": false,
"schema": {"$ref": "#/components/schemas/PricingModeEnum"}
"schema": {
"type": "string",
"description": "Two-letter country code for regional pricing. Only used when the server cannot geolocate the request; otherwise the request GeoIP country wins."
}
}
]
}
@@ -13623,7 +13620,10 @@
"name": "country_code",
"in": "query",
"required": false,
"schema": {"type": "string", "description": "Two-letter country code for regional pricing"}
"schema": {
"type": "string",
"description": "Two-letter country code for regional pricing. Only used when the server cannot geolocate the request; otherwise the request GeoIP country wins."
}
}
]
}
@@ -32064,9 +32064,9 @@
"description": "The amount the user is actually charged, in the currency minor unit"
},
"currency": {
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
"type": "string",
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
"description": "Currency of the charged amount"
},
"billing_cycle": {
@@ -32345,9 +32345,9 @@
"currency": {
"anyOf": [
{
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
"type": "string",
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"]
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"]
},
{"type": "null"}
],
@@ -32418,9 +32418,9 @@
"currency": {
"anyOf": [
{
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
"type": "string",
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"]
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"]
},
{"type": "null"}
],
@@ -32622,13 +32622,9 @@
"localized": {
"anyOf": [{"$ref": "#/components/schemas/PriceIdsResponse"}, {"type": "null"}],
"description": "Localized checkout prices resolved from mirrored billing data"
},
"base": {
"anyOf": [{"$ref": "#/components/schemas/PriceIdsResponse"}, {"type": "null"}],
"description": "Standard USD/EUR checkout prices resolved from mirrored billing data"
}
},
"required": ["country_code", "localized", "base"]
"required": ["country_code", "localized"]
},
"PriceIdsResponse": {
"type": "object",
@@ -32666,15 +32662,15 @@
"description": "Gift 1 year price amount in the currency minor unit"
},
"currency": {
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
"type": "string",
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
"description": "Currency for the prices"
},
"gift_currency": {
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
"type": "string",
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
"description": "Currency for gift prices"
}
},
@@ -32687,7 +32683,6 @@
},
"required": ["disabled"]
},
"PricingModeEnum": {"enum": ["localized", "base"], "type": "string", "x-enumNames": ["localized", "base"]},
"SelfServeRefundResponse": {
"type": "object",
"properties": {
@@ -32754,9 +32749,9 @@
"description": "Amount billed before the switch, in the currency minor unit"
},
"currency": {
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
"type": "string",
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
"description": "Currency of both amounts"
}
},
@@ -32793,9 +32788,9 @@
"description": "Amount billed before the switch, in the currency minor unit"
},
"currency": {
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
"type": "string",
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
"description": "Currency of both amounts"
}
},
@@ -34016,7 +34011,10 @@
"type": "object",
"properties": {
"price_id": {"type": "string", "description": "The Stripe price ID for the subscription plan"},
"country_code": {"type": "string", "description": "Two-letter country code used for regional pricing"},
"country_code": {
"type": "string",
"description": "Two-letter country code used for regional pricing. Only used when the server cannot geolocate the request; otherwise the request GeoIP country wins."
},
"client_geoip_country_code": {
"type": "string",
"description": "Two-letter country code observed by the client GeoIP store before checkout"
@@ -34025,7 +34023,6 @@
"type": "boolean",
"description": "Whether the EU/EEA digital content withdrawal waiver was expressly accepted before checkout"
},
"pricing_mode": {"$ref": "#/components/schemas/PricingModeEnum"},
"payment_method": {"$ref": "#/components/schemas/CheckoutPaymentMethodEnum"},
"is_business": {
"type": "boolean",
@@ -36836,7 +36833,7 @@
"description": "Controls who sees the full profile: all guild members, only small-guild members, or only friends"
},
"synced_preferences": {
"anyOf": [{"type": "string", "maxLength": 349528}, {"type": "null"}],
"anyOf": [{"type": "string", "maxLength": 699052}, {"type": "null"}],
"description": "Account-wide client preferences as a base64-encoded protobuf snapshot. Replaces the entire stored snapshot; pass null to clear it."
}
}
@@ -10,6 +10,7 @@ import {RateLimitMiddleware} from '../middleware/RateLimitMiddleware';
import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '../RateLimitConfig';
import type {HonoApp} from '../types/HonoEnv';
import {lookupGeoip} from '../utils/IpUtils';
import {Validator} from '../Validator';
export function PremiumController(app: HonoApp) {
@@ -32,7 +33,8 @@ export function PremiumController(app: HonoApp) {
async (ctx) => {
const userId = ctx.get('user').id;
const {country_code} = ctx.req.valid('query');
const state = await ctx.get('stripeService').getPremiumState(userId, country_code);
const geoip = await lookupGeoip(ctx.req.raw);
const state = await ctx.get('stripeService').getPremiumState(userId, geoip.countryCode ?? country_code);
return ctx.json(state);
},
);
@@ -56,8 +56,11 @@ const LEGACY_SLOT_CURRENCIES: Record<string, Currency | undefined> = {
usd: 'USD',
eur: 'EUR',
brl: 'BRL',
dkk: 'DKK',
inr: 'INR',
nok: 'NOK',
pln: 'PLN',
sek: 'SEK',
try: 'TRY',
};
@@ -109,6 +112,14 @@ export class ProductRegistry {
currency: 'BRL',
billingCycle: 'monthly',
});
this.registerProduct(prices.monthlyDkk, {
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: false,
currency: 'DKK',
billingCycle: 'monthly',
});
this.registerProduct(prices.monthlyInr, {
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
@@ -117,6 +128,14 @@ export class ProductRegistry {
currency: 'INR',
billingCycle: 'monthly',
});
this.registerProduct(prices.monthlyNok, {
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: false,
currency: 'NOK',
billingCycle: 'monthly',
});
this.registerProduct(prices.monthlyPln, {
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
@@ -125,6 +144,14 @@ export class ProductRegistry {
currency: 'PLN',
billingCycle: 'monthly',
});
this.registerProduct(prices.monthlySek, {
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: false,
currency: 'SEK',
billingCycle: 'monthly',
});
this.registerProduct(prices.monthlyTry, {
type: ProductType.MONTHLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
@@ -157,6 +184,14 @@ export class ProductRegistry {
currency: 'BRL',
billingCycle: 'yearly',
});
this.registerProduct(prices.yearlyDkk, {
type: ProductType.YEARLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: false,
currency: 'DKK',
billingCycle: 'yearly',
});
this.registerProduct(prices.yearlyInr, {
type: ProductType.YEARLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
@@ -165,6 +200,14 @@ export class ProductRegistry {
currency: 'INR',
billingCycle: 'yearly',
});
this.registerProduct(prices.yearlyNok, {
type: ProductType.YEARLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: false,
currency: 'NOK',
billingCycle: 'yearly',
});
this.registerProduct(prices.yearlyPln, {
type: ProductType.YEARLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
@@ -173,6 +216,14 @@ export class ProductRegistry {
currency: 'PLN',
billingCycle: 'yearly',
});
this.registerProduct(prices.yearlySek, {
type: ProductType.YEARLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: false,
currency: 'SEK',
billingCycle: 'yearly',
});
this.registerProduct(prices.yearlyTry, {
type: ProductType.YEARLY_SUBSCRIPTION,
premiumType: UserPremiumTypes.SUBSCRIPTION,
@@ -209,6 +260,48 @@ export class ProductRegistry {
isGift: true,
currency: 'INR',
});
this.registerProduct(prices.gift1MonthDkk, {
type: ProductType.GIFT_1_MONTH,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: true,
currency: 'DKK',
});
this.registerProduct(prices.gift1YearDkk, {
type: ProductType.GIFT_1_YEAR,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: true,
currency: 'DKK',
});
this.registerProduct(prices.gift1MonthNok, {
type: ProductType.GIFT_1_MONTH,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: true,
currency: 'NOK',
});
this.registerProduct(prices.gift1YearNok, {
type: ProductType.GIFT_1_YEAR,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: true,
currency: 'NOK',
});
this.registerProduct(prices.gift1MonthSek, {
type: ProductType.GIFT_1_MONTH,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 1,
isGift: true,
currency: 'SEK',
});
this.registerProduct(prices.gift1YearSek, {
type: ProductType.GIFT_1_YEAR,
premiumType: UserPremiumTypes.SUBSCRIPTION,
durationMonths: 12,
isGift: true,
currency: 'SEK',
});
this.registerProduct(prices.gift1MonthPln, {
type: ProductType.GIFT_1_MONTH,
premiumType: UserPremiumTypes.SUBSCRIPTION,
@@ -319,12 +412,21 @@ export class ProductRegistry {
if (normalizedCurrency === 'brl') {
return billingCycle === 'monthly' ? (prices.monthlyBrl ?? null) : (prices.yearlyBrl ?? null);
}
if (normalizedCurrency === 'dkk') {
return billingCycle === 'monthly' ? (prices.monthlyDkk ?? null) : (prices.yearlyDkk ?? null);
}
if (normalizedCurrency === 'inr') {
return billingCycle === 'monthly' ? (prices.monthlyInr ?? null) : (prices.yearlyInr ?? null);
}
if (normalizedCurrency === 'nok') {
return billingCycle === 'monthly' ? (prices.monthlyNok ?? null) : (prices.yearlyNok ?? null);
}
if (normalizedCurrency === 'pln') {
return billingCycle === 'monthly' ? (prices.monthlyPln ?? null) : (prices.yearlyPln ?? null);
}
if (normalizedCurrency === 'sek') {
return billingCycle === 'monthly' ? (prices.monthlySek ?? null) : (prices.yearlySek ?? null);
}
if (normalizedCurrency === 'try') {
return billingCycle === 'monthly' ? (prices.monthlyTry ?? null) : (prices.yearlyTry ?? null);
}
@@ -349,6 +451,15 @@ export class ProductRegistry {
if (normalizedCurrency === 'inr') {
return duration === 'gift_1_month' ? (prices.gift1MonthInr ?? null) : (prices.gift1YearInr ?? null);
}
if (normalizedCurrency === 'dkk') {
return duration === 'gift_1_month' ? (prices.gift1MonthDkk ?? null) : (prices.gift1YearDkk ?? null);
}
if (normalizedCurrency === 'nok') {
return duration === 'gift_1_month' ? (prices.gift1MonthNok ?? null) : (prices.gift1YearNok ?? null);
}
if (normalizedCurrency === 'sek') {
return duration === 'gift_1_month' ? (prices.gift1MonthSek ?? null) : (prices.gift1YearSek ?? null);
}
if (normalizedCurrency === 'pln') {
return duration === 'gift_1_month' ? (prices.gift1MonthPln ?? null) : (prices.gift1YearPln ?? null);
}
+7 -22
View File
@@ -92,7 +92,6 @@ export function StripeController(app: HonoApp) {
country_code,
client_geoip_country_code,
eu_withdrawal_waiver_accepted,
pricing_mode,
payment_method,
is_business,
} = ctx.req.valid('json');
@@ -105,7 +104,6 @@ export function StripeController(app: HonoApp) {
clientGeoipCountryCode: client_geoip_country_code,
purchaseGeoipCountryCode: await getPurchaseGeoipCountryCode(ctx.req.raw),
euWithdrawalWaiverAccepted: eu_withdrawal_waiver_accepted,
pricingMode: pricing_mode,
paymentMethod: payment_method,
isBusiness: is_business,
});
@@ -129,14 +127,8 @@ export function StripeController(app: HonoApp) {
}),
Validator('json', CreateCheckoutSessionRequest),
async (ctx) => {
const {
price_id,
country_code,
client_geoip_country_code,
eu_withdrawal_waiver_accepted,
pricing_mode,
is_business,
} = ctx.req.valid('json');
const {price_id, country_code, client_geoip_country_code, eu_withdrawal_waiver_accepted, is_business} =
ctx.req.valid('json');
const userId = ctx.get('user').id;
const checkoutUrl = await ctx.get('stripeService').createLocalizedCardPreapprovalSession({
userId,
@@ -145,7 +137,6 @@ export function StripeController(app: HonoApp) {
clientGeoipCountryCode: client_geoip_country_code,
purchaseGeoipCountryCode: await getPurchaseGeoipCountryCode(ctx.req.raw),
euWithdrawalWaiverAccepted: eu_withdrawal_waiver_accepted,
pricingMode: pricing_mode,
isBusiness: is_business,
});
return ctx.json({url: checkoutUrl});
@@ -187,14 +178,8 @@ export function StripeController(app: HonoApp) {
}),
Validator('json', CreateCheckoutSessionRequest),
async (ctx) => {
const {
price_id,
country_code,
client_geoip_country_code,
eu_withdrawal_waiver_accepted,
pricing_mode,
is_business,
} = ctx.req.valid('json');
const {price_id, country_code, client_geoip_country_code, eu_withdrawal_waiver_accepted, is_business} =
ctx.req.valid('json');
const userId = ctx.get('user').id;
const checkoutUrl = await ctx.get('stripeService').createCheckoutSession({
userId,
@@ -204,7 +189,6 @@ export function StripeController(app: HonoApp) {
clientGeoipCountryCode: client_geoip_country_code,
purchaseGeoipCountryCode: await getPurchaseGeoipCountryCode(ctx.req.raw),
euWithdrawalWaiverAccepted: eu_withdrawal_waiver_accepted,
pricingMode: pricing_mode,
isBusiness: is_business,
});
return ctx.json({url: checkoutUrl});
@@ -301,8 +285,9 @@ export function StripeController(app: HonoApp) {
tags: 'Premium',
}),
async (ctx) => {
const {country_code, pricing_mode} = ctx.req.valid('query');
const priceIds = await ctx.get('stripeService').getPriceIds(country_code, pricing_mode);
const {country_code} = ctx.req.valid('query');
const geoipCountryCode = await getPurchaseGeoipCountryCode(ctx.req.raw);
const priceIds = await ctx.get('stripeService').getPriceIds(geoipCountryCode ?? country_code);
return ctx.json(priceIds);
},
);
+2 -7
View File
@@ -4,7 +4,6 @@ import {PremiumPurchaseBlockedError} from '@fluxer/errors/src/domains/payment/Pr
import type {
CurrentSubscriptionPriceResponse,
PremiumStateResponse,
PricingMode,
SelfServeRefundEligibilityResponse,
SelfServeRefundResponse,
SwitchToListPriceResponse,
@@ -144,7 +143,6 @@ export class StripeService {
| 'euWithdrawalWaiverAccepted'
| 'isBusiness'
| 'priceId'
| 'pricingMode'
| 'purchaseGeoipCountryCode'
| 'userId'
>,
@@ -160,10 +158,7 @@ export class StripeService {
return this.checkoutService.createCustomerPortalSession(userId);
}
async getPriceIds(
countryCode?: string,
pricingMode: PricingMode = 'localized',
): Promise<{
async getPriceIds(countryCode?: string): Promise<{
monthly: string | null;
yearly: string | null;
gift_1_month: string | null;
@@ -175,7 +170,7 @@ export class StripeService {
gift_1_month_amount_minor: number | null;
gift_1_year_amount_minor: number | null;
}> {
return this.checkoutService.getPriceIds(countryCode, pricingMode);
return this.checkoutService.getPriceIds(countryCode);
}
async getCurrentSubscriptionPrice(userId: UserID): Promise<CurrentSubscriptionPriceResponse> {
@@ -13,7 +13,6 @@ import type {
PremiumPricingState,
PremiumStateResponse,
PriceIdsResponse,
PricingMode,
SelfServeRefundEligibilityResponse,
SelfServeRefundIneligibilityReason,
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
@@ -33,13 +32,7 @@ import type {User} from '../../models/User';
import type {IUserRepository} from '../../user/IUserRepository';
import {checkHasActivePaidPremium} from '../../user/UserHelpers';
import {mapUserToPrivateResponse} from '../../user/UserMappers';
import {
type Currency,
getBaseCurrencyPreferences,
getBaseGiftCurrencyPreferences,
getCurrencyPreferences,
getGiftCurrencyPreferences,
} from '../../utils/CurrencyUtils';
import {type Currency, getCurrencyPreferences, getGiftCurrencyPreferences} from '../../utils/CurrencyUtils';
import type {RecurringBillingCycle} from '../ProductRegistry';
import {ProductRegistry} from '../ProductRegistry';
import {getPrimarySubscriptionItem} from '../StripeSubscriptionPeriod';
@@ -111,8 +104,11 @@ function normalizeCurrency(value: string | null | undefined): Currency | null {
currency === 'USD' ||
currency === 'EUR' ||
currency === 'BRL' ||
currency === 'DKK' ||
currency === 'INR' ||
currency === 'NOK' ||
currency === 'PLN' ||
currency === 'SEK' ||
currency === 'TRY'
) {
return currency;
@@ -743,22 +739,15 @@ export class PremiumStateService {
private async resolvePricing(countryCode: string | null | undefined): Promise<PremiumPricingState> {
const normalizedCountryCode = normalizeCountryCode(countryCode);
const [localized, base] = await Promise.all([
this.resolvePriceIds(normalizedCountryCode, 'localized'),
this.resolvePriceIds(normalizedCountryCode, 'base'),
]);
const localized = await this.resolvePriceIds(normalizedCountryCode);
return {
country_code: normalizedCountryCode,
localized,
base,
};
}
private async resolvePriceIds(
countryCode: string | null,
pricingMode: PricingMode,
): Promise<PriceIdsResponse | null> {
const resolved = this.resolveConfiguredPriceIds(countryCode, pricingMode);
private async resolvePriceIds(countryCode: string | null): Promise<PriceIdsResponse | null> {
const resolved = this.resolveConfiguredPriceIds(countryCode);
if (!resolved) return null;
const [monthlyPrice, yearlyPrice, gift1MonthPrice, gift1YearPrice] = await Promise.all([
resolved.monthly ? this.billingRepository.prices.findById(resolved.monthly) : null,
@@ -775,11 +764,9 @@ export class PremiumStateService {
};
}
private resolveConfiguredPriceIds(countryCode: string | null, pricingMode: PricingMode): ResolvedPriceIds | null {
const recurringCurrencyPreferences =
pricingMode === 'base' ? getBaseCurrencyPreferences(countryCode) : getCurrencyPreferences(countryCode);
const giftCurrencyPreferences =
pricingMode === 'base' ? getBaseGiftCurrencyPreferences(countryCode) : getGiftCurrencyPreferences(countryCode);
private resolveConfiguredPriceIds(countryCode: string | null): ResolvedPriceIds | null {
const recurringCurrencyPreferences = getCurrencyPreferences(countryCode);
const giftCurrencyPreferences = getGiftCurrencyPreferences(countryCode);
const recurringPrices = this.resolveRecurringPriceIds(recurringCurrencyPreferences);
const giftPrices = this.resolveGiftPriceIds(giftCurrencyPreferences);
if (!recurringPrices || !giftPrices) return null;
@@ -13,7 +13,6 @@ import {StripePaymentNotAvailableError} from '@fluxer/errors/src/domains/payment
import {UnclaimedAccountCannotMakePurchasesError} from '@fluxer/errors/src/domains/user/UnclaimedAccountCannotMakePurchasesError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {CheckoutPaymentMethod} from '@fluxer/schema/src/domains/premium/GiftCodeSchemas';
import type {PricingMode} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import {seconds} from 'itty-time';
import type Stripe from 'stripe';
@@ -25,13 +24,7 @@ import {Logger} from '../../Logger';
import {getBillingRepository} from '../../middleware/ServiceRegistry';
import type {User} from '../../models/User';
import type {IUserRepository} from '../../user/IUserRepository';
import {
type Currency,
getBaseCurrencyPreferences,
getBaseGiftCurrencyPreferences,
getCurrencyPreferences,
getGiftCurrencyPreferences,
} from '../../utils/CurrencyUtils';
import {type Currency, getCurrencyPreferences, getGiftCurrencyPreferences} from '../../utils/CurrencyUtils';
import type {ProductInfo, ProductRegistry} from '../ProductRegistry';
import {
canProvisionPremiumFromSubscriptionStatus,
@@ -86,7 +79,6 @@ export interface CreateCheckoutSessionParams {
clientGeoipCountryCode?: string | null;
purchaseGeoipCountryCode?: string | null;
euWithdrawalWaiverAccepted?: boolean;
pricingMode?: PricingMode;
paymentMethod?: CheckoutPaymentMethod;
isBusiness?: boolean;
}
@@ -182,7 +174,6 @@ export class StripeCheckoutService {
clientGeoipCountryCode,
purchaseGeoipCountryCode,
euWithdrawalWaiverAccepted,
pricingMode = 'localized',
paymentMethod = 'card',
isBusiness = false,
}: CreateCheckoutSessionParams): Promise<string> {
@@ -191,7 +182,7 @@ export class StripeCheckoutService {
priceId,
isGift,
countryCode,
pricingMode,
purchaseGeoipCountryCode,
});
const isRecurringSubscription = this.productRegistry.isRecurringSubscription(productInfo);
const checkoutMode: CheckoutSessionMode = isRecurringSubscription ? 'subscription' : 'payment';
@@ -221,7 +212,6 @@ export class StripeCheckoutService {
eu_withdrawal_waiver_accepted: waiverContext.accepted ? 'true' : 'false',
...(waiverContext.acceptedAt ? {eu_withdrawal_waiver_accepted_at: waiverContext.acceptedAt.toISOString()} : {}),
eu_withdrawal_waiver_text_version: EU_WITHDRAWAL_WAIVER_TEXT_VERSION,
pricing_mode: pricingMode,
payment_method: paymentMethod,
};
const checkoutParams: CheckoutSessionCreateParams = {
@@ -301,7 +291,6 @@ export class StripeCheckoutService {
clientGeoipCountryCode,
purchaseGeoipCountryCode,
euWithdrawalWaiverAccepted,
pricingMode = 'localized',
isBusiness = false,
}: Pick<
CreateCheckoutSessionParams,
@@ -310,31 +299,22 @@ export class StripeCheckoutService {
| 'euWithdrawalWaiverAccepted'
| 'isBusiness'
| 'priceId'
| 'pricingMode'
| 'purchaseGeoipCountryCode'
| 'userId'
>): Promise<string> {
if (!this.stripe) {
throw new StripePaymentNotAvailableError();
}
const normalizedCountryCode = countryCode?.trim().toUpperCase();
const normalizedCountryCode = this.resolveEnforcedPricingCountryCode({countryCode, purchaseGeoipCountryCode});
if (!normalizedCountryCode) {
Logger.error({priceId, userId}, 'Localized card preapproval requires a country code');
throw new StripeInvalidProductConfigurationError();
}
if (pricingMode !== 'localized') {
Logger.error(
{priceId, userId, pricingMode},
'Localized card preapproval requested for non-localized pricing mode',
);
throw new StripeInvalidProductConfigurationError();
}
const {customerId, productInfo} = await this.prepareCheckoutContext({
userId,
priceId,
isGift: false,
countryCode: normalizedCountryCode,
pricingMode,
});
if (!this.requiresLocalizedCardPreapproval(productInfo)) {
Logger.error(
@@ -364,7 +344,6 @@ export class StripeCheckoutService {
eu_withdrawal_waiver_accepted: waiverContext.accepted ? 'true' : 'false',
...(waiverContext.acceptedAt ? {eu_withdrawal_waiver_accepted_at: waiverContext.acceptedAt.toISOString()} : {}),
...(waiverContext.required ? {eu_withdrawal_waiver_text_version: EU_WITHDRAWAL_WAIVER_TEXT_VERSION} : {}),
pricing_mode: pricingMode,
setup_type: 'localized_card_preapproval',
localized_card_preapproval_currency: productInfo.currency,
localized_card_preapproval_token: token,
@@ -560,7 +539,7 @@ export class StripeCheckoutService {
priceId,
isGift = false,
countryCode,
pricingMode = 'localized',
purchaseGeoipCountryCode,
}: CreateCheckoutSessionParams): Promise<{
customerId: string;
productInfo: ProductInfo;
@@ -581,12 +560,13 @@ export class StripeCheckoutService {
);
throw new StripeInvalidProductConfigurationError();
}
if (this.requiresCountryCodeForLocalizedCurrency(productInfo.currency) && !countryCode) {
const enforcedCountryCode = this.resolveEnforcedPricingCountryCode({countryCode, purchaseGeoipCountryCode});
if (this.requiresCountryCodeForLocalizedCurrency(productInfo.currency) && !enforcedCountryCode) {
Logger.error({priceId, userId, currency: productInfo.currency}, 'Localized price requested without country code');
throw new StripeInvalidProductConfigurationError();
}
if (countryCode) {
this.assertPriceMatchesCountryCatalog({countryCode, priceId, isGift, pricingMode, userId});
if (enforcedCountryCode) {
this.assertPriceMatchesCountryCatalog({countryCode: enforcedCountryCode, priceId, isGift, userId});
}
const user = await this.userRepository.findUnique(userId);
if (!user) {
@@ -703,20 +683,25 @@ export class StripeCheckoutService {
return normalized && /^[A-Z]{2}$/.test(normalized) ? normalized : null;
}
private resolveEnforcedPricingCountryCode({
countryCode,
purchaseGeoipCountryCode,
}: Pick<CreateCheckoutSessionParams, 'countryCode' | 'purchaseGeoipCountryCode'>): string | null {
return this.normalizeCountryCode(purchaseGeoipCountryCode) ?? this.normalizeCountryCode(countryCode);
}
private assertPriceMatchesCountryCatalog({
countryCode,
priceId,
isGift,
pricingMode = 'localized',
userId,
}: {
countryCode: string;
priceId: string;
isGift: boolean;
pricingMode?: PricingMode;
userId: UserID;
}): void {
const localizedPrices = this.resolveConfiguredPriceIds(countryCode, pricingMode);
const localizedPrices = this.resolveConfiguredPriceIds(countryCode);
const allowedPriceIds = new Set(
(isGift
? [localizedPrices.gift_1_month, localizedPrices.gift_1_year]
@@ -731,7 +716,6 @@ export class StripeCheckoutService {
userId,
currency: isGift ? localizedPrices.gift_currency : localizedPrices.currency,
isGift,
pricingMode,
},
'Checkout price mismatch for country',
);
@@ -1022,8 +1006,8 @@ export class StripeCheckoutService {
}
}
async getPriceIds(countryCode?: string, pricingMode: PricingMode = 'localized'): Promise<PriceIdsResponse> {
const resolvedPrices = this.resolveConfiguredPriceIds(countryCode, pricingMode);
async getPriceIds(countryCode?: string): Promise<PriceIdsResponse> {
const resolvedPrices = this.resolveConfiguredPriceIds(countryCode);
const [monthlyPrice, yearlyPrice, gift1MonthPrice, gift1YearPrice] = await Promise.all([
this.getStripePriceSummary(resolvedPrices.monthly),
this.getStripePriceSummary(resolvedPrices.yearly),
@@ -1057,11 +1041,9 @@ export class StripeCheckoutService {
private static readonly PRICE_CACHE_TTL_SECONDS = seconds('1 hour');
private static readonly PRICE_CACHE_PRODUCE_TIMEOUT_MS = 90000;
private resolveConfiguredPriceIds(countryCode?: string, pricingMode: PricingMode = 'localized'): ResolvedPriceIds {
const recurringCurrencyPreferences =
pricingMode === 'base' ? getBaseCurrencyPreferences(countryCode) : getCurrencyPreferences(countryCode);
const giftCurrencyPreferences =
pricingMode === 'base' ? getBaseGiftCurrencyPreferences(countryCode) : getGiftCurrencyPreferences(countryCode);
private resolveConfiguredPriceIds(countryCode?: string): ResolvedPriceIds {
const recurringCurrencyPreferences = getCurrencyPreferences(countryCode);
const giftCurrencyPreferences = getGiftCurrencyPreferences(countryCode);
const recurringPrices = this.resolveRecurringPriceIds(recurringCurrencyPreferences);
const giftPrices = this.resolveGiftPriceIds(giftCurrencyPreferences);
return {
@@ -1126,6 +1108,15 @@ export class StripeCheckoutService {
yearly: prices.yearlyBrl,
currency,
};
case 'DKK':
if (!prices.monthlyDkk || !prices.yearlyDkk) {
return null;
}
return {
monthly: prices.monthlyDkk,
yearly: prices.yearlyDkk,
currency,
};
case 'INR':
if (!prices.monthlyInr || !prices.yearlyInr) {
return null;
@@ -1135,6 +1126,15 @@ export class StripeCheckoutService {
yearly: prices.yearlyInr,
currency,
};
case 'NOK':
if (!prices.monthlyNok || !prices.yearlyNok) {
return null;
}
return {
monthly: prices.monthlyNok,
yearly: prices.yearlyNok,
currency,
};
case 'PLN':
if (!prices.monthlyPln || !prices.yearlyPln) {
return null;
@@ -1144,6 +1144,15 @@ export class StripeCheckoutService {
yearly: prices.yearlyPln,
currency,
};
case 'SEK':
if (!prices.monthlySek || !prices.yearlySek) {
return null;
}
return {
monthly: prices.monthlySek,
yearly: prices.yearlySek,
currency,
};
case 'TRY':
if (!prices.monthlyTry || !prices.yearlyTry) {
return null;
@@ -1193,6 +1202,33 @@ export class StripeCheckoutService {
gift_1_year: prices.gift1YearInr,
gift_currency: 'INR',
};
case 'DKK':
if (!prices.gift1MonthDkk || !prices.gift1YearDkk) {
return null;
}
return {
gift_1_month: prices.gift1MonthDkk,
gift_1_year: prices.gift1YearDkk,
gift_currency: 'DKK',
};
case 'NOK':
if (!prices.gift1MonthNok || !prices.gift1YearNok) {
return null;
}
return {
gift_1_month: prices.gift1MonthNok,
gift_1_year: prices.gift1YearNok,
gift_currency: 'NOK',
};
case 'SEK':
if (!prices.gift1MonthSek || !prices.gift1YearSek) {
return null;
}
return {
gift_1_month: prices.gift1MonthSek,
gift_1_year: prices.gift1YearSek,
gift_currency: 'SEK',
};
case 'PLN':
if (!prices.gift1MonthPln || !prices.gift1YearPln) {
return null;
@@ -0,0 +1,95 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {PremiumStateResponse} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
import Stripe from 'stripe';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {Config} from '../../Config';
import {getBillingRepository} from '../../middleware/ServiceRegistry';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {createStripeApiHandlers, type StripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
import {server} from '../../test/msw/server';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder';
import {STRIPE_API_VERSION} from '../StripeApiVersion';
const MOCK_PRICES = {
monthlyUsd: 'price_state_pricing_monthly_usd',
yearlyUsd: 'price_state_pricing_yearly_usd',
monthlyBrl: 'price_state_pricing_monthly_brl',
yearlyBrl: 'price_state_pricing_yearly_brl',
gift1MonthUsd: 'price_state_pricing_gift_1_month_usd',
gift1YearUsd: 'price_state_pricing_gift_1_year_usd',
gift1MonthBrl: 'price_state_pricing_gift_1_month_brl',
gift1YearBrl: 'price_state_pricing_gift_1_year_brl',
};
const MOCK_PRICE_SEEDS = {
[MOCK_PRICES.monthlyUsd]: {unit_amount: 499, currency: 'usd', interval: 'month' as const},
[MOCK_PRICES.yearlyUsd]: {unit_amount: 4999, currency: 'usd', interval: 'year' as const},
[MOCK_PRICES.monthlyBrl]: {unit_amount: 1890, currency: 'brl', interval: 'month' as const},
[MOCK_PRICES.yearlyBrl]: {unit_amount: 18900, currency: 'brl', interval: 'year' as const},
[MOCK_PRICES.gift1MonthUsd]: {unit_amount: 499, currency: 'usd', interval: 'month' as const},
[MOCK_PRICES.gift1YearUsd]: {unit_amount: 4999, currency: 'usd', interval: 'year' as const},
[MOCK_PRICES.gift1MonthBrl]: {unit_amount: 1890, currency: 'brl', interval: 'month' as const},
[MOCK_PRICES.gift1YearBrl]: {unit_amount: 18900, currency: 'brl', interval: 'year' as const},
};
describe('PremiumStatePricing', () => {
let harness: ApiTestHarness;
let stripeHandlers: StripeApiHandlers;
let originalPrices: typeof Config.stripe.prices | undefined;
async function mirrorPrices(): Promise<void> {
const stripe = new Stripe(Config.stripe.secretKey ?? 'sk_test_fluxer', {
apiVersion: STRIPE_API_VERSION,
httpClient: Stripe.createFetchHttpClient(),
});
for (const priceId of Object.keys(MOCK_PRICE_SEEDS)) {
const price = await stripe.prices.retrieve(priceId);
await getBillingRepository().prices.upsertFromStripe(price);
}
}
beforeAll(async () => {
originalPrices = Config.stripe.prices;
Config.stripe.prices = MOCK_PRICES;
harness = await createApiTestHarness();
});
afterAll(async () => {
await harness.shutdown();
Config.stripe.prices = originalPrices;
});
beforeEach(async () => {
await harness.resetData();
Config.stripe.prices = MOCK_PRICES;
stripeHandlers = createStripeApiHandlers({prices: MOCK_PRICE_SEEDS, subscriptionsListEmpty: true});
server.use(...stripeHandlers.handlers);
await mirrorPrices();
});
test('resolves the localized BRL catalog for a Brazilian request', async () => {
const account = await createTestAccount(harness);
const state = await createBuilder<PremiumStateResponse>(harness, account.token)
.get('/premium/state?country_code=BR')
.expect(HTTP_STATUS.OK)
.execute();
expect(state.pricing.country_code).toBe('BR');
expect(state.pricing.localized?.currency).toBe('BRL');
expect(state.pricing.localized?.monthly_amount_minor).toBe(1890);
expect(state.pricing.localized?.yearly_amount_minor).toBe(18900);
expect(state.pricing.localized?.monthly).toBe(MOCK_PRICES.monthlyBrl);
expect(state.pricing.localized?.yearly).toBe(MOCK_PRICES.yearlyBrl);
});
test('resolves the USD catalog when the request declares no country', async () => {
const account = await createTestAccount(harness);
const state = await createBuilder<PremiumStateResponse>(harness, account.token)
.get('/premium/state')
.expect(HTTP_STATUS.OK)
.execute();
expect(state.pricing.country_code).toBeNull();
expect(state.pricing.localized?.currency).toBe('USD');
expect(state.pricing.localized?.monthly_amount_minor).toBe(499);
});
});
@@ -119,16 +119,22 @@ describe('ProductRegistry - legacy prices', () => {
monthly_usd: ['legacy_usd'],
monthly_eur: ['legacy_eur'],
monthly_brl: ['legacy_brl'],
monthly_dkk: ['legacy_dkk'],
monthly_inr: ['legacy_inr'],
monthly_nok: ['legacy_nok'],
monthly_pln: ['legacy_pln'],
monthly_sek: ['legacy_sek'],
monthly_try: ['legacy_try'],
},
);
expect(registry.getProduct('legacy_usd')?.currency).toBe('USD');
expect(registry.getProduct('legacy_eur')?.currency).toBe('EUR');
expect(registry.getProduct('legacy_brl')?.currency).toBe('BRL');
expect(registry.getProduct('legacy_dkk')?.currency).toBe('DKK');
expect(registry.getProduct('legacy_inr')?.currency).toBe('INR');
expect(registry.getProduct('legacy_nok')?.currency).toBe('NOK');
expect(registry.getProduct('legacy_pln')?.currency).toBe('PLN');
expect(registry.getProduct('legacy_sek')?.currency).toBe('SEK');
expect(registry.getProduct('legacy_try')?.currency).toBe('TRY');
});
@@ -205,7 +211,7 @@ describe('ProductRegistry - legacy prices', () => {
test('no legacy price id is ever returned by either price getter, for any cycle or currency', () => {
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
const legacyIds = new Set(Object.values(MOCK_LEGACY_PRICES).flatMap((ids) => ids ?? []));
const currencies = ['USD', 'EUR', 'BRL', 'INR', 'PLN', 'TRY'];
const currencies = ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'NOK', 'PLN', 'SEK', 'TRY'];
const offered: Array<string> = [];
for (const currency of currencies) {
@@ -0,0 +1,170 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {GeoipResult} from '@pkgs/geoip/src/GeoipLookup';
import {afterAll, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {Config} from '../../Config';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {createStripeApiHandlers, type StripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
import {server} from '../../test/msw/server';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder';
const {lookupGeoipMock} = vi.hoisted(() => ({
lookupGeoipMock: vi.fn(),
}));
vi.mock('../../utils/IpUtils', async (importOriginal) => ({
...(await importOriginal<typeof import('../../utils/IpUtils')>()),
lookupGeoip: lookupGeoipMock,
}));
const MOCK_PRICES = {
monthlyUsd: 'price_enforce_monthly_usd',
yearlyUsd: 'price_enforce_yearly_usd',
monthlyBrl: 'price_enforce_monthly_brl',
yearlyBrl: 'price_enforce_yearly_brl',
gift1MonthUsd: 'price_enforce_gift_1_month_usd',
gift1YearUsd: 'price_enforce_gift_1_year_usd',
gift1MonthBrl: 'price_enforce_gift_1_month_brl',
gift1YearBrl: 'price_enforce_gift_1_year_brl',
};
const MOCK_PRICE_SEEDS = {
[MOCK_PRICES.monthlyUsd]: {unit_amount: 499, currency: 'usd', interval: 'month' as const},
[MOCK_PRICES.yearlyUsd]: {unit_amount: 4999, currency: 'usd', interval: 'year' as const},
[MOCK_PRICES.monthlyBrl]: {unit_amount: 1890, currency: 'brl', interval: 'month' as const},
[MOCK_PRICES.yearlyBrl]: {unit_amount: 18900, currency: 'brl', interval: 'year' as const},
};
function geoipCountry(countryCode: string | null): GeoipResult {
return {
countryCode,
normalizedIp: '203.0.113.10',
city: null,
region: null,
countryName: null,
};
}
describe('StripeCheckoutCountryEnforcement', () => {
let harness: ApiTestHarness;
let stripeHandlers: StripeApiHandlers;
let originalPrices: typeof Config.stripe.prices | undefined;
async function createPurchaser(): Promise<string> {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/security-flags`)
.body({email_verified: true})
.execute();
return account.token;
}
beforeAll(async () => {
originalPrices = Config.stripe.prices;
Config.stripe.prices = MOCK_PRICES;
harness = await createApiTestHarness();
});
afterAll(async () => {
await harness.shutdown();
Config.stripe.prices = originalPrices;
});
beforeEach(() => {
Config.stripe.prices = MOCK_PRICES;
lookupGeoipMock.mockReset();
lookupGeoipMock.mockResolvedValue(geoipCountry(null));
stripeHandlers = createStripeApiHandlers({prices: MOCK_PRICE_SEEDS, subscriptionsListEmpty: true});
server.use(...stripeHandlers.handlers);
});
test('rejects a base-currency subscription price when the request geolocates to a localized market', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyUsd, country_code: 'US'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects a base-currency subscription price when country_code is omitted entirely', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyUsd})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects a localized gift price when the request geolocates outside that market', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('US'));
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/gift')
.body({price_id: MOCK_PRICES.gift1MonthBrl, country_code: 'BR'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects a localized gift price even from inside that market', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/gift')
.body({price_id: MOCK_PRICES.gift1MonthBrl, country_code: 'BR'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('accepts the base gift price from inside a localized market', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
const token = await createPurchaser();
const response = await createBuilder<{url: string}>(harness, token)
.post('/stripe/checkout/gift')
.body({price_id: MOCK_PRICES.gift1MonthUsd, country_code: 'BR'})
.expect(HTTP_STATUS.OK)
.execute();
expect(response.url).toContain('checkout.stripe.com');
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
});
test('accepts the localized price for a request that geolocates to that market', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
const token = await createPurchaser();
const response = await createBuilder<{url: string}>(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyBrl, country_code: 'BR'})
.expect(HTTP_STATUS.OK)
.execute();
expect(response.url).toContain('checkout.stripe.com');
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
});
test('falls back to the declared country when the request cannot be geolocated', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry(null));
const token = await createPurchaser();
const response = await createBuilder<{url: string}>(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyBrl, country_code: 'BR'})
.expect(HTTP_STATUS.OK)
.execute();
expect(response.url).toContain('checkout.stripe.com');
});
test('resolves price ids from the request geolocation rather than the query parameter', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
const priceIds = await createBuilder<{currency: string; monthly: string | null}>(harness, '')
.get('/premium/price-ids?country_code=US')
.expect(HTTP_STATUS.OK)
.execute();
expect(priceIds.currency).toBe('BRL');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyBrl);
});
});
@@ -0,0 +1,288 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {
CurrentSubscriptionPriceResponse,
PriceIdsResponse,
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
import type {GeoipResult} from '@pkgs/geoip/src/GeoipLookup';
import {afterAll, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {Config} from '../../Config';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {createStripeApiHandlers, type StripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
import {server} from '../../test/msw/server';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder';
const {lookupGeoipMock} = vi.hoisted(() => ({
lookupGeoipMock: vi.fn(),
}));
vi.mock('../../utils/IpUtils', async (importOriginal) => ({
...(await importOriginal<typeof import('../../utils/IpUtils')>()),
lookupGeoip: lookupGeoipMock,
}));
const MOCK_PRICES = {
monthlyUsd: 'price_nordic_monthly_usd',
yearlyUsd: 'price_nordic_yearly_usd',
monthlyEur: 'price_nordic_monthly_eur',
yearlyEur: 'price_nordic_yearly_eur',
monthlyDkk: 'price_nordic_monthly_dkk',
yearlyDkk: 'price_nordic_yearly_dkk',
monthlyNok: 'price_nordic_monthly_nok',
yearlyNok: 'price_nordic_yearly_nok',
monthlyPln: 'price_nordic_monthly_pln',
yearlyPln: 'price_nordic_yearly_pln',
monthlySek: 'price_nordic_monthly_sek',
yearlySek: 'price_nordic_yearly_sek',
gift1MonthUsd: 'price_nordic_gift_1_month_usd',
gift1YearUsd: 'price_nordic_gift_1_year_usd',
gift1MonthEur: 'price_nordic_gift_1_month_eur',
gift1YearEur: 'price_nordic_gift_1_year_eur',
};
const MOCK_PRICE_SEEDS = {
[MOCK_PRICES.monthlyUsd]: {unit_amount: 499, currency: 'usd', interval: 'month' as const},
[MOCK_PRICES.yearlyUsd]: {unit_amount: 4999, currency: 'usd', interval: 'year' as const},
[MOCK_PRICES.monthlyEur]: {unit_amount: 499, currency: 'eur', interval: 'month' as const},
[MOCK_PRICES.yearlyEur]: {unit_amount: 4999, currency: 'eur', interval: 'year' as const},
[MOCK_PRICES.monthlyDkk]: {unit_amount: 3700, currency: 'dkk', interval: 'month' as const},
[MOCK_PRICES.yearlyDkk]: {unit_amount: 37000, currency: 'dkk', interval: 'year' as const},
[MOCK_PRICES.monthlyNok]: {unit_amount: 5400, currency: 'nok', interval: 'month' as const},
[MOCK_PRICES.yearlyNok]: {unit_amount: 54000, currency: 'nok', interval: 'year' as const},
[MOCK_PRICES.monthlyPln]: {unit_amount: 1900, currency: 'pln', interval: 'month' as const},
[MOCK_PRICES.yearlyPln]: {unit_amount: 19000, currency: 'pln', interval: 'year' as const},
[MOCK_PRICES.monthlySek]: {unit_amount: 5400, currency: 'sek', interval: 'month' as const},
[MOCK_PRICES.yearlySek]: {unit_amount: 54000, currency: 'sek', interval: 'year' as const},
};
const EUR_SUBSCRIPTION_ID = 'sub_nordic_legacy_eur';
function geoipCountry(countryCode: string | null): GeoipResult {
return {
countryCode,
normalizedIp: '203.0.113.10',
city: null,
region: null,
countryName: null,
};
}
describe('Nordic localized currencies', () => {
let harness: ApiTestHarness;
let stripeHandlers: StripeApiHandlers;
let originalPrices: typeof Config.stripe.prices | undefined;
async function createPurchaser(): Promise<string> {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/security-flags`)
.body({email_verified: true})
.execute();
return account.token;
}
function getPriceIds(countryCode: string | null): Promise<PriceIdsResponse> {
lookupGeoipMock.mockResolvedValue(geoipCountry(countryCode));
return createBuilder<PriceIdsResponse>(harness, '').get('/premium/price-ids').expect(HTTP_STATUS.OK).execute();
}
beforeAll(async () => {
originalPrices = Config.stripe.prices;
Config.stripe.prices = MOCK_PRICES;
harness = await createApiTestHarness();
});
afterAll(async () => {
await harness.shutdown();
Config.stripe.prices = originalPrices;
});
beforeEach(() => {
Config.stripe.prices = MOCK_PRICES;
lookupGeoipMock.mockReset();
lookupGeoipMock.mockResolvedValue(geoipCountry(null));
stripeHandlers = createStripeApiHandlers({
prices: MOCK_PRICE_SEEDS,
subscriptionsListEmpty: true,
subscriptions: {
[EUR_SUBSCRIPTION_ID]: {
customer: `cus_${EUR_SUBSCRIPTION_ID}`,
price_id: MOCK_PRICES.monthlyEur,
unit_amount: 499,
currency: 'eur',
interval: 'month',
item_id: `si_${EUR_SUBSCRIPTION_ID}`,
},
},
});
server.use(...stripeHandlers.handlers);
});
describe('country routing', () => {
test('Sweden resolves to the SEK subscription catalog', async () => {
const priceIds = await getPriceIds('SE');
expect(priceIds.currency).toBe('SEK');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlySek);
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlySek);
});
test('Denmark resolves to the DKK subscription catalog', async () => {
const priceIds = await getPriceIds('DK');
expect(priceIds.currency).toBe('DKK');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyDkk);
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyDkk);
});
test('Norway resolves to the NOK subscription catalog', async () => {
const priceIds = await getPriceIds('NO');
expect(priceIds.currency).toBe('NOK');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyNok);
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyNok);
});
test('Poland still resolves to the PLN subscription catalog', async () => {
const priceIds = await getPriceIds('PL');
expect(priceIds.currency).toBe('PLN');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyPln);
});
test.each(['DE', 'NL', 'FI', 'IE', 'IS'])('%s still resolves to the EUR subscription catalog', async (country) => {
const priceIds = await getPriceIds(country);
expect(priceIds.currency).toBe('EUR');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyEur);
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyEur);
});
test.each(['SE', 'DK', 'NO'])('%s keeps gifts on the base currency', async (country) => {
const priceIds = await getPriceIds(country);
expect(priceIds.gift_currency).toBe('EUR');
expect(priceIds.gift_1_month).toBe(MOCK_PRICES.gift1MonthEur);
expect(priceIds.gift_1_year).toBe(MOCK_PRICES.gift1YearEur);
});
test('a Nordic gift falls through to USD when no EUR gift price is configured', async () => {
Config.stripe.prices = {
...MOCK_PRICES,
gift1MonthEur: undefined,
gift1YearEur: undefined,
};
const priceIds = await getPriceIds('SE');
expect(priceIds.currency).toBe('SEK');
expect(priceIds.gift_currency).toBe('USD');
expect(priceIds.gift_1_month).toBe(MOCK_PRICES.gift1MonthUsd);
});
test('Sweden falls back to EUR while the SEK prices are still unconfigured', async () => {
Config.stripe.prices = {
...MOCK_PRICES,
monthlySek: undefined,
yearlySek: undefined,
};
const priceIds = await getPriceIds('SE');
expect(priceIds.currency).toBe('EUR');
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyEur);
expect(priceIds.gift_currency).toBe('EUR');
});
});
describe('checkout enforcement', () => {
test('rejects the EUR subscription price for a purchase that geolocates to Sweden', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyEur, country_code: 'SE'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects the USD subscription price for a purchase that geolocates to Sweden', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyUsd, country_code: 'US'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('rejects a neighbouring Nordic price for a purchase that geolocates to Sweden', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlyDkk, country_code: 'DK'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
test('accepts the SEK subscription price for a purchase that geolocates to Sweden', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
const token = await createPurchaser();
const response = await createBuilder<{url: string}>(harness, token)
.post('/stripe/checkout/subscription')
.body({price_id: MOCK_PRICES.monthlySek, country_code: 'SE'})
.expect(HTTP_STATUS.OK)
.execute();
expect(response.url).toContain('checkout.stripe.com');
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
});
test('accepts the base gift price from inside Sweden', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
const token = await createPurchaser();
const response = await createBuilder<{url: string}>(harness, token)
.post('/stripe/checkout/gift')
.body({price_id: MOCK_PRICES.gift1MonthEur, country_code: 'SE'})
.expect(HTTP_STATUS.OK)
.execute();
expect(response.url).toContain('checkout.stripe.com');
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
});
test('rejects the SEK subscription price on the gift endpoint', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
const token = await createPurchaser();
await createBuilder(harness, token)
.post('/stripe/checkout/gift')
.body({price_id: MOCK_PRICES.monthlySek, country_code: 'SE'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
.execute();
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
});
});
describe('existing subscribers', () => {
test('a Swedish customer billed in EUR keeps the EUR price and is not repriced to SEK', async () => {
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/premium`)
.body({
stripe_subscription_id: EUR_SUBSCRIPTION_ID,
premium_type: 1,
premium_billing_cycle: 'monthly',
premium_until: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
premium_will_cancel: false,
})
.execute();
const price = await createBuilder<CurrentSubscriptionPriceResponse>(harness, account.token)
.get('/premium/current-subscription-price')
.expect(HTTP_STATUS.OK)
.execute();
expect(price).toMatchObject({
price_id: MOCK_PRICES.monthlyEur,
currency: 'EUR',
billing_cycle: 'monthly',
is_grandfathered: false,
list_price_id: MOCK_PRICES.monthlyEur,
});
expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(0);
expect(stripeHandlers.spies.createdSubscriptionSchedules).toHaveLength(0);
});
});
});
@@ -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;
@@ -353,6 +353,16 @@ interface MockStripeSubscriptionSchedule {
}>;
}
const PRICE_ID_CURRENCY_MARKERS = ['eur', 'brl', 'dkk', 'inr', 'nok', 'pln', 'sek', 'try'] as const;
function inferPriceIdCurrency(normalizedPriceId: string): string {
return PRICE_ID_CURRENCY_MARKERS.find((marker) => normalizedPriceId.includes(marker)) ?? 'usd';
}
function inferPriceIdInterval(normalizedPriceId: string): 'month' | 'year' {
return normalizedPriceId.includes('year') ? 'year' : 'month';
}
function parseFormDataToObject<T extends object = Record<string, unknown>>(formData: FormData): T {
const result: Record<string, unknown> = {};
const isArrayIndex = (segment: string): boolean => /^\d+$/.test(segment);
@@ -797,18 +807,8 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
function inferSubscriptionPriceState(priceId: string): Pick<MockStripeSubscriptionState, 'currency' | 'interval'> {
const normalizedPriceId = priceId.toLowerCase();
return {
currency: normalizedPriceId.includes('eur')
? 'eur'
: normalizedPriceId.includes('brl')
? 'brl'
: normalizedPriceId.includes('inr')
? 'inr'
: normalizedPriceId.includes('pln')
? 'pln'
: normalizedPriceId.includes('try')
? 'try'
: 'usd',
interval: normalizedPriceId.includes('year') ? 'year' : 'month',
currency: inferPriceIdCurrency(normalizedPriceId),
interval: inferPriceIdInterval(normalizedPriceId),
};
}
function createDefaultSubscriptionState(): MockStripeSubscriptionState {
@@ -1718,23 +1718,11 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
id,
object: 'price',
active: true,
currency:
overrides?.currency ??
(normalizedPriceId.includes('eur')
? 'eur'
: normalizedPriceId.includes('brl')
? 'brl'
: normalizedPriceId.includes('inr')
? 'inr'
: normalizedPriceId.includes('pln')
? 'pln'
: normalizedPriceId.includes('try')
? 'try'
: 'usd'),
unit_amount: overrides?.unit_amount ?? (normalizedPriceId.includes('year') ? 4999 : 499),
currency: overrides?.currency ?? inferPriceIdCurrency(normalizedPriceId),
unit_amount: overrides?.unit_amount ?? (inferPriceIdInterval(normalizedPriceId) === 'year' ? 4999 : 499),
type: 'recurring',
recurring: {
interval: overrides?.interval ?? (normalizedPriceId.includes('year') ? 'year' : 'month'),
interval: overrides?.interval ?? inferPriceIdInterval(normalizedPriceId),
interval_count: 1,
},
product: overrides?.product ?? 'prod_test_1',
+14 -16
View File
@@ -2,7 +2,7 @@
import {isEuEeaCountryCode} from '@fluxer/constants/src/EuropeanEconomicArea';
export type Currency = 'USD' | 'EUR' | 'BRL' | 'INR' | 'PLN' | 'TRY';
export type Currency = 'USD' | 'EUR' | 'BRL' | 'DKK' | 'INR' | 'NOK' | 'PLN' | 'SEK' | 'TRY';
export function getCurrency(countryCode: string | null | undefined): Currency {
return getCurrencyPreferences(countryCode)[0];
@@ -16,12 +16,21 @@ export function getCurrencyPreferences(countryCode: string | null | undefined):
if (upperCode === 'BR') {
return ['BRL', 'USD', 'EUR'];
}
if (upperCode === 'DK') {
return ['DKK', 'EUR', 'USD'];
}
if (upperCode === 'IN') {
return ['INR', 'USD', 'EUR'];
}
if (upperCode === 'NO') {
return ['NOK', 'EUR', 'USD'];
}
if (upperCode === 'PL') {
return ['PLN', 'EUR', 'USD'];
}
if (upperCode === 'SE') {
return ['SEK', 'EUR', 'USD'];
}
if (upperCode === 'TR') {
return ['TRY', 'USD', 'EUR'];
}
@@ -31,21 +40,10 @@ export function getCurrencyPreferences(countryCode: string | null | undefined):
return ['USD', 'EUR'];
}
export function getBaseCurrencyPreferences(countryCode: string | null | undefined): Array<Currency> {
if (!countryCode) {
return ['USD', 'EUR'];
}
const upperCode = countryCode.toUpperCase();
if (isEuEeaCountryCode(upperCode)) {
return ['EUR', 'USD'];
}
return ['USD', 'EUR'];
}
const GIFT_ELIGIBLE_LOCALIZED_CURRENCIES = new Set<Currency>(['DKK', 'NOK', 'SEK']);
export function getGiftCurrencyPreferences(countryCode: string | null | undefined): Array<Currency> {
return getCurrencyPreferences(countryCode);
}
export function getBaseGiftCurrencyPreferences(countryCode: string | null | undefined): Array<Currency> {
return getBaseCurrencyPreferences(countryCode);
return getCurrencyPreferences(countryCode).filter(
(currency) => currency === 'USD' || currency === 'EUR' || GIFT_ELIGIBLE_LOCALIZED_CURRENCIES.has(currency),
);
}
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {getCurrency} from '../CurrencyUtils';
import {getCurrency, getCurrencyPreferences, getGiftCurrencyPreferences} from '../CurrencyUtils';
describe('getCurrency', () => {
describe('returns USD for non-EEA countries', () => {
@@ -55,11 +55,11 @@ describe('getCurrency', () => {
it('returns EUR for Finland', () => {
expect(getCurrency('FI')).toBe('EUR');
});
it('returns EUR for Sweden', () => {
expect(getCurrency('SE')).toBe('EUR');
it('returns SEK for Sweden', () => {
expect(getCurrency('SE')).toBe('SEK');
});
it('returns EUR for Denmark', () => {
expect(getCurrency('DK')).toBe('EUR');
it('returns DKK for Denmark', () => {
expect(getCurrency('DK')).toBe('DKK');
});
it('returns PLN for Poland', () => {
expect(getCurrency('PL')).toBe('PLN');
@@ -76,8 +76,8 @@ describe('getCurrency', () => {
it('returns EUR for Romania', () => {
expect(getCurrency('RO')).toBe('EUR');
});
it('returns EUR for Norway (EEA but not EU)', () => {
expect(getCurrency('NO')).toBe('EUR');
it('returns NOK for Norway (EEA but not EU)', () => {
expect(getCurrency('NO')).toBe('NOK');
});
it('returns EUR for Iceland (EEA but not EU)', () => {
expect(getCurrency('IS')).toBe('EUR');
@@ -128,7 +128,6 @@ describe('getCurrency', () => {
'HR',
'CY',
'CZ',
'DK',
'EE',
'FI',
'FR',
@@ -147,10 +146,8 @@ describe('getCurrency', () => {
'SK',
'SI',
'ES',
'SE',
'IS',
'LI',
'NO',
];
for (const country of eeaCountries) {
it(`returns EUR for ${country}`, () => {
@@ -160,5 +157,45 @@ describe('getCurrency', () => {
it('uses local currency for Poland', () => {
expect(getCurrency('PL')).toBe('PLN');
});
it('uses local currency for Sweden', () => {
expect(getCurrency('SE')).toBe('SEK');
});
it('uses local currency for Denmark', () => {
expect(getCurrency('DK')).toBe('DKK');
});
it('uses local currency for Norway', () => {
expect(getCurrency('NO')).toBe('NOK');
});
});
});
describe('getGiftCurrencyPreferences', () => {
it('never offers a localized currency that is cheaper than the base price', () => {
for (const country of ['BR', 'IN', 'PL', 'TR']) {
expect(getGiftCurrencyPreferences(country)).not.toContain(getCurrencyPreferences(country)[0]);
}
});
it('offers the localized currency where it is not cheaper than the base price', () => {
expect(getGiftCurrencyPreferences('SE')).toEqual(['SEK', 'EUR', 'USD']);
expect(getGiftCurrencyPreferences('DK')).toEqual(['DKK', 'EUR', 'USD']);
expect(getGiftCurrencyPreferences('NO')).toEqual(['NOK', 'EUR', 'USD']);
});
it('uses EUR for other EEA countries', () => {
expect(getGiftCurrencyPreferences('DE')).toEqual(['EUR', 'USD']);
expect(getGiftCurrencyPreferences('PL')).toEqual(['EUR', 'USD']);
});
it('uses USD everywhere else', () => {
expect(getGiftCurrencyPreferences('BR')).toEqual(['USD', 'EUR']);
expect(getGiftCurrencyPreferences('IN')).toEqual(['USD', 'EUR']);
expect(getGiftCurrencyPreferences('TR')).toEqual(['USD', 'EUR']);
expect(getGiftCurrencyPreferences('US')).toEqual(['USD', 'EUR']);
});
it('uses USD when the country is unknown', () => {
expect(getGiftCurrencyPreferences(null)).toEqual(['USD', 'EUR']);
expect(getGiftCurrencyPreferences(undefined)).toEqual(['USD', 'EUR']);
});
it('is case insensitive', () => {
expect(getGiftCurrencyPreferences('se')).toEqual(['SEK', 'EUR', 'USD']);
expect(getGiftCurrencyPreferences('br')).toEqual(['USD', 'EUR']);
});
});
+2
View File
@@ -26,6 +26,8 @@ const configuredMaxConcurrency = parseParallelInteger(process.env.API_TEST_MAX_C
const MODULE_REGISTRY_TEST_FILES = [
'src/api/gif/GifRequestCountry.test.ts',
'src/api/risk/__tests__/AccountPolicyService.test.ts',
'src/api/stripe/tests/StripeCheckoutCountryEnforcement.test.ts',
'src/api/stripe/tests/StripeNordicCurrencies.test.ts',
];
const INSTANCE_POLICY_TEST_FILES = [
-2
View File
@@ -55,7 +55,6 @@ import {VoiceLiveKitRoot} from '@app/features/voice/components/VoiceLiveKitRoot'
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
import {useElectronScreenSharePicker} from '@app/features/voice/hooks/useElectronScreenSharePicker';
import {startScreenSharePiPController} from '@app/features/voice/state/ScreenSharePiPController';
import {startMediaDeviceStartupPreload} from '@app/features/voice/utils/MediaDeviceStartupPreload';
import {useNativeTitleBar} from '@app/features/window/hooks/useNativeTitleBar';
import {useStopFlashFrameOnFocus} from '@app/features/window/hooks/useStopFlashFrameOnFocus';
import {useWindowEventListeners} from '@app/features/window/hooks/useWindowEventListeners';
@@ -149,7 +148,6 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
};
}, []);
useEffect(() => startScreenSharePiPController(), []);
useEffect(() => startMediaDeviceStartupPreload(), []);
useServiceWorkerBadge();
useKeybindManager(i18n);
useDesktopElectronBridges();
@@ -29,7 +29,6 @@ import {
FREE_VS_PREMIUM_DESCRIPTOR,
VERIFY_EMAIL_TO_PURCHASE_PREMIUM_DESCRIPTOR,
} from '@app/features/premium/utils/PremiumMessageDescriptors';
import type {PricingMode} from '@app/features/premium/utils/PricingUtils';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import MobileLayout from '@app/features/ui/state/MobileLayout';
@@ -51,7 +50,6 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
const locale = LocaleUtils.getCurrentLocale();
const mobileLayoutState = MobileLayout;
const [isGiftMode, setIsGiftMode] = useState(defaultGiftMode);
const [pricingMode, setPricingMode] = useState<PricingMode>('localized');
const giftSectionRef = useRef<HTMLDivElement | null>(null);
const perksSectionRef = useRef<HTMLDivElement | null>(null);
const countryCode = GeoIP.countryCode;
@@ -66,14 +64,11 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
yearlyPrice,
giftMonthlyPrice,
giftYearlyPrice,
hasPricingChoice,
localizedCurrency,
baseCurrency,
currentSubscriptionPrice,
currentSubscriptionPriceLabel,
currentSubscriptionListPriceLabel,
isCurrentSubscriptionGrandfathered,
} = usePremiumData(countryCode, pricingMode, {premiumState});
} = usePremiumData({premiumState});
const {
loadingPortal,
loadingCancel,
@@ -92,15 +87,9 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
const {loadingCheckout, handleSelectPlan} = useCheckoutActions(
priceIds,
countryCode,
pricingMode,
subscriptionStatus.isGiftSubscription,
mobileLayoutState.enabled,
);
useEffect(() => {
if (!hasPricingChoice && pricingMode === 'base') {
setPricingMode('localized');
}
}, [hasPricingChoice, pricingMode]);
useEffect(() => {
if (!currentUser?.id) return;
void PremiumCommands.refreshPremiumState(countryCode ?? undefined);
@@ -175,12 +164,6 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
<PlutoniumUpsellBanner data-flx="app.plutonium-content.plutonium-upsell-banner" />
<GiftSection
giftSectionRef={giftSectionRef}
countryCode={countryCode}
pricingMode={pricingMode}
setPricingMode={setPricingMode}
hasPricingChoice={hasPricingChoice}
localizedCurrency={localizedCurrency}
baseCurrency={baseCurrency}
giftMonthlyPrice={giftMonthlyPrice}
giftYearlyPrice={giftYearlyPrice}
loadingCheckout={loadingCheckout}
@@ -289,12 +272,6 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
<PricingSection
isGiftMode={isGiftMode}
setIsGiftMode={setIsGiftMode}
countryCode={countryCode}
pricingMode={pricingMode}
setPricingMode={setPricingMode}
hasPricingChoice={hasPricingChoice}
localizedCurrency={localizedCurrency}
baseCurrency={baseCurrency}
monthlyPrice={monthlyPrice}
yearlyPrice={yearlyPrice}
giftMonthlyPrice={giftMonthlyPrice}
@@ -308,12 +285,6 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
) : (
<GiftSection
giftSectionRef={giftSectionRef}
countryCode={countryCode}
pricingMode={pricingMode}
setPricingMode={setPricingMode}
hasPricingChoice={hasPricingChoice}
localizedCurrency={localizedCurrency}
baseCurrency={baseCurrency}
giftMonthlyPrice={giftMonthlyPrice}
giftYearlyPrice={giftYearlyPrice}
loadingCheckout={loadingCheckout}
@@ -340,12 +311,6 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
{!subscriptionStatus.isPremium && (
<BottomCTASection
isGiftMode={isGiftMode}
countryCode={countryCode}
pricingMode={pricingMode}
setPricingMode={setPricingMode}
hasPricingChoice={hasPricingChoice}
localizedCurrency={localizedCurrency}
baseCurrency={baseCurrency}
monthlyPrice={monthlyPrice}
yearlyPrice={yearlyPrice}
giftMonthlyPrice={giftMonthlyPrice}
@@ -2,10 +2,8 @@
import {PurchaseDisclaimer} from '@app/features/app/components/dialogs/components/PurchaseDisclaimer';
import styles from '@app/features/app/components/dialogs/components/plutonium/BottomCTASection.module.css';
import {PricingContextPanel} from '@app/features/app/components/dialogs/components/plutonium/PricingContextPanel';
import {PurchaseDisabledWrapper} from '@app/features/app/components/dialogs/components/plutonium/PurchaseDisabledWrapper';
import {PREMIUM_PRODUCT_FULL_NAME} from '@app/features/app/config/I18nDisplayConstants';
import type {PricingMode} from '@app/features/premium/utils/PricingUtils';
import {Button} from '@app/features/ui/button/Button';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
@@ -20,12 +18,6 @@ const CLAIM_YOUR_ACCOUNT_TO_PURCHASE_DESCRIPTOR = msg({
interface BottomCTASectionProps {
isGiftMode: boolean;
countryCode: string | null;
pricingMode: PricingMode;
setPricingMode: (value: PricingMode) => void;
hasPricingChoice: boolean;
localizedCurrency: string | null;
baseCurrency: string | null;
monthlyPrice: string;
yearlyPrice: string;
giftMonthlyPrice: string;
@@ -39,12 +31,6 @@ interface BottomCTASectionProps {
export const BottomCTASection: React.FC<BottomCTASectionProps> = observer(
({
isGiftMode,
countryCode,
pricingMode,
setPricingMode,
hasPricingChoice,
localizedCurrency,
baseCurrency,
monthlyPrice,
yearlyPrice,
giftMonthlyPrice,
@@ -63,17 +49,6 @@ export const BottomCTASection: React.FC<BottomCTASectionProps> = observer(
<h2 className={styles.title} data-flx="app.plutonium.bottom-cta-section.title">
{isGiftMode ? <Trans>Ready to buy a gift?</Trans> : <Trans>Ready to upgrade?</Trans>}
</h2>
<PricingContextPanel
countryCode={countryCode}
pricingMode={pricingMode}
setPricingMode={setPricingMode}
hasPricingChoice={hasPricingChoice}
localizedCurrency={localizedCurrency}
baseCurrency={baseCurrency}
isGiftMode={isGiftMode}
compact
data-flx="app.plutonium.bottom-cta-section.pricing-context-panel"
/>
<div className={styles.buttonContainer} data-flx="app.plutonium.bottom-cta-section.button-container">
{!isGiftMode ? (
<>
@@ -5,7 +5,6 @@ import gridStyles from '@app/features/app/components/dialogs/components/PricingG
import {PurchaseDisclaimer} from '@app/features/app/components/dialogs/components/PurchaseDisclaimer';
import styles from '@app/features/app/components/dialogs/components/plutonium/GiftSection.module.css';
import {SectionHeader} from '@app/features/app/components/dialogs/components/plutonium/PlutoniumSectionHeader';
import {PricingContextPanel} from '@app/features/app/components/dialogs/components/plutonium/PricingContextPanel';
import {PurchaseDisabledWrapper} from '@app/features/app/components/dialogs/components/plutonium/PurchaseDisabledWrapper';
import {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import {
@@ -16,7 +15,6 @@ import {
SHARE_PREMIUM_EXPERIENCE_DESCRIPTOR,
VIEW_PREMIUM_PERKS_DESCRIPTOR,
} from '@app/features/premium/utils/PremiumMessageDescriptors';
import type {PricingMode} from '@app/features/premium/utils/PricingUtils';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {ArrowDownIcon} from '@phosphor-icons/react';
@@ -38,12 +36,6 @@ const MESSAGE_1_MONTH_GIFT_DESCRIPTOR = msg({
interface GiftSectionProps {
giftSectionRef: React.RefObject<HTMLDivElement | null>;
countryCode: string | null;
pricingMode: PricingMode;
setPricingMode: (value: PricingMode) => void;
hasPricingChoice: boolean;
localizedCurrency: string | null;
baseCurrency: string | null;
giftMonthlyPrice: string;
giftYearlyPrice: string;
loadingCheckout: boolean;
@@ -55,12 +47,6 @@ interface GiftSectionProps {
export const GiftSection: React.FC<GiftSectionProps> = observer(
({
giftSectionRef,
countryCode,
pricingMode,
setPricingMode,
hasPricingChoice,
localizedCurrency,
baseCurrency,
giftMonthlyPrice,
giftYearlyPrice,
loadingCheckout,
@@ -82,16 +68,6 @@ export const GiftSection: React.FC<GiftSectionProps> = observer(
})}
data-flx="app.plutonium.gift-section.section-header"
/>
<PricingContextPanel
countryCode={countryCode}
pricingMode={pricingMode}
setPricingMode={setPricingMode}
hasPricingChoice={hasPricingChoice}
localizedCurrency={localizedCurrency}
baseCurrency={baseCurrency}
isGiftMode
data-flx="app.plutonium.gift-section.pricing-context-panel"
/>
<div className={gridStyles.gridWrapper} data-flx="app.plutonium.gift-section.div--2">
<div className={gridStyles.gridTwoColumns} data-flx="app.plutonium.gift-section.div--3">
<PurchaseDisabledWrapper
@@ -1,53 +0,0 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.container {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.toggleContainer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.75rem;
}
.panel {
border: 0.0625rem solid var(--border-color);
border-radius: 0.75rem;
background-color: var(--background-tertiary);
padding: 0.875rem 1rem;
}
.panelCompact {
padding: 0.75rem 0.875rem;
}
.title {
margin: 0 0 0.5rem;
font-weight: 700;
font-size: 0.95rem;
line-height: 1.35rem;
color: var(--text-primary);
}
.list {
margin: 0;
padding-left: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.375rem;
color: var(--text-primary-muted);
font-size: 0.875rem;
line-height: 1.35rem;
}
.summary {
margin: 0;
color: var(--text-primary-muted);
font-size: 0.875rem;
line-height: 1.35rem;
text-align: center;
}
@@ -1,490 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import styles from '@app/features/app/components/dialogs/components/plutonium/PricingContextPanel.module.css';
import {ToggleButton} from '@app/features/app/components/dialogs/components/ToggleButton';
import {
BLIK_PAYMENT_METHOD,
MB_WAY_PAYMENT_METHOD,
PAYMENT_PROVIDER_NAME,
PIX_PAYMENT_METHOD,
UPI_PAYMENT_METHOD,
} from '@app/features/app/config/I18nDisplayConstants';
import {getCurrencyCodeLabel, type PricingMode} from '@app/features/premium/utils/PricingUtils';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useMemo} from 'react';
const BRL_GIFT_LOCALIZED_TITLE_DESCRIPTOR = msg({
message: 'Localized BRL gift checkout',
comment: 'Plutonium gift checkout panel title for buyers in Brazil who selected localized BRL pricing.',
});
const BRL_GIFT_PIX_DETAIL_DESCRIPTOR = msg({
message:
'{pixPaymentMethod} can be used for one-time BRL payments when {paymentProviderName} offers it on the checkout page.',
comment:
'Plutonium gift checkout guidance for Brazil. Explains that Pix is available for one-time gift purchases via the payment provider.',
});
const BRL_GIFT_CARD_ISSUANCE_DETAIL_DESCRIPTOR = msg({
message: 'If you pay with a card in BRL, it still needs to be issued in Brazil for the purchase to stay eligible.',
comment:
'Plutonium gift checkout guidance for Brazil. Clarifies that BRL card payments require a Brazil-issued card.',
});
const INR_GIFT_LOCALIZED_TITLE_DESCRIPTOR = msg({
message: 'Localized INR gift checkout',
comment: 'Plutonium gift checkout panel title for buyers in India who selected localized INR pricing.',
});
const INR_GIFT_UPI_DETAIL_DESCRIPTOR = msg({
message:
'{upiPaymentMethod} can be used for one-time INR payments when {paymentProviderName} offers it on the checkout page.',
comment:
'Plutonium gift checkout guidance for India. Explains that UPI is available for one-time gift purchases via the payment provider.',
});
const INR_GIFT_CARD_ISSUANCE_DETAIL_DESCRIPTOR = msg({
message: 'If you pay with a card in INR, it still needs to be issued in India for the purchase to stay eligible.',
comment: 'Plutonium gift checkout guidance for India. Clarifies that INR card payments require an India-issued card.',
});
const PLN_GIFT_LOCALIZED_TITLE_DESCRIPTOR = msg({
message: 'Localized PLN gift checkout',
comment: 'Plutonium gift checkout panel title for buyers in Poland who selected localized PLN pricing.',
});
const PLN_GIFT_BLIK_DETAIL_DESCRIPTOR = msg({
message:
'{blikPaymentMethod} can be used for one-time PLN gift payments, even though it does not support subscriptions in {paymentProviderName} checkout.',
comment:
'Plutonium gift checkout guidance for Poland. Explains that BLIK works for gifts but not for recurring subscriptions.',
});
const PLN_GIFT_CARD_ISSUANCE_DETAIL_DESCRIPTOR = msg({
message: 'If you pay with a card in PLN, it still needs to be issued in Poland for the purchase to stay eligible.',
comment:
'Plutonium gift checkout guidance for Poland. Clarifies that PLN card payments require a Poland-issued card.',
});
const TRY_GIFT_LOCALIZED_TITLE_DESCRIPTOR = msg({
message: 'Localized TRY gift checkout',
comment: 'Plutonium gift checkout panel title for buyers in Türkiye who selected localized TRY pricing.',
});
const TRY_GIFT_CARD_ISSUANCE_DETAIL_DESCRIPTOR = msg({
message:
'TRY gifts are one-time payments. Cards charged in TRY still need to be issued in Türkiye for the purchase to stay eligible.',
comment:
'Plutonium gift checkout guidance for Türkiye. Clarifies one-time-only nature and Türkiye card issuance requirement.',
});
const TRY_GIFT_NO_LOCAL_APP_METHOD_DETAIL_DESCRIPTOR = msg({
message: 'There is no app-based local payment method surfaced here today, so card checkout is the main path.',
comment:
'Plutonium gift checkout guidance for Türkiye. Notes that no local wallet method is currently available; card is the main option.',
});
const EUR_GIFT_TITLE_DESCRIPTOR = msg({
message: 'EUR gift checkout',
comment: 'Plutonium gift checkout panel title for buyers in Portugal paying with EUR.',
});
const EUR_GIFT_MBWAY_DETAIL_DESCRIPTOR = msg({
message:
'{mbWayPaymentMethod} can be used for one-time EUR gift payments when {paymentProviderName} offers it on the checkout page.',
comment:
'Plutonium gift checkout guidance for Portugal. Explains that MB WAY can be used for one-time EUR gift purchases.',
});
const STANDARD_GIFT_TITLE_DESCRIPTOR = msg({
message: 'Standard {baseCurrencyLabel} gift checkout',
comment:
'Plutonium gift checkout panel title when the buyer opted into the standard (non-localized) currency. The currency code is interpolated.',
});
const STANDARD_GIFT_SWITCH_BACK_DETAIL_DESCRIPTOR = msg({
message: 'Switch back to {localizedCurrencyLabel} if you prefer the localized price instead.',
comment:
'Plutonium gift checkout guidance. Reminds the buyer they can revert to localized pricing. The currency code is interpolated.',
});
const EUR_SUBSCRIPTION_TITLE_DESCRIPTOR = msg({
message: 'EUR subscription checkout',
comment: 'Plutonium subscription checkout panel title for subscribers in Portugal paying with EUR.',
});
const EUR_SUBSCRIPTION_MBWAY_ONE_TIME_DETAIL_DESCRIPTOR = msg({
message:
'{mbWayPaymentMethod} is a one-time payment method in {paymentProviderName} checkout, so it is not a recurring subscription path here.',
comment:
'Plutonium subscription checkout guidance for Portugal. Explains that MB WAY cannot be used for recurring subscriptions.',
});
const EUR_SUBSCRIPTION_USE_CARD_OR_GIFT_DETAIL_DESCRIPTOR = msg({
message: 'Use a card for the subscription, or buy a gift instead if you want a one-time EUR payment method.',
comment:
'Plutonium subscription checkout guidance for Portugal. Suggests using a card for recurring billing or a gift for one-time EUR payments.',
});
const STANDARD_PRICING_TITLE_DESCRIPTOR = msg({
message: 'Standard {baseCurrencyLabel} pricing',
comment:
'Plutonium subscription checkout panel title when the subscriber opted into the standard (non-localized) currency. The currency code is interpolated.',
});
const STANDARD_PRICING_FULL_PRICE_DETAIL_DESCRIPTOR = msg({
message: 'You are using the full standard {baseCurrencyLabel} price instead of localized pricing.',
comment:
'Plutonium subscription checkout guidance. States that the standard (non-localized) price applies. The currency code is interpolated.',
});
const STANDARD_PRICING_SKIPS_VERIFICATION_DETAIL_DESCRIPTOR = msg({
message:
'This skips localized card verification. Switch back to {localizedCurrencyLabel} if you prefer the local price.',
comment:
'Plutonium subscription checkout guidance. Notes that the standard path skips localized verification and offers a way back.',
});
const BRL_SUBSCRIPTION_LOCALIZED_TITLE_DESCRIPTOR = msg({
message: 'Localized BRL subscription checkout',
comment: 'Plutonium subscription checkout panel title for subscribers in Brazil with localized BRL pricing.',
});
const BRL_SUBSCRIPTION_CARD_VERIFICATION_DETAIL_DESCRIPTOR = msg({
message:
'Cards can keep BRL pricing after a quick {paymentProviderName} verification, but the card must be issued in Brazil.',
comment:
'Plutonium subscription checkout guidance for Brazil. Explains that BRL card subscriptions need a verification step and a Brazil-issued card.',
});
const BRL_SUBSCRIPTION_PIX_RECURRING_DETAIL_DESCRIPTOR = msg({
message:
'{pixPaymentMethod} can skip the card verification step when {paymentProviderName} offers recurring {pixPaymentMethod2} for your checkout.',
comment: 'Plutonium subscription checkout guidance for Brazil. Explains that recurring Pix avoids card verification.',
});
const BRL_SUBSCRIPTION_PIX_FALLBACK_DETAIL_DESCRIPTOR = msg({
message: 'If {pixPaymentMethod} is unavailable or you prefer standard pricing, you can switch to USD at any time.',
comment:
'Plutonium subscription checkout guidance for Brazil. Offers USD as a fallback when Pix is not available or standard pricing is preferred.',
});
const INR_SUBSCRIPTION_LOCALIZED_TITLE_DESCRIPTOR = msg({
message: 'Localized INR subscription checkout',
comment: 'Plutonium subscription checkout panel title for subscribers in India with localized INR pricing.',
});
const INR_SUBSCRIPTION_CARD_VERIFICATION_DETAIL_DESCRIPTOR = msg({
message:
'Cards can keep INR pricing after a quick {paymentProviderName} verification, but the card must be issued in India.',
comment:
'Plutonium subscription checkout guidance for India. Explains that INR card subscriptions need a verification step and an India-issued card.',
});
const INR_SUBSCRIPTION_UPI_RECURRING_DETAIL_DESCRIPTOR = msg({
message:
'{upiPaymentMethod} can skip the card verification step when {paymentProviderName} offers recurring {upiPaymentMethod2} for your checkout.',
comment: 'Plutonium subscription checkout guidance for India. Explains that recurring UPI avoids card verification.',
});
const INR_SUBSCRIPTION_UPI_FALLBACK_DETAIL_DESCRIPTOR = msg({
message:
'If recurring {upiPaymentMethod} is unavailable or you prefer standard pricing, you can switch to USD at any time.',
comment:
'Plutonium subscription checkout guidance for India. Offers USD as a fallback when recurring UPI is not available.',
});
const PLN_SUBSCRIPTION_LOCALIZED_TITLE_DESCRIPTOR = msg({
message: 'Localized PLN subscription checkout',
comment: 'Plutonium subscription checkout panel title for subscribers in Poland with localized PLN pricing.',
});
const PLN_SUBSCRIPTION_CARD_VERIFICATION_DETAIL_DESCRIPTOR = msg({
message:
'Cards can keep PLN pricing after a quick {paymentProviderName} verification, but the card must be issued in Poland.',
comment:
'Plutonium subscription checkout guidance for Poland. Explains that PLN card subscriptions need a verification step and a Poland-issued card.',
});
const PLN_SUBSCRIPTION_BLIK_UNSUPPORTED_DETAIL_DESCRIPTOR = msg({
message: '{blikPaymentMethod} does not support subscriptions in {paymentProviderName} checkout.',
comment:
'Plutonium subscription checkout guidance for Poland. Clarifies that BLIK is not available for recurring subscriptions.',
});
const PLN_SUBSCRIPTION_BLIK_GIFT_FALLBACK_DETAIL_DESCRIPTOR = msg({
message: 'If you want to use {blikPaymentMethod}, buy a PLN gift instead, or switch to standard EUR pricing.',
comment:
'Plutonium subscription checkout guidance for Poland. Suggests a PLN gift or standard EUR if the subscriber wants BLIK.',
});
const TRY_SUBSCRIPTION_LOCALIZED_TITLE_DESCRIPTOR = msg({
message: 'Localized TRY subscription checkout',
comment: 'Plutonium subscription checkout panel title for subscribers in Türkiye with localized TRY pricing.',
});
const TRY_SUBSCRIPTION_CARD_VERIFICATION_DETAIL_DESCRIPTOR = msg({
message:
'Cards can keep TRY pricing after a quick {paymentProviderName} verification, but the card must be issued in Türkiye.',
comment:
'Plutonium subscription checkout guidance for Türkiye. Explains that TRY card subscriptions need a verification step and a Türkiye-issued card.',
});
const TRY_SUBSCRIPTION_NO_LOCAL_APP_METHOD_DETAIL_DESCRIPTOR = msg({
message:
'There is no app-based local subscription method surfaced here today, so local card checkout is the main path.',
comment:
'Plutonium subscription checkout guidance for Türkiye. Notes that no local wallet method is available; card is the main option.',
});
const SWITCH_TO_STANDARD_USD_DETAIL_DESCRIPTOR = msg({
message: 'If you prefer, you can switch to standard USD pricing instead.',
comment: 'Plutonium subscription checkout guidance. Offers USD as a fallback option.',
});
const USING_LOCALIZED_PRICING_SUMMARY_DESCRIPTOR = msg({
message: 'Using localized {localizedCurrencyLabel} pricing.',
comment:
'Compact summary line shown in Plutonium checkout when localized pricing is active. The currency code is interpolated.',
});
const USING_STANDARD_PRICING_SUMMARY_DESCRIPTOR = msg({
message: 'Using standard {baseCurrencyLabel} pricing.',
comment:
'Compact summary line shown in Plutonium checkout when standard pricing is active. The currency code is interpolated.',
});
const PRICING_PREFERENCE_GROUP_LABEL_DESCRIPTOR = msg({
message: 'Pricing preference',
comment:
'Accessible group label for the pricing toggle (localized vs standard) in Plutonium checkout. Not visible on screen.',
});
interface PricingContextPanelProps {
countryCode: string | null;
pricingMode: PricingMode;
setPricingMode: (mode: PricingMode) => void;
hasPricingChoice: boolean;
localizedCurrency: string | null;
baseCurrency: string | null;
isGiftMode: boolean;
compact?: boolean;
}
function getCountryContext(countryCode: string | null): 'BR' | 'IN' | 'PL' | 'PT' | 'TR' | 'OTHER' {
const upperCountryCode = countryCode?.toUpperCase();
switch (upperCountryCode) {
case 'BR':
case 'IN':
case 'PL':
case 'PT':
case 'TR':
return upperCountryCode;
default:
return 'OTHER';
}
}
export const PricingContextPanel: React.FC<PricingContextPanelProps> = observer(
({
countryCode,
pricingMode,
setPricingMode,
hasPricingChoice,
localizedCurrency,
baseCurrency,
isGiftMode,
compact = false,
}) => {
const {i18n} = useLingui();
const countryContext = getCountryContext(countryCode);
const localizedCurrencyLabel = getCurrencyCodeLabel(localizedCurrency);
const baseCurrencyLabel = getCurrencyCodeLabel(baseCurrency);
const guidance = useMemo(() => {
if (isGiftMode) {
if (pricingMode === 'localized') {
switch (countryContext) {
case 'BR':
return {
title: i18n._(BRL_GIFT_LOCALIZED_TITLE_DESCRIPTOR),
items: [
i18n._(BRL_GIFT_PIX_DETAIL_DESCRIPTOR, {
pixPaymentMethod: PIX_PAYMENT_METHOD,
paymentProviderName: PAYMENT_PROVIDER_NAME,
}),
i18n._(BRL_GIFT_CARD_ISSUANCE_DETAIL_DESCRIPTOR),
],
};
case 'IN':
return {
title: i18n._(INR_GIFT_LOCALIZED_TITLE_DESCRIPTOR),
items: [
i18n._(INR_GIFT_UPI_DETAIL_DESCRIPTOR, {
upiPaymentMethod: UPI_PAYMENT_METHOD,
paymentProviderName: PAYMENT_PROVIDER_NAME,
}),
i18n._(INR_GIFT_CARD_ISSUANCE_DETAIL_DESCRIPTOR),
],
};
case 'PL':
return {
title: i18n._(PLN_GIFT_LOCALIZED_TITLE_DESCRIPTOR),
items: [
i18n._(PLN_GIFT_BLIK_DETAIL_DESCRIPTOR, {
blikPaymentMethod: BLIK_PAYMENT_METHOD,
paymentProviderName: PAYMENT_PROVIDER_NAME,
}),
i18n._(PLN_GIFT_CARD_ISSUANCE_DETAIL_DESCRIPTOR),
],
};
case 'TR':
return {
title: i18n._(TRY_GIFT_LOCALIZED_TITLE_DESCRIPTOR),
items: [
i18n._(TRY_GIFT_CARD_ISSUANCE_DETAIL_DESCRIPTOR),
i18n._(TRY_GIFT_NO_LOCAL_APP_METHOD_DETAIL_DESCRIPTOR),
],
};
}
}
if (countryContext === 'PT' && baseCurrency === 'EUR') {
return {
title: i18n._(EUR_GIFT_TITLE_DESCRIPTOR),
items: [
i18n._(EUR_GIFT_MBWAY_DETAIL_DESCRIPTOR, {
mbWayPaymentMethod: MB_WAY_PAYMENT_METHOD,
paymentProviderName: PAYMENT_PROVIDER_NAME,
}),
],
};
}
if (hasPricingChoice && pricingMode === 'base') {
return {
title: i18n._(STANDARD_GIFT_TITLE_DESCRIPTOR, {baseCurrencyLabel}),
items: [i18n._(STANDARD_GIFT_SWITCH_BACK_DETAIL_DESCRIPTOR, {localizedCurrencyLabel})],
};
}
return null;
}
if (countryContext === 'PT' && baseCurrency === 'EUR') {
return {
title: i18n._(EUR_SUBSCRIPTION_TITLE_DESCRIPTOR),
items: [
i18n._(EUR_SUBSCRIPTION_MBWAY_ONE_TIME_DETAIL_DESCRIPTOR, {
mbWayPaymentMethod: MB_WAY_PAYMENT_METHOD,
paymentProviderName: PAYMENT_PROVIDER_NAME,
}),
i18n._(EUR_SUBSCRIPTION_USE_CARD_OR_GIFT_DETAIL_DESCRIPTOR),
],
};
}
if (pricingMode === 'base') {
if (hasPricingChoice) {
return {
title: i18n._(STANDARD_PRICING_TITLE_DESCRIPTOR, {baseCurrencyLabel}),
items: [
i18n._(STANDARD_PRICING_FULL_PRICE_DETAIL_DESCRIPTOR, {baseCurrencyLabel}),
i18n._(STANDARD_PRICING_SKIPS_VERIFICATION_DETAIL_DESCRIPTOR, {localizedCurrencyLabel}),
],
};
}
return null;
}
switch (countryContext) {
case 'BR':
return {
title: i18n._(BRL_SUBSCRIPTION_LOCALIZED_TITLE_DESCRIPTOR),
items: [
i18n._(BRL_SUBSCRIPTION_CARD_VERIFICATION_DETAIL_DESCRIPTOR, {
paymentProviderName: PAYMENT_PROVIDER_NAME,
}),
i18n._(BRL_SUBSCRIPTION_PIX_RECURRING_DETAIL_DESCRIPTOR, {
pixPaymentMethod: PIX_PAYMENT_METHOD,
paymentProviderName: PAYMENT_PROVIDER_NAME,
pixPaymentMethod2: PIX_PAYMENT_METHOD,
}),
i18n._(BRL_SUBSCRIPTION_PIX_FALLBACK_DETAIL_DESCRIPTOR, {
pixPaymentMethod: PIX_PAYMENT_METHOD,
}),
],
};
case 'IN':
return {
title: i18n._(INR_SUBSCRIPTION_LOCALIZED_TITLE_DESCRIPTOR),
items: [
i18n._(INR_SUBSCRIPTION_CARD_VERIFICATION_DETAIL_DESCRIPTOR, {
paymentProviderName: PAYMENT_PROVIDER_NAME,
}),
i18n._(INR_SUBSCRIPTION_UPI_RECURRING_DETAIL_DESCRIPTOR, {
upiPaymentMethod: UPI_PAYMENT_METHOD,
paymentProviderName: PAYMENT_PROVIDER_NAME,
upiPaymentMethod2: UPI_PAYMENT_METHOD,
}),
i18n._(INR_SUBSCRIPTION_UPI_FALLBACK_DETAIL_DESCRIPTOR, {
upiPaymentMethod: UPI_PAYMENT_METHOD,
}),
],
};
case 'PL':
return {
title: i18n._(PLN_SUBSCRIPTION_LOCALIZED_TITLE_DESCRIPTOR),
items: [
i18n._(PLN_SUBSCRIPTION_CARD_VERIFICATION_DETAIL_DESCRIPTOR, {
paymentProviderName: PAYMENT_PROVIDER_NAME,
}),
i18n._(PLN_SUBSCRIPTION_BLIK_UNSUPPORTED_DETAIL_DESCRIPTOR, {
blikPaymentMethod: BLIK_PAYMENT_METHOD,
paymentProviderName: PAYMENT_PROVIDER_NAME,
}),
i18n._(PLN_SUBSCRIPTION_BLIK_GIFT_FALLBACK_DETAIL_DESCRIPTOR, {blikPaymentMethod: BLIK_PAYMENT_METHOD}),
],
};
case 'TR':
return {
title: i18n._(TRY_SUBSCRIPTION_LOCALIZED_TITLE_DESCRIPTOR),
items: [
i18n._(TRY_SUBSCRIPTION_CARD_VERIFICATION_DETAIL_DESCRIPTOR, {
paymentProviderName: PAYMENT_PROVIDER_NAME,
}),
i18n._(TRY_SUBSCRIPTION_NO_LOCAL_APP_METHOD_DETAIL_DESCRIPTOR),
i18n._(SWITCH_TO_STANDARD_USD_DETAIL_DESCRIPTOR),
],
};
default:
return null;
}
}, [
baseCurrency,
baseCurrencyLabel,
countryContext,
hasPricingChoice,
isGiftMode,
localizedCurrencyLabel,
pricingMode,
i18n.locale,
]);
const summary = useMemo(() => {
if (pricingMode === 'localized') {
if (hasPricingChoice) {
return i18n._(USING_LOCALIZED_PRICING_SUMMARY_DESCRIPTOR, {localizedCurrencyLabel});
}
return null;
}
if (hasPricingChoice) {
return i18n._(USING_STANDARD_PRICING_SUMMARY_DESCRIPTOR, {baseCurrencyLabel});
}
return null;
}, [baseCurrencyLabel, hasPricingChoice, localizedCurrencyLabel, pricingMode, i18n.locale]);
return (
<div className={styles.container} data-flx="app.plutonium.pricing-context-panel.container">
{hasPricingChoice && (
<div
className={styles.toggleContainer}
role="group"
aria-label={i18n._(PRICING_PREFERENCE_GROUP_LABEL_DESCRIPTOR)}
data-flx="app.plutonium.pricing-context-panel.toggle-container"
>
<ToggleButton
active={pricingMode === 'localized'}
onClick={() => setPricingMode('localized')}
label={<Trans>Local {localizedCurrencyLabel}</Trans>}
data-flx="app.plutonium.pricing-context-panel.toggle-button.set-pricing-mode"
/>
<ToggleButton
active={pricingMode === 'base'}
onClick={() => setPricingMode('base')}
label={<Trans>Standard {baseCurrencyLabel}</Trans>}
data-flx="app.plutonium.pricing-context-panel.toggle-button.set-pricing-mode--2"
/>
</div>
)}
{summary && compact && (
<p className={styles.summary} data-flx="app.plutonium.pricing-context-panel.summary">
{summary}
</p>
)}
{guidance && !compact && (
<div
className={clsx(styles.panel, compact && styles.panelCompact)}
data-flx="app.plutonium.pricing-context-panel.panel"
>
<h3 className={styles.title} data-flx="app.plutonium.pricing-context-panel.title">
{guidance.title}
</h3>
<ul className={styles.list} data-flx="app.plutonium.pricing-context-panel.list">
{guidance.items.map((item) => (
<li key={item} data-flx="app.plutonium.pricing-context-panel.li">
{item}
</li>
))}
</ul>
</div>
)}
</div>
);
},
);
@@ -7,7 +7,6 @@
}
.toggleContainer {
margin-bottom: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
@@ -3,7 +3,6 @@
import {PricingCard} from '@app/features/app/components/dialogs/components/PricingCard';
import gridStyles from '@app/features/app/components/dialogs/components/PricingGrid.module.css';
import {PurchaseDisclaimer} from '@app/features/app/components/dialogs/components/PurchaseDisclaimer';
import {PricingContextPanel} from '@app/features/app/components/dialogs/components/plutonium/PricingContextPanel';
import styles from '@app/features/app/components/dialogs/components/plutonium/PricingSection.module.css';
import {PurchaseDisabledWrapper} from '@app/features/app/components/dialogs/components/plutonium/PurchaseDisabledWrapper';
import {ToggleButton} from '@app/features/app/components/dialogs/components/ToggleButton';
@@ -14,7 +13,6 @@ import {
ONE_TIME_PURCHASE_DESCRIPTOR,
VIEW_PREMIUM_PERKS_DESCRIPTOR,
} from '@app/features/premium/utils/PremiumMessageDescriptors';
import type {PricingMode} from '@app/features/premium/utils/PricingUtils';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {ArrowDownIcon} from '@phosphor-icons/react';
@@ -73,12 +71,6 @@ const MESSAGE_1_MONTH_GIFT_DESCRIPTOR = msg({
interface PricingSectionProps {
isGiftMode: boolean;
setIsGiftMode: (value: boolean) => void;
countryCode: string | null;
pricingMode: PricingMode;
setPricingMode: (value: PricingMode) => void;
hasPricingChoice: boolean;
localizedCurrency: string | null;
baseCurrency: string | null;
monthlyPrice: string;
yearlyPrice: string;
giftMonthlyPrice: string;
@@ -93,12 +85,6 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
({
isGiftMode,
setIsGiftMode,
countryCode,
pricingMode,
setPricingMode,
hasPricingChoice,
localizedCurrency,
baseCurrency,
monthlyPrice,
yearlyPrice,
giftMonthlyPrice,
@@ -133,16 +119,6 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
data-flx="app.plutonium.pricing-section.toggle-button.set-is-gift-mode--2"
/>
</div>
<PricingContextPanel
countryCode={countryCode}
pricingMode={pricingMode}
setPricingMode={setPricingMode}
hasPricingChoice={hasPricingChoice}
localizedCurrency={localizedCurrency}
baseCurrency={baseCurrency}
isGiftMode={isGiftMode}
data-flx="app.plutonium.pricing-section.pricing-context-panel"
/>
<div className={gridStyles.gridWrapper} data-flx="app.plutonium.pricing-section.div">
<div className={gridStyles.gridTwoColumns} data-flx="app.plutonium.pricing-section.div--2">
{!isGiftMode ? (
@@ -49,12 +49,6 @@
color: var(--text-primary);
}
.description {
font-size: 0.8125rem;
line-height: 1.25rem;
color: var(--text-primary-muted);
}
@media (min-width: 768px) {
.description {
font-size: 0.875rem;
@@ -19,7 +19,7 @@ interface PurchaseHistorySectionProps {
export const PurchaseHistorySection: React.FC<PurchaseHistorySectionProps> = observer(
({premiumState, loadingPortal, handleOpenCustomerPortal}) => {
const invoices = premiumState?.billing.invoices ?? [];
const [invoicesExpanded, setInvoicesExpanded] = useState(invoices.length > 0);
const [invoicesExpanded, setInvoicesExpanded] = useState(false);
return (
<section className={styles.section} data-flx="app.plutonium.purchase-history-section.section">
<div className={styles.card} data-flx="app.plutonium.purchase-history-section.card">
@@ -28,12 +28,6 @@ export const PurchaseHistorySection: React.FC<PurchaseHistorySectionProps> = obs
<h3 className={styles.title} data-flx="app.plutonium.purchase-history-section.title">
<Trans>Purchase history</Trans>
</h3>
<p className={styles.description} data-flx="app.plutonium.purchase-history-section.description">
<Trans>
Your recent invoices. To change the payment method for your subscription, add or choose one in the
billing portal and make it the default.
</Trans>
</p>
</div>
<Button
variant="secondary"
@@ -18,7 +18,6 @@ import type {CheckoutPaymentMethod, PriceIds} from '@app/features/premium/comman
import * as PremiumCommands from '@app/features/premium/commands/PremiumCommands';
import {recordPremiumCheckoutReturnIntent} from '@app/features/premium/utils/PremiumCheckoutReturnIntent';
import {MANAGE_SUBSCRIPTION_DESCRIPTOR} from '@app/features/premium/utils/PremiumMessageDescriptors';
import type {PricingMode} from '@app/features/premium/utils/PricingUtils';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import {openExternalUrl} from '@app/features/ui/utils/NativeUtils';
@@ -246,7 +245,6 @@ function alternativePaymentMethodForCurrency(
export const useCheckoutActions = (
priceIds: PriceIds | null,
countryCode: string | null,
pricingMode: PricingMode,
isGiftSubscription: boolean,
mobileEnabled: boolean,
) => {
@@ -552,7 +550,6 @@ export const useCheckoutActions = (
priceId,
countryCode ?? undefined,
isGift,
pricingMode,
paymentMethod,
);
await openCheckoutUrl(checkoutUrl, {promptKind: 'payment', skipMobilePrompt});
@@ -572,11 +569,7 @@ export const useCheckoutActions = (
}
setLoadingCheckout(true);
try {
const checkoutUrl = await PremiumCommands.createLocalizedCardPreapprovalSession(
priceId,
countryCode,
pricingMode,
);
const checkoutUrl = await PremiumCommands.createLocalizedCardPreapprovalSession(priceId, countryCode);
await openCheckoutUrl(checkoutUrl, {
promptKind: 'localized_card_preapproval',
skipMobilePrompt,
@@ -641,7 +634,6 @@ export const useCheckoutActions = (
getAlternativePaymentMethodPrompt,
isGiftSubscription,
mobileEnabled,
pricingMode,
i18n,
],
);
@@ -2,7 +2,7 @@
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import type {CurrentSubscriptionPrice, PriceIds} from '@app/features/premium/commands/PremiumCommands';
import {formatMinorUnitPrice, type PricingMode} from '@app/features/premium/utils/PricingUtils';
import {formatMinorUnitPrice} from '@app/features/premium/utils/PricingUtils';
import * as LocaleUtils from '@app/features/user/utils/LocaleUtils';
import type {PremiumStateResponse} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
import {useMemo} from 'react';
@@ -35,35 +35,19 @@ export interface PremiumData {
yearlyPrice: string;
giftMonthlyPrice: string;
giftYearlyPrice: string;
localizedPriceIds: PriceIds | null;
basePriceIds: PriceIds | null;
hasPricingChoice: boolean;
selectedPricingMode: PricingMode;
localizedCurrency: string | null;
baseCurrency: string | null;
currentSubscriptionPrice: CurrentSubscriptionPrice | null;
currentSubscriptionPriceLabel: string | null;
currentSubscriptionListPriceLabel: string | null;
isCurrentSubscriptionGrandfathered: boolean;
}
export const usePremiumData = (
_countryCode: string | null,
pricingMode: PricingMode = 'localized',
{
premiumState = null,
}: {
premiumState?: PremiumStateResponse | null;
} = {},
): PremiumData => {
export const usePremiumData = ({
premiumState = null,
}: {
premiumState?: PremiumStateResponse | null;
} = {}): PremiumData => {
const locale = LocaleUtils.getCurrentLocale();
const localizedPriceIds = premiumState?.pricing.localized ?? null;
const basePriceIds = premiumState?.pricing.base ?? null;
const hasPricingChoice =
typeof localizedPriceIds?.currency === 'string' &&
typeof basePriceIds?.currency === 'string' &&
localizedPriceIds.currency !== basePriceIds.currency;
const priceIds = pricingMode === 'base' && hasPricingChoice ? basePriceIds : localizedPriceIds;
const priceIds = premiumState?.pricing.localized ?? null;
const monthlyPrice = useMemo(() => {
return formatPriceLabel(priceIds?.monthly_amount_minor, priceIds?.currency, locale);
}, [locale, priceIds?.currency, priceIds?.monthly_amount_minor]);
@@ -97,12 +81,6 @@ export const usePremiumData = (
yearlyPrice,
giftMonthlyPrice,
giftYearlyPrice,
localizedPriceIds,
basePriceIds,
hasPricingChoice,
selectedPricingMode: pricingMode,
localizedCurrency: localizedPriceIds?.currency ?? null,
baseCurrency: hasPricingChoice ? (basePriceIds?.currency ?? null) : null,
currentSubscriptionPrice,
currentSubscriptionPriceLabel,
currentSubscriptionListPriceLabel,
@@ -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
@@ -23,7 +23,6 @@ export const MACOS_SCREEN_RECORDING_PERMISSION_NAME = 'Screen Recording';
export const PIX_PAYMENT_METHOD = 'Pix';
export const UPI_PAYMENT_METHOD = 'UPI';
export const BLIK_PAYMENT_METHOD = 'BLIK';
export const MB_WAY_PAYMENT_METHOD = 'MB WAY';
export const SUPPORT_EMAIL = '[email protected]';
export const SUPPORT_EMAIL_MAILTO = `mailto:${SUPPORT_EMAIL}`;
export const I18N_EMAIL = '[email protected]';
+216 -15
View File
@@ -1,21 +1,39 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UpdaterDownloadOption} from '@app/features/platform/types/Electron';
import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap';
import type {UpdaterEvent} from '@app/types/electron.d';
import {afterEach, describe, expect, test, vi} from 'vitest';
const {pushUpdateReadyModal} = vi.hoisted(() => ({pushUpdateReadyModal: vi.fn()}));
interface ManualUpdatePrompt {
options: ReadonlyArray<UpdaterDownloadOption>;
onDownload: (option: UpdaterDownloadOption) => void | Promise<void>;
}
const {
pushManualUpdateAvailableModal,
pushUnsupportedUpdateModal,
pushUpdateCheckFailedModal,
pushUpdateReadyModal,
pushUpToDateModal,
} = vi.hoisted(() => ({
pushManualUpdateAvailableModal: vi.fn<(prompt: ManualUpdatePrompt) => void>(),
pushUnsupportedUpdateModal: vi.fn(),
pushUpdateCheckFailedModal: vi.fn(),
pushUpdateReadyModal: vi.fn(),
pushUpToDateModal: vi.fn(),
}));
vi.mock('@app/features/updater/commands/UpdaterModalCommands', () => ({
pushDesktopUpdateDownloadFailedModal: vi.fn(),
pushDesktopUpdateInstallFailedModal: vi.fn(),
pushManualUpdateAvailableModal: vi.fn(),
pushUnsupportedUpdateModal: vi.fn(),
pushManualUpdateAvailableModal,
pushUnsupportedUpdateModal,
pushUpdateAvailableModal: vi.fn(),
pushUpdateCheckFailedModal: vi.fn(),
pushUpdateCheckFailedModal,
pushUpdateReadyModal,
pushUpToDateModal: vi.fn(),
pushUpToDateModal,
}));
vi.mock('@lingui/core/macro', () => ({
@@ -29,15 +47,30 @@ vi.mock('@app/features/platform/utils/ClientInfo', () => ({
installVoiceMenuTestBootstrap();
const REPORTED_VERSION = '2026.908.173325';
const NEWER_VERSION = '2026.909.202036';
const CANARY_X64 = 'https://api.canary.fluxer.app/dl/desktop/canary/linux/x64';
const REPORTED_DEB_DOWNLOAD = [
`${CANARY_X64}/${REPORTED_VERSION}/deb`,
`Fluxer-Canary-${REPORTED_VERSION}-linux-amd64.deb`,
];
const NEWER_DEB_DOWNLOAD = [`${CANARY_X64}/${NEWER_VERSION}/deb`, `Fluxer-Canary-${NEWER_VERSION}-linux-amd64.deb`];
let nativeEventListener: ((event: UpdaterEvent) => void) | null = null;
let onUpdaterCheck: (() => void) | null = null;
let onUpdaterCheck: ((context: string) => void | Promise<void>) | null = null;
let loadedUpdater: {dispose: () => void} | null = null;
function installElectronApi(): void {
const updaterCheck = vi.fn((context: string) => Promise.resolve(onUpdaterCheck?.(context)));
const downloadFile = vi.fn<(url: string, suggestedName: string) => Promise<{success: boolean}>>(() =>
Promise.resolve({success: true}),
);
const openExternal = vi.fn<(url: string) => Promise<void>>(() => Promise.resolve());
function installElectronApi(platform: string): void {
nativeEventListener = null;
onUpdaterCheck = null;
(window as unknown as {electron: unknown}).electron = {
platform: 'win32',
platform,
buildChannel: 'canary',
onUpdaterEvent: (listener: (event: UpdaterEvent) => void) => {
nativeEventListener = listener;
@@ -45,12 +78,11 @@ function installElectronApi(): void {
nativeEventListener = null;
};
},
updaterCheck: () => {
onUpdaterCheck?.();
return Promise.resolve();
},
updaterCheck,
updaterDownload: () => Promise.resolve(),
updaterInstall: () => Promise.resolve(),
downloadFile,
openExternal,
};
}
@@ -59,16 +91,16 @@ function emit(event: UpdaterEvent): void {
nativeEventListener(event);
}
async function loadUpdater() {
async function loadUpdater(platform = 'win32') {
vi.resetModules();
installElectronApi();
installElectronApi(platform);
const {default: Updater} = await import('@app/features/app/state/Updater');
loadedUpdater = Updater;
await vi.waitFor(() => {
expect(nativeEventListener).not.toBeNull();
expect(Updater.lastCheckedAt).not.toBeNull();
});
pushUpdateReadyModal.mockClear();
vi.clearAllMocks();
return Updater;
}
@@ -77,7 +109,37 @@ function emitUserDownloadCompletion(version: string): void {
emit({type: 'downloaded', context: 'user', version});
}
function emitLinuxManualUpdate(context: 'user' | 'background', version: string): void {
emit({
type: 'available',
context,
version,
downloadStarted: false,
downloadUrl: `${CANARY_X64}/latest/appimage`,
downloadOptions: [
{
format: 'deb',
label: 'DEB package',
url: `${CANARY_X64}/latest/deb`,
suggestedName: `Fluxer-Canary-${version}-linux-amd64.deb`,
sha256: `deb-${version}`,
},
],
});
}
async function openLinuxManualUpdatePrompt() {
const Updater = await loadUpdater('linux');
emitLinuxManualUpdate('background', REPORTED_VERSION);
await Updater.applyUpdate();
const prompt = pushManualUpdateAvailableModal.mock.lastCall?.[0];
const debOption = prompt?.options.find((option) => option.format === 'deb');
if (!prompt || !debOption) throw new Error('Manual update prompt never offered a deb package');
return {Updater, download: () => Promise.resolve(prompt.onDownload(debOption))};
}
afterEach(() => {
vi.useRealTimers();
loadedUpdater?.dispose();
loadedUpdater = null;
});
@@ -110,3 +172,142 @@ describe('updater update-ready surface', () => {
expect(pushUpdateReadyModal).toHaveBeenCalledTimes(1);
});
});
describe('updater manual Linux package download', () => {
test('refreshes the check as the user and saves a newer build under its own name', async () => {
const {Updater, download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => {
emit({type: 'checking', context: 'user'});
emitLinuxManualUpdate('user', NEWER_VERSION);
};
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([NEWER_DEB_DOWNLOAD]);
expect(pushManualUpdateAvailableModal).toHaveBeenCalledTimes(1);
expect(Updater.displayVersion).toBe(NEWER_VERSION);
expect(Updater.isChecking).toBe(false);
});
test('saves the build the prompt named when the refresh fails', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emit({type: 'error', context: 'user', message: 'fetch failed'});
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(pushUpdateCheckFailedModal).not.toHaveBeenCalled();
});
test('saves the build the prompt named when the refresh finds no update', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emit({type: 'not-available', context: 'user'});
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(pushUpToDateModal).not.toHaveBeenCalled();
});
test('saves the build the prompt named when the refresh reports an unsupported install', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emit({type: 'unsupported', context: 'user', reason: 'managed-package'});
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(pushUnsupportedUpdateModal).not.toHaveBeenCalled();
});
test('saves the build the prompt named when the refresh does not answer in time', async () => {
const {Updater, download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => new Promise<void>(() => {});
vi.useFakeTimers();
const downloaded = download();
await vi.advanceTimersByTimeAsync(5_000);
await downloaded;
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(Updater.isChecking).toBe(false);
});
test.each([
['a newer build', () => emitLinuxManualUpdate('user', NEWER_VERSION)],
['no update', () => emit({type: 'not-available', context: 'user'})],
['a failure', () => emit({type: 'error', context: 'user', message: 'fetch failed'})],
])('stays quiet when a timed out refresh answers with %s after the download', async (_answer, answerLate) => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => new Promise<void>(() => {});
vi.useFakeTimers();
const downloaded = download();
await vi.advanceTimersByTimeAsync(5_000);
await downloaded;
answerLate();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
expect(pushManualUpdateAvailableModal).toHaveBeenCalledTimes(1);
expect(pushUpToDateModal).not.toHaveBeenCalled();
expect(pushUpdateCheckFailedModal).not.toHaveBeenCalled();
});
test('saves the refreshed build when a late failure from an earlier refresh lands inside the next one', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => new Promise<void>(() => {});
vi.useFakeTimers();
const firstDownload = download();
await vi.advanceTimersByTimeAsync(5_000);
await firstDownload;
onUpdaterCheck = () => {
emit({type: 'error', context: 'user', message: 'fetch failed'});
emitLinuxManualUpdate('user', NEWER_VERSION);
};
await download();
expect(updaterCheck.mock.calls).toEqual([['user'], ['user']]);
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD, NEWER_DEB_DOWNLOAD]);
expect(pushManualUpdateAvailableModal).toHaveBeenCalledTimes(1);
expect(pushUpdateCheckFailedModal).not.toHaveBeenCalled();
});
test('still shows the failure of a user check made while a package is being saved', async () => {
const {Updater, download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emitLinuxManualUpdate('user', REPORTED_VERSION);
downloadFile.mockImplementationOnce(() => new Promise<{success: boolean}>(() => {}));
void download();
await vi.waitFor(() => expect(downloadFile).toHaveBeenCalledTimes(1));
onUpdaterCheck = () => emit({type: 'error', context: 'user', message: 'fetch failed'});
await Updater.checkForUpdates(true, true);
expect(updaterCheck.mock.calls).toEqual([['user'], ['user']]);
expect(pushUpdateCheckFailedModal).toHaveBeenCalledTimes(1);
});
test('does not refresh when a check is already running at the click', async () => {
const {Updater, download} = await openLinuxManualUpdatePrompt();
void Updater.checkForUpdates(true);
await download();
expect(updaterCheck).not.toHaveBeenCalled();
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
});
test('saves one package when the button is pressed twice during the refresh', async () => {
const {download} = await openLinuxManualUpdatePrompt();
let answerRefresh = () => {};
onUpdaterCheck = () =>
new Promise<void>((resolve) => {
answerRefresh = () => {
emitLinuxManualUpdate('user', NEWER_VERSION);
resolve();
};
});
const firstClick = download();
const secondClick = download();
answerRefresh();
await Promise.all([firstClick, secondClick]);
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(downloadFile.mock.calls).toEqual([NEWER_DEB_DOWNLOAD]);
});
test('opens the pinned link and never the download page when saving fails', async () => {
const {download} = await openLinuxManualUpdatePrompt();
onUpdaterCheck = () => emitLinuxManualUpdate('user', REPORTED_VERSION);
downloadFile.mockResolvedValueOnce({success: false});
await download();
expect(updaterCheck.mock.calls).toEqual([['user']]);
expect(openExternal.mock.calls).toEqual([[`${CANARY_X64}/${REPORTED_VERSION}/deb`]]);
});
});
+50 -2
View File
@@ -51,6 +51,7 @@ export const DOWNLOADING_UPDATE_DESCRIPTOR = msg({
const logger = new Logger('Updater');
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
const MIN_CHECK_INTERVAL_MS = 60 * 1000;
const MANUAL_DOWNLOAD_REFRESH_TIMEOUT_MS = 5 * 1000;
const VERSION_ENDPOINT = '/version.json';
const CURRENT_BUILD_VERSION = Config.PUBLIC_BUILD_VERSION ?? null;
const ALLOWED_WEB_UPDATE_HOSTS = new Set(['web.fluxer.app', 'web.canary.fluxer.app']);
@@ -132,6 +133,7 @@ class Updater {
private backgroundCheckCleanups: Array<() => void> = [];
private unsubscribeNativeEvents: (() => void) | null = null;
private updateReadyNagbarDismissedVersion: string | null = null;
private pendingManualDownloadRefreshes = 0;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
@@ -288,7 +290,17 @@ class Updater {
}
private handleNativeEvent(event: UpdaterEvent): void {
const isUserCheck = event.context === 'user';
const isManualDownloadRefreshResult =
event.context === 'user' &&
this.pendingManualDownloadRefreshes > 0 &&
(event.type === 'available' ||
event.type === 'not-available' ||
event.type === 'error' ||
event.type === 'unsupported');
if (isManualDownloadRefreshResult) {
this.pendingManualDownloadRefreshes -= 1;
}
const isUserCheck = event.context === 'user' && !isManualDownloadRefreshResult;
const isBackgroundOrFocusCheck = event.context === 'background' || event.context === 'focus';
const shouldSurfaceNativeDesktopUpdate = this.shouldSurfaceNativeDesktopUpdate();
const shouldShowImmediateUserResult = isUserCheck && !this.checkInProgress;
@@ -643,7 +655,7 @@ class Updater {
currentVersion: this.currentVersion,
version: this.updateInfo.native.version,
options: this.nativeManualDownloadOptions,
onDownload: (option) => this.downloadManualNativeUpdateOrOpen(option.url, option.suggestedName),
onDownload: (option) => this.downloadManualNativeUpdateOption(option),
});
return;
}
@@ -651,6 +663,42 @@ class Updater {
pushUpdateAvailableModal(this.updateInfo.native.version, () => this.downloadManualNativeUpdateOrOpen(url));
}
private async refreshManualNativeDownloadOption(option: UpdaterDownloadOption): Promise<UpdaterDownloadOption> {
if (this.checkInProgress) {
return option;
}
this.transition({type: 'check.started'});
let timeoutId: number | undefined;
const timedOut = new Promise<boolean>((resolve) => {
timeoutId = window.setTimeout(() => resolve(false), MANUAL_DOWNLOAD_REFRESH_TIMEOUT_MS);
});
try {
this.pendingManualDownloadRefreshes += 1;
const checked = await Promise.race([this.checkNativeUpdate('user'), timedOut]);
if (!checked) {
return option;
}
return this.nativeManualDownloadOptions.find((candidate) => candidate.format === option.format) ?? option;
} finally {
window.clearTimeout(timeoutId);
this.transition({type: 'check.finished', now: Date.now()});
}
}
private async downloadManualNativeUpdateOption(option: UpdaterDownloadOption): Promise<void> {
if (this.manualNativeDownloadInFlight) {
return;
}
this.transition({type: 'manualDownload.started'});
let currentOption = option;
try {
currentOption = await this.refreshManualNativeDownloadOption(option);
} finally {
this.transition({type: 'manualDownload.finished'});
}
await this.downloadManualNativeUpdateOrOpen(currentOption.url, currentOption.suggestedName);
}
private async downloadManualNativeUpdateOrOpen(url: string, suggestedName?: string): Promise<void> {
if (this.manualNativeDownloadInFlight) {
return;
@@ -92,7 +92,11 @@ 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 {
canSubmitComposerContent,
getComposerMessageContent,
hasVisibleMessageContent,
} from '@app/features/messaging/utils/MessageRequestUtils';
import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager';
import {
resolveTypedEmojiShortcodes,
@@ -267,6 +271,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 +501,7 @@ export const LexicalChannelTextareaContent = observer(
isSlotMenu,
onCursorMove,
handleSelect,
specialMentionsAllowed,
} = useLexicalAutocomplete({
channel,
handleRef,
@@ -526,7 +532,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,12 +718,16 @@ export const LexicalChannelTextareaContent = observer(
}, [channel.id, hasAttachments, hasPendingSticker]);
const showAttachments = hasAttachments;
const showStickers = hasPendingSticker;
const isOverCharacterLimit = trimmedMessageContent.length > maxMessageLength;
const canSubmit =
!textareaInputDisabled &&
!isSubmissionBlockedBySlowmode &&
!isOverCharacterLimit &&
(hasMessageContent || hasAttachments || hasPendingSticker);
const isOverCharacterLimit = composerMessageContent.length > maxMessageLength;
const canSubmit = canSubmitComposerContent({
inputDisabled: textareaInputDisabled,
isSubmissionBlockedBySlowmode,
isOverCharacterLimit,
hasMessageContent,
hasAttachments,
hasPendingSticker,
isEditingMessageOnMobile,
});
const {onSubmit} = useTextareaSubmit({
channelId: channel.id,
guildId: channel.guildId === undefined ? null : channel.guildId,
@@ -1343,9 +1357,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 +1422,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';
@@ -29,6 +30,7 @@ import {hasStyleableMessageText} from '@app/features/messaging/utils/FailedMessa
import {
buildExistingAttachmentEditReferences,
canSubmitEmptyMessageEdit,
isAttachmentOnlyMessage,
} from '@app/features/messaging/utils/MessageEditContentUtils';
import {retryFailedMessage} from '@app/features/messaging/utils/MessageRetryUtils';
import {NodeType} from '@app/features/messaging/utils/markdown/parser/Enums';
@@ -193,13 +195,13 @@ export const UserMessage = observer(() => {
if (message.messageSnapshots) {
return;
}
const content = (actualContent ?? '').trim();
const content = dropTrailingEmptyBlockquoteLines(actualContent ?? '').trim();
if (!content) {
if (isAttachmentOnlyMessage(message)) {
finishEditing();
return;
}
if (canSubmitEmptyMessageEdit(message)) {
if (message.content.length === 0) {
finishEditing();
return;
}
finishEditing();
void MessageCommands.edit(
channel.id,
@@ -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
@@ -1416,9 +1416,15 @@
{
"msgid": "Clear command"
},
{
"msgid": "Clear message text"
},
{
"msgid": "Clearer text"
},
{
"msgid": "Clearing the text of a message with attachments asks to delete the message unless this is on"
},
{
"msgid": "Closed"
},
@@ -1482,6 +1488,9 @@
{
"msgid": "Delete other members' messages in this channel. Pinning is controlled separately."
},
{
"msgid": "Delete prompt"
},
{
"msgid": "Delete user"
},
@@ -1518,6 +1527,9 @@
{
"msgid": "Email us and a person will review your account. Tell us you cannot verify by SMS and we will take it from there. Replies come by email, so this is not instant."
},
{
"msgid": "Empty edit"
},
{
"msgid": "Emulate decode codec (testing)"
},
@@ -1641,6 +1653,12 @@
{
"msgid": "Keep Neko still"
},
{
"msgid": "Keep attachments"
},
{
"msgid": "Keep attachments when clearing an edit"
},
{
"msgid": "Kicked {targetUserTag} from the community"
},
@@ -1674,6 +1692,9 @@
{
"msgid": "Mention everyone or any role in this channel (even if the role isn't set to be mentionable)."
},
{
"msgid": "Message editing"
},
{
"msgid": "Microphone ({deviceLabel})"
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
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) {

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