initial commit
This commit is contained in:
572
fluxer_app/src/lib/AccountStorage.tsx
Normal file
572
fluxer_app/src/lib/AccountStorage.tsx
Normal file
@@ -0,0 +1,572 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import type {RuntimeConfigSnapshot} from '~/stores/RuntimeConfigStore';
|
||||
|
||||
const logger = new Logger('AccountStorage');
|
||||
|
||||
const DB_NAME = 'FluxerAccounts';
|
||||
const DB_VERSION = 2;
|
||||
const STORE_NAME = 'accounts';
|
||||
|
||||
const hasIndexedDb = typeof indexedDB !== 'undefined';
|
||||
const hasLocalStorage = typeof localStorage !== 'undefined';
|
||||
|
||||
export interface UserData {
|
||||
username: string;
|
||||
discriminator: string;
|
||||
email?: string | null;
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
export interface StoredAccount {
|
||||
userId: string;
|
||||
token: string | null;
|
||||
userData?: UserData;
|
||||
localStorageData: Record<string, string>;
|
||||
managedStorageData?: Record<string, string>;
|
||||
lastActive: number;
|
||||
instance?: RuntimeConfigSnapshot;
|
||||
isValid?: boolean;
|
||||
}
|
||||
|
||||
type IdbOpenState = 'idle' | 'opening' | 'open' | 'failed';
|
||||
|
||||
const MANAGED_KEY_EXACT: ReadonlySet<string> = new Set([
|
||||
'token',
|
||||
'userId',
|
||||
|
||||
'runtimeConfig',
|
||||
'AccountManager',
|
||||
|
||||
'token',
|
||||
]);
|
||||
|
||||
const MANAGED_KEY_PREFIXES: ReadonlyArray<string> = ['mobx', 'mobx-persist', 'persist', 'fluxer'];
|
||||
|
||||
function isManagedKey(key: string): boolean {
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (MANAGED_KEY_EXACT.has(key)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const prefix of MANAGED_KEY_PREFIXES) {
|
||||
if (key.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function stableNow(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
||||
let timer: any = null;
|
||||
|
||||
try {
|
||||
const timeout = new Promise<T>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`Timeout: ${label} (${ms}ms)`)), ms);
|
||||
});
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AccountStorage {
|
||||
private db: IDBDatabase | null = null;
|
||||
private openPromise: Promise<IDBDatabase | null> | null = null;
|
||||
private openState: IdbOpenState = 'idle';
|
||||
|
||||
private memoryStore = new Map<string, StoredAccount>();
|
||||
|
||||
private storageSwapTail: Promise<void> = Promise.resolve();
|
||||
|
||||
private enqueueStorageSwap(fn: () => Promise<void>): Promise<void> {
|
||||
const run = async (): Promise<void> => {
|
||||
await fn();
|
||||
};
|
||||
|
||||
const next = this.storageSwapTail.then(run, run);
|
||||
this.storageSwapTail = next.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (!hasIndexedDb) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.openState === 'open') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.openState === 'opening' && this.openPromise) {
|
||||
await this.openPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
this.openState = 'opening';
|
||||
|
||||
this.openPromise = new Promise<IDBDatabase | null>((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => {
|
||||
this.openState = 'failed';
|
||||
logger.error('Failed to open IndexedDB', request.error);
|
||||
reject(request.error ?? new Error('IndexedDB open error'));
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const database = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||
database.createObjectStore(STORE_NAME, {keyPath: 'userId'}).createIndex('lastActive', 'lastActive');
|
||||
logger.debug('Created IndexedDB object store for accounts');
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
this.openState = 'open';
|
||||
resolve(this.db);
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await withTimeout(this.openPromise, 5000, 'IndexedDB open');
|
||||
} finally {
|
||||
this.openPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureDb(): Promise<void> {
|
||||
if (!hasIndexedDb) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.db) {
|
||||
try {
|
||||
await this.init();
|
||||
} catch (err) {
|
||||
logger.warn('IndexedDB init failed; using in-memory fallback', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private captureManagedStorageSnapshot(): Record<string, string> {
|
||||
if (!hasLocalStorage) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const snapshot: Record<string, string> = {};
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isManagedKey(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = localStorage.getItem(key);
|
||||
if (value != null) {
|
||||
snapshot[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private async applyManagedStorageSnapshot(snapshot: Record<string, string>): Promise<void> {
|
||||
if (!hasLocalStorage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingManagedKeys: Set<string> = new Set();
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isManagedKey(key)) {
|
||||
existingManagedKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(snapshot)) {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to set managed localStorage key ${key}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of existingManagedKeys) {
|
||||
if (snapshot[key] === undefined) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to remove managed localStorage key ${key}`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeRecord(record: StoredAccount): StoredAccount {
|
||||
const managed = record.managedStorageData ?? record.localStorageData ?? {};
|
||||
return {
|
||||
...record,
|
||||
localStorageData: managed,
|
||||
managedStorageData: managed,
|
||||
};
|
||||
}
|
||||
|
||||
private cloneStorageSnapshot(snapshot: Record<string, string>): Record<string, string> {
|
||||
const safe: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(snapshot)) {
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
safe[key] = typeof value === 'string' ? value : String(value);
|
||||
}
|
||||
|
||||
return safe;
|
||||
}
|
||||
|
||||
private cloneUserData(userData?: UserData): UserData | undefined {
|
||||
if (!userData) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {...userData};
|
||||
}
|
||||
|
||||
private cloneRuntimeConfig(instance?: RuntimeConfigSnapshot): RuntimeConfigSnapshot | undefined {
|
||||
if (!instance) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
apiEndpoint: instance.apiEndpoint,
|
||||
apiPublicEndpoint: instance.apiPublicEndpoint,
|
||||
gatewayEndpoint: instance.gatewayEndpoint,
|
||||
mediaEndpoint: instance.mediaEndpoint,
|
||||
cdnEndpoint: instance.cdnEndpoint,
|
||||
marketingEndpoint: instance.marketingEndpoint,
|
||||
adminEndpoint: instance.adminEndpoint,
|
||||
inviteEndpoint: instance.inviteEndpoint,
|
||||
giftEndpoint: instance.giftEndpoint,
|
||||
webAppEndpoint: instance.webAppEndpoint,
|
||||
captchaProvider: instance.captchaProvider,
|
||||
hcaptchaSiteKey: instance.hcaptchaSiteKey,
|
||||
turnstileSiteKey: instance.turnstileSiteKey,
|
||||
apiCodeVersion: instance.apiCodeVersion,
|
||||
features: {...instance.features},
|
||||
publicPushVapidKey: instance.publicPushVapidKey,
|
||||
};
|
||||
}
|
||||
|
||||
private sanitizeRecord(record: StoredAccount): StoredAccount {
|
||||
const managedSnapshot = this.cloneStorageSnapshot(record.managedStorageData ?? record.localStorageData ?? {});
|
||||
|
||||
return {
|
||||
userId: record.userId,
|
||||
token: record.token,
|
||||
userData: this.cloneUserData(record.userData),
|
||||
localStorageData: managedSnapshot,
|
||||
managedStorageData: managedSnapshot,
|
||||
lastActive: record.lastActive,
|
||||
instance: this.cloneRuntimeConfig(record.instance),
|
||||
isValid: record.isValid,
|
||||
};
|
||||
}
|
||||
|
||||
private isDataCloneError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const name = (error as {name?: unknown}).name;
|
||||
return name === 'DataCloneError';
|
||||
}
|
||||
|
||||
async stashAccountData(
|
||||
userId: string,
|
||||
token: string | null,
|
||||
userData?: UserData,
|
||||
instance?: RuntimeConfigSnapshot,
|
||||
): Promise<void> {
|
||||
if (!userId) {
|
||||
const error = new Error(`Invalid stashAccountData: missing userId`);
|
||||
logger.error('Invalid parameters for stashAccountData', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
const error = new Error(`Invalid stashAccountData: missing token for ${userId}`);
|
||||
logger.error('Invalid parameters for stashAccountData', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.ensureDb();
|
||||
|
||||
const managedStorageData = this.captureManagedStorageSnapshot();
|
||||
|
||||
const record: StoredAccount = {
|
||||
userId,
|
||||
token,
|
||||
userData,
|
||||
localStorageData: managedStorageData,
|
||||
managedStorageData,
|
||||
lastActive: stableNow(),
|
||||
instance,
|
||||
};
|
||||
|
||||
const safeRecord = this.sanitizeRecord(record);
|
||||
|
||||
try {
|
||||
if (!this.db) {
|
||||
this.memoryStore.set(userId, safeRecord);
|
||||
logger.debug(`Stashed account data for ${userId} (memory fallback)`);
|
||||
return;
|
||||
}
|
||||
|
||||
await withTimeout(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const tx = this.db!.transaction([STORE_NAME], 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
|
||||
const req = store.put(safeRecord);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error ?? new Error('IndexedDB put failed'));
|
||||
}),
|
||||
5000,
|
||||
'IndexedDB put account',
|
||||
);
|
||||
|
||||
logger.debug(`Stashed account data for ${userId} (idb)`);
|
||||
} catch (err) {
|
||||
if (this.isDataCloneError(err)) {
|
||||
logger.warn(`DataCloneError while stashing account ${userId}; using memory store`, err);
|
||||
this.memoryStore.set(userId, safeRecord);
|
||||
logger.debug(`Stashed account data for ${userId} (memory fallback after DataCloneError)`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error(`Failed to stash account data for ${userId}`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async restoreAccountData(userId: string): Promise<StoredAccount | null> {
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.ensureDb();
|
||||
|
||||
const record = await this.getRecord(userId);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = this.normalizeRecord(record);
|
||||
|
||||
await this.enqueueStorageSwap(async () => {
|
||||
await this.applyManagedStorageSnapshot(normalized.localStorageData ?? {});
|
||||
});
|
||||
|
||||
await this.updateLastActive(userId);
|
||||
|
||||
logger.debug(`Restored account data for ${userId}`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async getAllAccounts(): Promise<Array<StoredAccount>> {
|
||||
await this.ensureDb();
|
||||
|
||||
try {
|
||||
if (!this.db) {
|
||||
return Array.from(this.memoryStore.values()).map((r) => this.normalizeRecord(r));
|
||||
}
|
||||
|
||||
const records = await withTimeout(
|
||||
new Promise<Array<StoredAccount>>((resolve, reject) => {
|
||||
const tx = this.db!.transaction([STORE_NAME], 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.getAll();
|
||||
req.onsuccess = () => resolve((req.result as Array<StoredAccount>) ?? []);
|
||||
req.onerror = () => reject(req.error ?? new Error('IndexedDB getAll failed'));
|
||||
}),
|
||||
5000,
|
||||
'IndexedDB getAll accounts',
|
||||
);
|
||||
|
||||
return records.map((r) => this.normalizeRecord(r));
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch stored accounts', err);
|
||||
return Array.from(this.memoryStore.values()).map((r) => this.normalizeRecord(r));
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAccount(userId: string): Promise<void> {
|
||||
await this.ensureDb();
|
||||
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!this.db) {
|
||||
this.memoryStore.delete(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
await withTimeout(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const tx = this.db!.transaction([STORE_NAME], 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.delete(userId);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error ?? new Error('IndexedDB delete failed'));
|
||||
}),
|
||||
5000,
|
||||
'IndexedDB delete account',
|
||||
);
|
||||
|
||||
logger.debug(`Deleted account data for ${userId}`);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to delete account ${userId}`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async updateAccountUserData(userId: string, userData: UserData): Promise<void> {
|
||||
await this.ensureDb();
|
||||
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const record = await this.getRecord(userId);
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.putRecord({...record, userData});
|
||||
} catch (err) {
|
||||
logger.error(`Failed to update userData for account ${userId}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
async updateAccountValidity(userId: string, isValid: boolean): Promise<void> {
|
||||
await this.ensureDb();
|
||||
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const record = await this.getRecord(userId);
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.putRecord({...record, isValid});
|
||||
} catch (err) {
|
||||
logger.error(`Failed to update validity for account ${userId}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
private async getRecord(userId: string): Promise<StoredAccount | null> {
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.db) {
|
||||
return this.memoryStore.get(userId) ?? null;
|
||||
}
|
||||
|
||||
return await withTimeout(
|
||||
new Promise<StoredAccount | null>((resolve, reject) => {
|
||||
const tx = this.db!.transaction([STORE_NAME], 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.get(userId);
|
||||
req.onsuccess = () => resolve((req.result as StoredAccount | undefined) ?? null);
|
||||
req.onerror = () => reject(req.error ?? new Error('IndexedDB get failed'));
|
||||
}),
|
||||
5000,
|
||||
'IndexedDB get account',
|
||||
);
|
||||
}
|
||||
|
||||
private async putRecord(record: StoredAccount): Promise<void> {
|
||||
const normalized = this.normalizeRecord(record);
|
||||
|
||||
if (!this.db) {
|
||||
this.memoryStore.set(record.userId, normalized);
|
||||
return;
|
||||
}
|
||||
|
||||
await withTimeout(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const tx = this.db!.transaction([STORE_NAME], 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.put(normalized);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error ?? new Error('IndexedDB put failed'));
|
||||
}),
|
||||
5000,
|
||||
'IndexedDB put account record',
|
||||
);
|
||||
|
||||
this.memoryStore.set(record.userId, normalized);
|
||||
}
|
||||
|
||||
private async updateLastActive(userId: string): Promise<void> {
|
||||
const record = await this.getRecord(userId);
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.putRecord({...record, lastActive: stableNow()});
|
||||
}
|
||||
}
|
||||
|
||||
export default new AccountStorage();
|
||||
156
fluxer_app/src/lib/AppStorage.ts
Normal file
156
fluxer_app/src/lib/AppStorage.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
interface EnhancedStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
clear(): void;
|
||||
key(index: number): string | null;
|
||||
readonly length: number;
|
||||
|
||||
getJSON<T>(key: string, defaultValue?: T): T | null;
|
||||
setJSON<T>(key: string, value: T): void;
|
||||
keys(): Array<string>;
|
||||
}
|
||||
|
||||
function createStorage(storageType: 'local' | 'session' | 'memory' = 'local'): EnhancedStorage {
|
||||
let baseStorage: Storage | null = null;
|
||||
|
||||
if (storageType === 'local' || storageType === 'session') {
|
||||
try {
|
||||
baseStorage = storageType === 'local' ? localStorage : sessionStorage;
|
||||
baseStorage.setItem('__test__', '1');
|
||||
baseStorage.removeItem('__test__');
|
||||
} catch (_e) {
|
||||
baseStorage = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (baseStorage == null) {
|
||||
const memoryStore: Record<string, string> = {};
|
||||
|
||||
baseStorage = {
|
||||
getItem: (key) => (key in memoryStore ? memoryStore[key] : null),
|
||||
setItem: (key, value) => {
|
||||
memoryStore[key] = String(value);
|
||||
},
|
||||
removeItem: (key) => {
|
||||
delete memoryStore[key];
|
||||
},
|
||||
clear: () => {
|
||||
Object.keys(memoryStore).forEach((k) => {
|
||||
delete memoryStore[k];
|
||||
});
|
||||
},
|
||||
key: (index) => {
|
||||
const keys = Object.keys(memoryStore);
|
||||
return index >= 0 && index < keys.length ? keys[index] : null;
|
||||
},
|
||||
get length() {
|
||||
return Object.keys(memoryStore).length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const storage: EnhancedStorage = Object.create(null);
|
||||
|
||||
Object.defineProperties(storage, {
|
||||
getItem: {
|
||||
value: (key: string) => baseStorage!.getItem(key),
|
||||
writable: false,
|
||||
enumerable: false,
|
||||
},
|
||||
setItem: {
|
||||
value: (key: string, value: string) => baseStorage!.setItem(key, value),
|
||||
writable: false,
|
||||
enumerable: false,
|
||||
},
|
||||
removeItem: {
|
||||
value: (key: string) => baseStorage!.removeItem(key),
|
||||
writable: false,
|
||||
enumerable: false,
|
||||
},
|
||||
clear: {
|
||||
value: () => baseStorage!.clear(),
|
||||
writable: false,
|
||||
enumerable: false,
|
||||
},
|
||||
key: {
|
||||
value: (index: number) => baseStorage!.key(index),
|
||||
writable: false,
|
||||
enumerable: false,
|
||||
},
|
||||
length: {
|
||||
get: () => baseStorage!.length,
|
||||
enumerable: false,
|
||||
},
|
||||
|
||||
getJSON: {
|
||||
value: <T>(key: string, defaultValue?: T): T | null => {
|
||||
const item = baseStorage!.getItem(key);
|
||||
if (item === null) return defaultValue === undefined ? null : defaultValue;
|
||||
|
||||
try {
|
||||
return JSON.parse(item);
|
||||
} catch (e) {
|
||||
console.warn(`Failed to parse JSON for key "${key}":`, e);
|
||||
return defaultValue === undefined ? null : defaultValue;
|
||||
}
|
||||
},
|
||||
writable: false,
|
||||
enumerable: false,
|
||||
},
|
||||
setJSON: {
|
||||
value: <T>(key: string, value: T) => {
|
||||
if (value === storage) {
|
||||
throw new Error('Cannot store the storage object itself');
|
||||
}
|
||||
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
baseStorage!.setItem(key, serialized);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to store value for key "${key}": ${e}`);
|
||||
}
|
||||
},
|
||||
writable: false,
|
||||
enumerable: false,
|
||||
},
|
||||
keys: {
|
||||
value: (): Array<string> => {
|
||||
const result: Array<string> = [];
|
||||
for (let i = 0; i < baseStorage!.length; i++) {
|
||||
const key = baseStorage!.key(i);
|
||||
if (key !== null) {
|
||||
result.push(key);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
writable: false,
|
||||
enumerable: false,
|
||||
},
|
||||
});
|
||||
|
||||
return storage;
|
||||
}
|
||||
|
||||
const AppStorage = createStorage('local');
|
||||
export default AppStorage;
|
||||
165
fluxer_app/src/lib/CaptchaInterceptor.tsx
Normal file
165
fluxer_app/src/lib/CaptchaInterceptor.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {action, makeObservable, observable} from 'mobx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import {CaptchaModal, type CaptchaType} from '~/components/modals/CaptchaModal';
|
||||
import HttpClient, {type HttpResponse} from '~/lib/HttpClient';
|
||||
|
||||
export interface CaptchaResult {
|
||||
token: string;
|
||||
type: CaptchaType;
|
||||
}
|
||||
|
||||
class CaptchaState {
|
||||
error: string | null = null;
|
||||
isVerifying = false;
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
error: observable,
|
||||
isVerifying: observable,
|
||||
setError: action,
|
||||
setIsVerifying: action,
|
||||
reset: action,
|
||||
});
|
||||
}
|
||||
|
||||
setError(error: string | null) {
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
setIsVerifying(isVerifying: boolean) {
|
||||
this.isVerifying = isVerifying;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.error = null;
|
||||
this.isVerifying = false;
|
||||
}
|
||||
}
|
||||
|
||||
class CaptchaInterceptorStore {
|
||||
private state = new CaptchaState();
|
||||
private pendingPromise: {resolve: (result: CaptchaResult) => void; reject: (error: Error) => void} | null = null;
|
||||
private i18n: I18n | null = null;
|
||||
|
||||
setI18n(i18n: I18n) {
|
||||
this.i18n = i18n;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
HttpClient.setInterceptors({
|
||||
interceptResponse: this.intercept.bind(this),
|
||||
});
|
||||
}
|
||||
|
||||
private isCaptchaError(body: unknown): boolean {
|
||||
if (body && typeof body === 'object' && 'code' in body) {
|
||||
const code = (body as {code?: string}).code;
|
||||
return code === 'CAPTCHA_REQUIRED' || code === 'INVALID_CAPTCHA';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private showCaptchaModal(): Promise<CaptchaResult> {
|
||||
if (this.pendingPromise) {
|
||||
this.pendingPromise.reject(new Error('Captcha cancelled'));
|
||||
this.pendingPromise = null;
|
||||
}
|
||||
|
||||
this.state.reset();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pendingPromise = {resolve, reject};
|
||||
|
||||
const handleVerify = (token: string, captchaType: CaptchaType) => {
|
||||
const result = {token, type: captchaType};
|
||||
this.state.setIsVerifying(true);
|
||||
if (this.pendingPromise) {
|
||||
this.pendingPromise.resolve(result);
|
||||
this.pendingPromise = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
this.state.reset();
|
||||
if (this.pendingPromise) {
|
||||
this.pendingPromise.reject(new Error('Captcha cancelled'));
|
||||
this.pendingPromise = null;
|
||||
}
|
||||
ModalActionCreators.pop();
|
||||
};
|
||||
|
||||
const CaptchaModalWrapper = observer(() => (
|
||||
<CaptchaModal
|
||||
onVerify={handleVerify}
|
||||
onCancel={handleCancel}
|
||||
error={this.state.error}
|
||||
isVerifying={this.state.isVerifying}
|
||||
closeOnVerify={false}
|
||||
/>
|
||||
));
|
||||
|
||||
ModalActionCreators.push(modal(() => <CaptchaModalWrapper />));
|
||||
});
|
||||
}
|
||||
|
||||
private intercept(
|
||||
response: HttpResponse,
|
||||
retryWithHeaders: (headers: Record<string, string>) => Promise<HttpResponse>,
|
||||
reject: (error: Error) => void,
|
||||
): boolean | Promise<HttpResponse> | undefined {
|
||||
if (response.status === 400 && this.isCaptchaError(response.body)) {
|
||||
const errorBody = response.body as {message?: string};
|
||||
const i18n = this.i18n!;
|
||||
const errorMessage = errorBody?.message || i18n._(msg`Captcha verification failed. Please try again.`);
|
||||
|
||||
this.state.setError(errorMessage);
|
||||
this.state.setIsVerifying(false);
|
||||
|
||||
const promise = this.showCaptchaModal()
|
||||
.then((captchaResult) => {
|
||||
this.state.setError(null);
|
||||
this.state.setIsVerifying(false);
|
||||
ModalActionCreators.pop();
|
||||
return retryWithHeaders({
|
||||
'X-Captcha-Token': captchaResult.token,
|
||||
'X-Captcha-Type': captchaResult.type,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
this.state.reset();
|
||||
ModalActionCreators.pop();
|
||||
reject(error);
|
||||
throw error;
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptchaInterceptorStore();
|
||||
839
fluxer_app/src/lib/ChannelMessages.tsx
Normal file
839
fluxer_app/src/lib/ChannelMessages.tsx
Normal file
@@ -0,0 +1,839 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
JumpTypes,
|
||||
MAX_LOADED_MESSAGES,
|
||||
MAX_MESSAGE_CACHE_SIZE,
|
||||
MAX_MESSAGES_PER_CHANNEL,
|
||||
TRUNCATED_MESSAGE_VIEW_SIZE,
|
||||
} from '~/Constants';
|
||||
import {type Message, MessageRecord} from '~/records/MessageRecord';
|
||||
|
||||
type MessageId = string;
|
||||
type ChannelId = string;
|
||||
|
||||
const IS_MOBILE_CLIENT = /Mobi|Android/i.test(navigator.userAgent);
|
||||
|
||||
export interface JumpOptions {
|
||||
messageId?: MessageId | null;
|
||||
offset?: number;
|
||||
present?: boolean;
|
||||
flash?: boolean;
|
||||
returnMessageId?: MessageId | null;
|
||||
jumpType?: JumpTypes;
|
||||
}
|
||||
|
||||
interface LoadCompleteOptions {
|
||||
newMessages: Array<Message>;
|
||||
isBefore?: boolean;
|
||||
isAfter?: boolean;
|
||||
jump?: JumpOptions | null;
|
||||
hasMoreBefore?: boolean;
|
||||
hasMoreAfter?: boolean;
|
||||
cached?: boolean;
|
||||
}
|
||||
|
||||
function shouldUseIncoming(existing: MessageRecord, incoming: Message): boolean {
|
||||
const previousEdit = existing.editedTimestamp != null ? +existing.editedTimestamp : 0;
|
||||
const nextEdit = incoming.edited_timestamp != null ? +new Date(incoming.edited_timestamp) : 0;
|
||||
|
||||
if (nextEdit > previousEdit) return true;
|
||||
if (existing.embeds.length < (incoming.embeds?.length ?? 0)) return true;
|
||||
if (existing.content !== incoming.content) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function hydrateMessage(channelMessages: ChannelMessages, raw: Message): MessageRecord {
|
||||
const current = channelMessages.get(raw.id);
|
||||
if (!current || channelMessages.cached || shouldUseIncoming(current, raw)) {
|
||||
return new MessageRecord(raw);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
class MessageBufferSegment {
|
||||
private readonly fromOlderSide: boolean;
|
||||
private items: Array<MessageRecord> = [];
|
||||
private keyedById: Record<MessageId, MessageRecord> = {};
|
||||
private reachedBoundary = false;
|
||||
|
||||
constructor(fromOlderSide: boolean) {
|
||||
this.fromOlderSide = fromOlderSide;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.items.length;
|
||||
}
|
||||
|
||||
get messages(): Array<MessageRecord> {
|
||||
return this.items;
|
||||
}
|
||||
|
||||
get isBoundary(): boolean {
|
||||
return this.reachedBoundary;
|
||||
}
|
||||
|
||||
set isBoundary(value: boolean) {
|
||||
this.reachedBoundary = value;
|
||||
}
|
||||
|
||||
clone(): MessageBufferSegment {
|
||||
const clone = new MessageBufferSegment(this.fromOlderSide);
|
||||
clone.items = [...this.items];
|
||||
clone.keyedById = {...this.keyedById};
|
||||
clone.reachedBoundary = this.reachedBoundary;
|
||||
return clone;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.items = [];
|
||||
this.keyedById = {};
|
||||
this.reachedBoundary = false;
|
||||
}
|
||||
|
||||
has(id: MessageId): boolean {
|
||||
return this.keyedById[id] != null;
|
||||
}
|
||||
|
||||
get(id: MessageId): MessageRecord | undefined {
|
||||
return this.keyedById[id];
|
||||
}
|
||||
|
||||
remove(id: MessageId): void {
|
||||
if (!this.keyedById[id]) return;
|
||||
delete this.keyedById[id];
|
||||
this.items = this.items.filter((m) => m.id !== id);
|
||||
}
|
||||
|
||||
removeMany(ids: Array<MessageId>): void {
|
||||
if (!ids.length) return;
|
||||
|
||||
for (const id of ids) {
|
||||
delete this.keyedById[id];
|
||||
}
|
||||
|
||||
this.items = this.items.filter((m) => !ids.includes(m.id));
|
||||
}
|
||||
|
||||
replace(previousId: MessageId, next: MessageRecord): void {
|
||||
const existing = this.keyedById[previousId];
|
||||
if (!existing) return;
|
||||
|
||||
delete this.keyedById[previousId];
|
||||
this.keyedById[next.id] = next;
|
||||
|
||||
const idx = this.items.indexOf(existing);
|
||||
if (idx >= 0) this.items[idx] = next;
|
||||
}
|
||||
|
||||
update(id: MessageId, updater: (m: MessageRecord) => MessageRecord): void {
|
||||
const current = this.keyedById[id];
|
||||
if (!current) return;
|
||||
|
||||
const updated = updater(current);
|
||||
this.keyedById[id] = updated;
|
||||
|
||||
const idx = this.items.indexOf(current);
|
||||
if (idx >= 0) this.items[idx] = updated;
|
||||
}
|
||||
|
||||
forEach(cb: (m: MessageRecord, index: number, arr: Array<MessageRecord>) => void, thisArg?: unknown): void {
|
||||
this.items.forEach(cb, thisArg);
|
||||
}
|
||||
|
||||
cache(batch: Array<MessageRecord>, boundaryAtInsert = false): void {
|
||||
if (this.items.length === 0) {
|
||||
this.reachedBoundary = boundaryAtInsert;
|
||||
}
|
||||
|
||||
const combinedSize = this.items.length + batch.length;
|
||||
|
||||
if (combinedSize > MAX_MESSAGE_CACHE_SIZE) {
|
||||
this.reachedBoundary = false;
|
||||
|
||||
if (batch.length >= MAX_MESSAGE_CACHE_SIZE) {
|
||||
this.items = this.fromOlderSide
|
||||
? batch.slice(batch.length - MAX_MESSAGE_CACHE_SIZE)
|
||||
: batch.slice(0, MAX_MESSAGE_CACHE_SIZE);
|
||||
} else {
|
||||
const available = MAX_MESSAGE_CACHE_SIZE - batch.length;
|
||||
this.items = this.fromOlderSide
|
||||
? this.items.slice(Math.max(this.items.length - available, 0))
|
||||
: this.items.slice(0, available);
|
||||
}
|
||||
}
|
||||
|
||||
this.items = this.fromOlderSide ? [...this.items, ...batch] : [...batch, ...this.items];
|
||||
|
||||
this.keyedById = {};
|
||||
for (const msg of this.items) {
|
||||
this.keyedById[msg.id] = msg;
|
||||
}
|
||||
}
|
||||
|
||||
takeAll(): Array<MessageRecord> {
|
||||
const all = this.items;
|
||||
this.items = [];
|
||||
this.keyedById = {};
|
||||
return all;
|
||||
}
|
||||
|
||||
take(count: number): Array<MessageRecord> {
|
||||
if (count <= 0 || this.items.length === 0) return [];
|
||||
|
||||
let extracted: Array<MessageRecord>;
|
||||
|
||||
if (this.fromOlderSide) {
|
||||
const start = Math.max(this.items.length - count, 0);
|
||||
extracted = this.items.slice(start);
|
||||
this.items.splice(start);
|
||||
} else {
|
||||
const end = Math.min(count, this.items.length);
|
||||
extracted = this.items.slice(0, end);
|
||||
this.items.splice(0, end);
|
||||
}
|
||||
|
||||
for (const msg of extracted) {
|
||||
delete this.keyedById[msg.id];
|
||||
}
|
||||
|
||||
return extracted;
|
||||
}
|
||||
}
|
||||
|
||||
export class ChannelMessages {
|
||||
private static readonly channelCache = new Map<ChannelId, ChannelMessages>();
|
||||
private static readonly maxChannelsInMemory = 50;
|
||||
private static accessSequence: Array<ChannelId> = [];
|
||||
|
||||
readonly channelId: ChannelId;
|
||||
|
||||
ready = false;
|
||||
|
||||
jumpType: JumpTypes = JumpTypes.ANIMATED;
|
||||
jumpTargetId: MessageId | null = null;
|
||||
jumpTargetOffset = 0;
|
||||
jumpSequenceId = 1;
|
||||
jumped = false;
|
||||
jumpedToPresent = false;
|
||||
jumpFlash = true;
|
||||
jumpReturnTargetId: MessageId | null = null;
|
||||
|
||||
hasMoreBefore = true;
|
||||
hasMoreAfter = false;
|
||||
loadingMore = false;
|
||||
revealedMessageId: MessageId | null = null;
|
||||
cached = false;
|
||||
error = false;
|
||||
version = 0;
|
||||
|
||||
private messageList: Array<MessageRecord> = [];
|
||||
private messageIndex: Record<MessageId, MessageRecord> = {};
|
||||
private beforeBuffer: MessageBufferSegment;
|
||||
private afterBuffer: MessageBufferSegment;
|
||||
|
||||
static forEach(callback: (messages: ChannelMessages, channelId: ChannelId) => void): void {
|
||||
for (const [id, messages] of ChannelMessages.channelCache) {
|
||||
callback(messages, id);
|
||||
}
|
||||
}
|
||||
|
||||
static get(channelId: ChannelId): ChannelMessages | undefined {
|
||||
return ChannelMessages.channelCache.get(channelId);
|
||||
}
|
||||
|
||||
static hasPresent(channelId: ChannelId): boolean {
|
||||
return ChannelMessages.get(channelId)?.hasPresent() ?? false;
|
||||
}
|
||||
|
||||
static getOrCreate(channelId: ChannelId): ChannelMessages {
|
||||
let instance = ChannelMessages.channelCache.get(channelId);
|
||||
|
||||
if (!instance) {
|
||||
instance = new ChannelMessages(channelId);
|
||||
ChannelMessages.channelCache.set(channelId, instance);
|
||||
ChannelMessages.evictIfNeeded();
|
||||
}
|
||||
|
||||
ChannelMessages.markTouched(channelId);
|
||||
return instance;
|
||||
}
|
||||
|
||||
static clear(channelId: ChannelId): void {
|
||||
ChannelMessages.channelCache.delete(channelId);
|
||||
const idx = ChannelMessages.accessSequence.indexOf(channelId);
|
||||
if (idx >= 0) ChannelMessages.accessSequence.splice(idx, 1);
|
||||
}
|
||||
|
||||
static clearCache(channelId: ChannelId): void {
|
||||
const instance = ChannelMessages.channelCache.get(channelId);
|
||||
if (!instance) return;
|
||||
|
||||
instance.beforeBuffer.clear();
|
||||
instance.afterBuffer.clear();
|
||||
ChannelMessages.save(instance);
|
||||
}
|
||||
|
||||
static commit(instance: ChannelMessages): ChannelMessages {
|
||||
ChannelMessages.channelCache.set(instance.channelId, instance);
|
||||
ChannelMessages.markTouched(instance.channelId);
|
||||
return instance;
|
||||
}
|
||||
|
||||
static save(instance: ChannelMessages): void {
|
||||
ChannelMessages.channelCache.set(instance.channelId, instance);
|
||||
}
|
||||
|
||||
private static markTouched(channelId: ChannelId): void {
|
||||
const existingIndex = ChannelMessages.accessSequence.indexOf(channelId);
|
||||
if (existingIndex >= 0) {
|
||||
ChannelMessages.accessSequence.splice(existingIndex, 1);
|
||||
}
|
||||
ChannelMessages.accessSequence.push(channelId);
|
||||
}
|
||||
|
||||
private static evictIfNeeded(): void {
|
||||
while (ChannelMessages.channelCache.size > ChannelMessages.maxChannelsInMemory) {
|
||||
const oldest = ChannelMessages.accessSequence.shift();
|
||||
if (oldest) {
|
||||
ChannelMessages.channelCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
constructor(channelId: ChannelId) {
|
||||
this.channelId = channelId;
|
||||
this.beforeBuffer = new MessageBufferSegment(true);
|
||||
this.afterBuffer = new MessageBufferSegment(false);
|
||||
}
|
||||
|
||||
mutate(patch: Partial<ChannelMessages>): ChannelMessages {
|
||||
return this.cloneAnd(patch);
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.messageList.length;
|
||||
}
|
||||
|
||||
toArray(): Array<MessageRecord> {
|
||||
return [...this.messageList];
|
||||
}
|
||||
|
||||
forEach(
|
||||
callback: (message: MessageRecord, index: number) => boolean | undefined,
|
||||
thisArg?: unknown,
|
||||
reverse = false,
|
||||
): void {
|
||||
if (reverse) {
|
||||
for (let i = this.messageList.length - 1; i >= 0; i--) {
|
||||
if (callback.call(thisArg, this.messageList[i], i) === false) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.messageList.forEach(callback, thisArg);
|
||||
}
|
||||
|
||||
reduce<T>(
|
||||
reducer: (memo: T, message: MessageRecord, index: number, array: Array<MessageRecord>) => T,
|
||||
initial: T,
|
||||
): T {
|
||||
return this.messageList.reduce(reducer, initial);
|
||||
}
|
||||
|
||||
forAll(callback: (m: MessageRecord, idx: number, arr: Array<MessageRecord>) => void, thisArg?: unknown): void {
|
||||
this.beforeBuffer.forEach(callback, thisArg);
|
||||
this.messageList.forEach(callback, thisArg);
|
||||
this.afterBuffer.forEach(callback, thisArg);
|
||||
}
|
||||
|
||||
findOldest(predicate: (m: MessageRecord) => boolean): MessageRecord | undefined {
|
||||
return (
|
||||
this.beforeBuffer.messages.find(predicate) ??
|
||||
this.messageList.find(predicate) ??
|
||||
this.afterBuffer.messages.find(predicate)
|
||||
);
|
||||
}
|
||||
|
||||
findNewest(predicate: (m: MessageRecord) => boolean): MessageRecord | undefined {
|
||||
const after = this.afterBuffer.messages;
|
||||
for (let i = after.length - 1; i >= 0; i--) {
|
||||
if (predicate(after[i])) return after[i];
|
||||
}
|
||||
|
||||
for (let i = this.messageList.length - 1; i >= 0; i--) {
|
||||
if (predicate(this.messageList[i])) return this.messageList[i];
|
||||
}
|
||||
|
||||
const before = this.beforeBuffer.messages;
|
||||
for (let i = before.length - 1; i >= 0; i--) {
|
||||
if (predicate(before[i])) return before[i];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
map<T>(mapper: (m: MessageRecord, idx: number, arr: Array<MessageRecord>) => T, thisArg?: unknown): Array<T> {
|
||||
return this.messageList.map(mapper, thisArg);
|
||||
}
|
||||
|
||||
first(): MessageRecord | undefined {
|
||||
return this.messageList[0];
|
||||
}
|
||||
|
||||
last(): MessageRecord | undefined {
|
||||
return this.messageList[this.messageList.length - 1];
|
||||
}
|
||||
|
||||
get(id: MessageId, checkBuffers = false): MessageRecord | undefined {
|
||||
const local = this.messageIndex[id];
|
||||
if (local || !checkBuffers) return local;
|
||||
return this.beforeBuffer.get(id) ?? this.afterBuffer.get(id);
|
||||
}
|
||||
|
||||
getByIndex(index: number): MessageRecord | undefined {
|
||||
return this.messageList[index];
|
||||
}
|
||||
|
||||
getAfter(id: MessageId): MessageRecord | null {
|
||||
const current = this.get(id);
|
||||
if (!current) return null;
|
||||
|
||||
const idx = this.messageList.indexOf(current);
|
||||
if (idx < 0 || idx === this.messageList.length - 1) return null;
|
||||
|
||||
return this.messageList[idx + 1] ?? null;
|
||||
}
|
||||
|
||||
has(id: MessageId, checkBuffers = true): boolean {
|
||||
if (this.messageIndex[id]) return true;
|
||||
if (!checkBuffers) return false;
|
||||
return this.beforeBuffer.has(id) || this.afterBuffer.has(id);
|
||||
}
|
||||
|
||||
indexOf(id: MessageId): number {
|
||||
return this.messageList.findIndex((m) => m.id === id);
|
||||
}
|
||||
|
||||
hasPresent(): boolean {
|
||||
return (this.afterBuffer.size > 0 && this.afterBuffer.isBoundary) || !this.hasMoreAfter;
|
||||
}
|
||||
|
||||
hasBeforeCached(beforeId: MessageId): boolean {
|
||||
if (this.messageList.length === 0 || this.beforeBuffer.size === 0) {
|
||||
return false;
|
||||
}
|
||||
const first = this.first();
|
||||
return Boolean(first && first.id === beforeId);
|
||||
}
|
||||
|
||||
hasAfterCached(afterId: MessageId): boolean {
|
||||
if (this.messageList.length === 0 || this.afterBuffer.size === 0) {
|
||||
return false;
|
||||
}
|
||||
const last = this.last();
|
||||
return Boolean(last && last.id === afterId);
|
||||
}
|
||||
|
||||
update(id: MessageId, updater: (m: MessageRecord) => MessageRecord): ChannelMessages {
|
||||
const current = this.messageIndex[id];
|
||||
|
||||
if (!current) {
|
||||
if (this.beforeBuffer.has(id)) {
|
||||
return this.cloneAnd((draft) => draft.beforeBuffer.update(id, updater), true);
|
||||
}
|
||||
if (this.afterBuffer.has(id)) {
|
||||
return this.cloneAnd((draft) => draft.afterBuffer.update(id, updater), true);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
const updated = updater(current);
|
||||
|
||||
return this.cloneAnd((draft) => {
|
||||
draft.messageIndex[current.id] = updated;
|
||||
const idx = draft.messageList.indexOf(current);
|
||||
if (idx >= 0) draft.messageList[idx] = updated;
|
||||
}, true);
|
||||
}
|
||||
|
||||
replace(previousId: MessageId, next: MessageRecord): ChannelMessages {
|
||||
const current = this.messageIndex[previousId];
|
||||
|
||||
if (!current) {
|
||||
if (this.beforeBuffer.has(previousId)) {
|
||||
return this.cloneAnd((draft) => draft.beforeBuffer.replace(previousId, next), true);
|
||||
}
|
||||
if (this.afterBuffer.has(previousId)) {
|
||||
return this.cloneAnd((draft) => draft.afterBuffer.replace(previousId, next), true);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
return this.cloneAnd((draft) => {
|
||||
delete draft.messageIndex[previousId];
|
||||
draft.messageIndex[next.id] = next;
|
||||
|
||||
const idx = draft.messageList.indexOf(current);
|
||||
if (idx >= 0) draft.messageList[idx] = next;
|
||||
}, true);
|
||||
}
|
||||
|
||||
remove(id: MessageId): ChannelMessages {
|
||||
return this.cloneAnd((draft) => {
|
||||
delete draft.messageIndex[id];
|
||||
draft.messageList = draft.messageList.filter((m) => m.id !== id);
|
||||
draft.beforeBuffer.remove(id);
|
||||
draft.afterBuffer.remove(id);
|
||||
}, true);
|
||||
}
|
||||
|
||||
removeMany(ids: Array<MessageId>): ChannelMessages {
|
||||
if (!ids.some((id) => this.has(id))) return this;
|
||||
|
||||
return this.cloneAnd((draft) => {
|
||||
for (const id of ids) {
|
||||
delete draft.messageIndex[id];
|
||||
}
|
||||
draft.messageList = draft.messageList.filter((m) => !ids.includes(m.id));
|
||||
draft.beforeBuffer.removeMany(ids);
|
||||
draft.afterBuffer.removeMany(ids);
|
||||
}, true);
|
||||
}
|
||||
|
||||
merge(records: Array<MessageRecord>, prepend = false, clearBuffer = false): ChannelMessages {
|
||||
return this.cloneAnd((draft) => {
|
||||
draft.mergeInto(records, prepend, clearBuffer);
|
||||
}, true);
|
||||
}
|
||||
|
||||
reset(records: Array<MessageRecord>): ChannelMessages {
|
||||
return this.cloneAnd((draft) => {
|
||||
draft.messageList = records;
|
||||
draft.messageIndex = {};
|
||||
for (const m of records) {
|
||||
draft.messageIndex[m.id] = m;
|
||||
}
|
||||
draft.beforeBuffer.clear();
|
||||
draft.afterBuffer.clear();
|
||||
}, true);
|
||||
}
|
||||
|
||||
truncateTop(maxCount: number, deepCopy = true): ChannelMessages {
|
||||
const overflow = this.messageList.length - maxCount;
|
||||
if (overflow <= 0) return this;
|
||||
|
||||
return this.cloneAnd((draft) => {
|
||||
for (let i = 0; i < overflow; i++) {
|
||||
delete draft.messageIndex[draft.messageList[i].id];
|
||||
}
|
||||
|
||||
draft.beforeBuffer.cache(draft.messageList.slice(0, overflow), !draft.hasMoreBefore);
|
||||
draft.messageList = draft.messageList.slice(overflow);
|
||||
draft.hasMoreBefore = true;
|
||||
}, deepCopy);
|
||||
}
|
||||
|
||||
truncateBottom(maxCount: number, deepCopy = true): ChannelMessages {
|
||||
if (this.messageList.length <= maxCount) return this;
|
||||
|
||||
return this.cloneAnd((draft) => {
|
||||
for (let i = maxCount; i < this.messageList.length; i++) {
|
||||
delete draft.messageIndex[draft.messageList[i].id];
|
||||
}
|
||||
|
||||
draft.afterBuffer.cache(draft.messageList.slice(maxCount, this.messageList.length), !draft.hasMoreAfter);
|
||||
draft.messageList = draft.messageList.slice(0, maxCount);
|
||||
draft.hasMoreAfter = true;
|
||||
}, deepCopy);
|
||||
}
|
||||
|
||||
truncate(trimBottom: boolean, trimTop: boolean): ChannelMessages {
|
||||
if (this.length <= MAX_LOADED_MESSAGES) return this;
|
||||
|
||||
if (trimBottom) {
|
||||
return this.truncateBottom(TRUNCATED_MESSAGE_VIEW_SIZE);
|
||||
}
|
||||
|
||||
if (trimTop) {
|
||||
return this.truncateTop(TRUNCATED_MESSAGE_VIEW_SIZE);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
jumpToPresent(limit: number): ChannelMessages {
|
||||
return this.cloneAnd((draft) => {
|
||||
const allAfter = draft.afterBuffer.takeAll();
|
||||
draft.hasMoreAfter = false;
|
||||
|
||||
const startIndex = Math.max(allAfter.length - limit, 0);
|
||||
const visible = allAfter.slice(startIndex);
|
||||
const remaining = allAfter.slice(0, startIndex);
|
||||
|
||||
draft.beforeBuffer.cache(draft.messageList);
|
||||
draft.beforeBuffer.cache(remaining);
|
||||
|
||||
draft.clearAllMessages();
|
||||
draft.mergeInto(visible);
|
||||
|
||||
draft.hasMoreBefore = draft.beforeBuffer.size > 0;
|
||||
draft.jumped = true;
|
||||
draft.jumpTargetId = null;
|
||||
draft.jumpTargetOffset = 0;
|
||||
draft.jumpedToPresent = true;
|
||||
draft.jumpFlash = false;
|
||||
draft.jumpReturnTargetId = null;
|
||||
draft.jumpSequenceId += 1;
|
||||
draft.ready = true;
|
||||
draft.loadingMore = false;
|
||||
}, true);
|
||||
}
|
||||
|
||||
jumpToMessage(
|
||||
messageId: MessageId,
|
||||
flash = true,
|
||||
offset?: number,
|
||||
returnTargetId?: MessageId | null,
|
||||
jumpType?: JumpTypes,
|
||||
): ChannelMessages {
|
||||
return this.cloneAnd((draft) => {
|
||||
draft.jumped = true;
|
||||
draft.jumpedToPresent = false;
|
||||
draft.jumpType = jumpType ?? JumpTypes.ANIMATED;
|
||||
draft.jumpTargetId = messageId;
|
||||
draft.jumpTargetOffset = messageId && offset != null ? offset : 0;
|
||||
draft.jumpSequenceId += 1;
|
||||
draft.jumpFlash = flash;
|
||||
draft.jumpReturnTargetId = returnTargetId ?? null;
|
||||
draft.ready = true;
|
||||
draft.loadingMore = false;
|
||||
}, false);
|
||||
}
|
||||
|
||||
loadFromCache(before: boolean, limit: number): ChannelMessages {
|
||||
let next = this.cloneAnd((draft) => {
|
||||
const buffer = before ? draft.beforeBuffer : draft.afterBuffer;
|
||||
draft.mergeInto(buffer.take(limit), before);
|
||||
|
||||
const hasMore = buffer.size > 0 || !buffer.isBoundary;
|
||||
if (before) draft.hasMoreBefore = hasMore;
|
||||
else draft.hasMoreAfter = hasMore;
|
||||
|
||||
draft.ready = true;
|
||||
draft.loadingMore = false;
|
||||
}, true);
|
||||
|
||||
if (before) {
|
||||
next = next.truncate(true, false);
|
||||
} else {
|
||||
next = next.truncate(false, true);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
receiveMessage(message: Message, truncateFromTop = true): ChannelMessages {
|
||||
const possibleNonce = message.nonce ?? null;
|
||||
const previous = possibleNonce ? this.get(possibleNonce, true) : null;
|
||||
|
||||
if (
|
||||
previous &&
|
||||
message.author.id === previous.author.id &&
|
||||
message.nonce != null &&
|
||||
previous.id === message.nonce
|
||||
) {
|
||||
const updated = new MessageRecord(message);
|
||||
return this.replace(message.nonce, updated);
|
||||
}
|
||||
|
||||
if (this.hasMoreAfter) {
|
||||
if (this.afterBuffer.isBoundary) {
|
||||
this.afterBuffer.isBoundary = false;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
const merged = this.merge([hydrateMessage(this, message)]);
|
||||
|
||||
if (truncateFromTop) {
|
||||
return merged.truncateTop(IS_MOBILE_CLIENT ? MAX_MESSAGES_PER_CHANNEL : TRUNCATED_MESSAGE_VIEW_SIZE, false);
|
||||
}
|
||||
|
||||
if (this.length > MAX_LOADED_MESSAGES) {
|
||||
return merged.truncateBottom(IS_MOBILE_CLIENT ? MAX_MESSAGES_PER_CHANNEL : TRUNCATED_MESSAGE_VIEW_SIZE, false);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
receivePushNotification(message: Message): ChannelMessages {
|
||||
const possibleNonce = message.nonce ?? null;
|
||||
const existing = possibleNonce ? this.get(possibleNonce, true) : null;
|
||||
if (existing) return this;
|
||||
|
||||
return this.cloneAnd({ready: true, cached: true}).merge([hydrateMessage(this, message)]);
|
||||
}
|
||||
|
||||
loadStart(jump?: JumpOptions): ChannelMessages {
|
||||
return this.cloneAnd({
|
||||
loadingMore: true,
|
||||
jumped: jump != null,
|
||||
jumpedToPresent: jump?.present ?? false,
|
||||
jumpTargetId: jump?.messageId ?? null,
|
||||
jumpTargetOffset: jump?.offset ?? 0,
|
||||
jumpReturnTargetId: jump?.returnMessageId ?? null,
|
||||
ready: jump ? false : this.ready,
|
||||
});
|
||||
}
|
||||
|
||||
loadComplete(options: LoadCompleteOptions): ChannelMessages {
|
||||
const {
|
||||
newMessages,
|
||||
isBefore = false,
|
||||
isAfter = false,
|
||||
jump = null,
|
||||
hasMoreBefore = false,
|
||||
hasMoreAfter = false,
|
||||
cached = false,
|
||||
} = options;
|
||||
|
||||
const records = [...newMessages].reverse().map((m) => hydrateMessage(this, m));
|
||||
|
||||
let next: ChannelMessages;
|
||||
|
||||
if ((!isBefore && !isAfter) || jump || !this.ready) {
|
||||
next = this.reset(records);
|
||||
} else {
|
||||
next = this.merge(records, isBefore, true);
|
||||
if (isBefore) {
|
||||
next = next.truncate(true, false);
|
||||
} else if (isAfter) {
|
||||
next = next.truncate(false, true);
|
||||
}
|
||||
}
|
||||
|
||||
next = next.cloneAnd({
|
||||
ready: true,
|
||||
loadingMore: false,
|
||||
jumpType: jump?.jumpType ?? JumpTypes.ANIMATED,
|
||||
jumpFlash: jump?.flash ?? false,
|
||||
jumped: jump != null,
|
||||
jumpedToPresent: jump?.present ?? false,
|
||||
jumpTargetId: jump?.messageId ?? null,
|
||||
jumpTargetOffset: jump && jump.messageId != null && jump.offset != null ? jump.offset : 0,
|
||||
jumpSequenceId: jump ? next.jumpSequenceId + 1 : next.jumpSequenceId,
|
||||
jumpReturnTargetId: jump?.returnMessageId ?? null,
|
||||
hasMoreBefore: jump == null && isAfter ? next.hasMoreBefore : hasMoreBefore,
|
||||
hasMoreAfter: jump == null && isBefore ? next.hasMoreAfter : hasMoreAfter,
|
||||
cached,
|
||||
error: false,
|
||||
});
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
private clearAllMessages(): void {
|
||||
this.messageList = [];
|
||||
this.messageIndex = {};
|
||||
}
|
||||
|
||||
private mergeInto(incoming: Array<MessageRecord>, prepend = false, clearSideBuffer = false): void {
|
||||
const newItems: Array<MessageRecord> = [];
|
||||
|
||||
for (const msg of incoming) {
|
||||
const existing = this.messageIndex[msg.id];
|
||||
this.messageIndex[msg.id] = msg;
|
||||
|
||||
if (existing) {
|
||||
const idx = this.messageList.indexOf(existing);
|
||||
if (idx >= 0) this.messageList[idx] = msg;
|
||||
} else {
|
||||
newItems.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
if (clearSideBuffer) {
|
||||
const buffer = prepend ? this.beforeBuffer : this.afterBuffer;
|
||||
buffer.clear();
|
||||
}
|
||||
|
||||
this.messageList = prepend ? [...newItems, ...this.messageList] : [...this.messageList, ...newItems];
|
||||
}
|
||||
|
||||
private cloneAnd(
|
||||
mutator: ((draft: ChannelMessages) => void) | Partial<ChannelMessages>,
|
||||
deepCopyCollections = false,
|
||||
): ChannelMessages {
|
||||
const clone = new ChannelMessages(this.channelId);
|
||||
|
||||
clone.messageList = deepCopyCollections ? [...this.messageList] : this.messageList;
|
||||
clone.messageIndex = deepCopyCollections ? {...this.messageIndex} : this.messageIndex;
|
||||
clone.beforeBuffer = deepCopyCollections ? this.beforeBuffer.clone() : this.beforeBuffer;
|
||||
clone.afterBuffer = deepCopyCollections ? this.afterBuffer.clone() : this.afterBuffer;
|
||||
clone.version = this.version;
|
||||
|
||||
if (typeof mutator === 'function') {
|
||||
clone.ready = this.ready;
|
||||
clone.jumpType = this.jumpType;
|
||||
clone.jumpTargetId = this.jumpTargetId;
|
||||
clone.jumpTargetOffset = this.jumpTargetOffset;
|
||||
clone.jumpSequenceId = this.jumpSequenceId;
|
||||
clone.jumped = this.jumped;
|
||||
clone.jumpedToPresent = this.jumpedToPresent;
|
||||
clone.jumpFlash = this.jumpFlash;
|
||||
clone.jumpReturnTargetId = this.jumpReturnTargetId;
|
||||
clone.hasMoreBefore = this.hasMoreBefore;
|
||||
clone.hasMoreAfter = this.hasMoreAfter;
|
||||
clone.loadingMore = this.loadingMore;
|
||||
clone.revealedMessageId = this.revealedMessageId;
|
||||
clone.cached = this.cached;
|
||||
clone.error = this.error;
|
||||
|
||||
mutator(clone);
|
||||
} else {
|
||||
const patch = mutator as Partial<ChannelMessages>;
|
||||
|
||||
clone.ready = 'ready' in patch ? !!patch.ready : this.ready;
|
||||
clone.jumpType = patch.jumpType ?? this.jumpType;
|
||||
clone.jumpTargetId = 'jumpTargetId' in patch ? (patch.jumpTargetId ?? null) : this.jumpTargetId;
|
||||
clone.jumpTargetOffset = patch.jumpTargetOffset !== undefined ? patch.jumpTargetOffset : this.jumpTargetOffset;
|
||||
clone.jumpSequenceId = patch.jumpSequenceId !== undefined ? patch.jumpSequenceId : this.jumpSequenceId;
|
||||
clone.jumped = 'jumped' in patch ? !!patch.jumped : this.jumped;
|
||||
clone.jumpedToPresent = 'jumpedToPresent' in patch ? !!patch.jumpedToPresent : this.jumpedToPresent;
|
||||
clone.jumpFlash = 'jumpFlash' in patch ? !!patch.jumpFlash : this.jumpFlash;
|
||||
clone.jumpReturnTargetId =
|
||||
'jumpReturnTargetId' in patch ? (patch.jumpReturnTargetId ?? null) : this.jumpReturnTargetId;
|
||||
clone.hasMoreBefore = 'hasMoreBefore' in patch ? !!patch.hasMoreBefore : this.hasMoreBefore;
|
||||
clone.hasMoreAfter = 'hasMoreAfter' in patch ? !!patch.hasMoreAfter : this.hasMoreAfter;
|
||||
clone.loadingMore = patch.loadingMore !== undefined ? patch.loadingMore : this.loadingMore;
|
||||
clone.revealedMessageId =
|
||||
'revealedMessageId' in patch ? (patch.revealedMessageId ?? null) : this.revealedMessageId;
|
||||
clone.cached = patch.cached ?? this.cached;
|
||||
clone.error = patch.error ?? this.error;
|
||||
}
|
||||
|
||||
clone.version = this.version + 1;
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
725
fluxer_app/src/lib/CloudUpload.tsx
Normal file
725
fluxer_app/src/lib/CloudUpload.tsx
Normal file
@@ -0,0 +1,725 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {BehaviorSubject, firstValueFrom, interval, type Observable, type Subscription} from 'rxjs';
|
||||
import {filter, map, timeout as rxTimeout, take} from 'rxjs/operators';
|
||||
import {MessageAttachmentFlags} from '~/Constants';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import type {AllowedMentions} from '~/records/MessageRecord';
|
||||
|
||||
const logger = new Logger('CloudUpload');
|
||||
const hasDOM = true;
|
||||
|
||||
export type UploadStatus = 'pending' | 'uploading' | 'failed' | 'sending';
|
||||
|
||||
export type UploadStage = 'idle' | 'queued' | 'uploading' | 'processing' | 'completed' | 'failed' | 'canceled';
|
||||
|
||||
export interface CloudAttachment {
|
||||
id: number;
|
||||
channelId: string;
|
||||
file: File;
|
||||
filename: string;
|
||||
description?: string;
|
||||
flags: number;
|
||||
previewURL: string | null;
|
||||
thumbnailURL: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
status: UploadStatus;
|
||||
uploadProgress?: number;
|
||||
spoiler?: boolean;
|
||||
}
|
||||
|
||||
export interface MessageUpload {
|
||||
nonce: string;
|
||||
channelId: string;
|
||||
attachments: Array<CloudAttachment>;
|
||||
content?: string;
|
||||
messageReference?: {message_id: string};
|
||||
allowedMentions?: AllowedMentions;
|
||||
flags?: number;
|
||||
|
||||
sendingProgress?: number;
|
||||
|
||||
stage: UploadStage;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
type MessageUploadListener = (upload: MessageUpload) => void;
|
||||
|
||||
class CloudUploadManager {
|
||||
private static readonly MESSAGE_UPLOAD_TTL_MS = 5 * 60 * 1000;
|
||||
private static readonly CLEANUP_INTERVAL_MS = 60 * 1000;
|
||||
|
||||
private nextAttachmentId = 1;
|
||||
|
||||
private readonly textareaAttachmentSubjects = new Map<string, BehaviorSubject<Array<CloudAttachment>>>();
|
||||
|
||||
private readonly messageUploadSubjects = new Map<string, BehaviorSubject<MessageUpload | null>>();
|
||||
|
||||
private readonly messageUploadTimestamps: Map<string, number> = new Map();
|
||||
|
||||
private readonly activeUploadControllers: Map<number, AbortController> = new Map();
|
||||
|
||||
private readonly attachmentIndex: Map<number, CloudAttachment> = new Map();
|
||||
|
||||
private readonly textareaListeners: Map<string, Set<Listener>> = new Map();
|
||||
private readonly messageUploadListeners: Map<string, Set<MessageUploadListener>> = new Map();
|
||||
|
||||
private cleanupSubscription: Subscription | null = null;
|
||||
|
||||
constructor() {
|
||||
this.cleanupSubscription = interval(CloudUploadManager.CLEANUP_INTERVAL_MS).subscribe(() => {
|
||||
this.cleanupStaleUploads();
|
||||
});
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.cleanupSubscription) {
|
||||
this.cleanupSubscription.unsubscribe();
|
||||
this.cleanupSubscription = null;
|
||||
}
|
||||
|
||||
for (const subject of this.textareaAttachmentSubjects.values()) {
|
||||
for (const att of subject.value) {
|
||||
if (att.previewURL) URL.revokeObjectURL(att.previewURL);
|
||||
if (att.thumbnailURL) URL.revokeObjectURL(att.thumbnailURL);
|
||||
}
|
||||
}
|
||||
|
||||
for (const subject of this.messageUploadSubjects.values()) {
|
||||
const upload = subject.value;
|
||||
if (!upload) continue;
|
||||
for (const att of upload.attachments) {
|
||||
if (att.previewURL) URL.revokeObjectURL(att.previewURL);
|
||||
if (att.thumbnailURL) URL.revokeObjectURL(att.thumbnailURL);
|
||||
}
|
||||
}
|
||||
|
||||
this.textareaAttachmentSubjects.clear();
|
||||
this.messageUploadSubjects.clear();
|
||||
this.attachmentIndex.clear();
|
||||
this.messageUploadTimestamps.clear();
|
||||
this.activeUploadControllers.clear();
|
||||
this.textareaListeners.clear();
|
||||
this.messageUploadListeners.clear();
|
||||
}
|
||||
|
||||
private ensureTextareaSubject(channelId: string): BehaviorSubject<Array<CloudAttachment>> {
|
||||
let subject = this.textareaAttachmentSubjects.get(channelId);
|
||||
if (!subject) {
|
||||
subject = new BehaviorSubject<Array<CloudAttachment>>([]);
|
||||
this.textareaAttachmentSubjects.set(channelId, subject);
|
||||
}
|
||||
return subject;
|
||||
}
|
||||
|
||||
private ensureMessageUploadSubject(nonce: string): BehaviorSubject<MessageUpload | null> {
|
||||
let subject = this.messageUploadSubjects.get(nonce);
|
||||
if (!subject) {
|
||||
subject = new BehaviorSubject<MessageUpload | null>(null);
|
||||
this.messageUploadSubjects.set(nonce, subject);
|
||||
}
|
||||
return subject;
|
||||
}
|
||||
|
||||
private getMessageUploadInternal(nonce: string): MessageUpload | null {
|
||||
const subject = this.messageUploadSubjects.get(nonce);
|
||||
return subject?.value ?? null;
|
||||
}
|
||||
|
||||
private setMessageUploadInternal(nonce: string, upload: MessageUpload | null): void {
|
||||
const subject = this.ensureMessageUploadSubject(nonce);
|
||||
|
||||
if (upload) {
|
||||
this.messageUploadTimestamps.set(nonce, Date.now());
|
||||
} else {
|
||||
this.messageUploadTimestamps.delete(nonce);
|
||||
}
|
||||
|
||||
subject.next(upload);
|
||||
}
|
||||
|
||||
private updateMessageUpload(nonce: string, updater: (current: MessageUpload) => MessageUpload): void {
|
||||
const current = this.getMessageUploadInternal(nonce);
|
||||
if (!current) return;
|
||||
|
||||
const updated = updater(current);
|
||||
this.setMessageUploadInternal(nonce, updated);
|
||||
this.notifyMessageUploadListeners(nonce);
|
||||
}
|
||||
|
||||
private cleanupStaleUploads(): void {
|
||||
const now = Date.now();
|
||||
const staleNonces: Array<string> = [];
|
||||
|
||||
for (const [nonce, timestamp] of this.messageUploadTimestamps.entries()) {
|
||||
if (now - timestamp > CloudUploadManager.MESSAGE_UPLOAD_TTL_MS) {
|
||||
staleNonces.push(nonce);
|
||||
}
|
||||
}
|
||||
|
||||
for (const nonce of staleNonces) {
|
||||
logger.debug(`Cleaning up stale upload for nonce ${nonce}`);
|
||||
this.removeMessageUpload(nonce);
|
||||
}
|
||||
}
|
||||
|
||||
attachments$(channelId: string): Observable<ReadonlyArray<CloudAttachment>> {
|
||||
return this.ensureTextareaSubject(channelId).asObservable();
|
||||
}
|
||||
|
||||
messageUpload$(nonce: string): Observable<MessageUpload | null> {
|
||||
return this.ensureMessageUploadSubject(nonce).asObservable();
|
||||
}
|
||||
|
||||
getTextareaAttachments(channelId: string): Array<CloudAttachment> {
|
||||
return this.ensureTextareaSubject(channelId).value;
|
||||
}
|
||||
|
||||
subscribeToTextarea(channelId: string, listener: Listener): () => void {
|
||||
if (!this.textareaListeners.has(channelId)) {
|
||||
this.textareaListeners.set(channelId, new Set());
|
||||
}
|
||||
this.textareaListeners.get(channelId)!.add(listener);
|
||||
|
||||
const subject = this.ensureTextareaSubject(channelId);
|
||||
const subscription = subject.subscribe(() => listener());
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
const listeners = this.textareaListeners.get(channelId);
|
||||
if (listeners) {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) {
|
||||
this.textareaListeners.delete(channelId);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private notifyTextareaListeners(channelId: string): void {
|
||||
const listeners = this.textareaListeners.get(channelId);
|
||||
if (!listeners) return;
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
getMessageUpload(nonce: string): MessageUpload | null {
|
||||
return this.getMessageUploadInternal(nonce);
|
||||
}
|
||||
|
||||
subscribeToMessageUpload(nonce: string, listener: MessageUploadListener): () => void {
|
||||
if (!this.messageUploadListeners.has(nonce)) {
|
||||
this.messageUploadListeners.set(nonce, new Set());
|
||||
}
|
||||
this.messageUploadListeners.get(nonce)!.add(listener);
|
||||
|
||||
const subject = this.ensureMessageUploadSubject(nonce);
|
||||
const subscription = subject.subscribe((upload) => {
|
||||
if (upload) listener(upload);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
const listeners = this.messageUploadListeners.get(nonce);
|
||||
if (listeners) {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) {
|
||||
this.messageUploadListeners.delete(nonce);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private notifyMessageUploadListeners(nonce: string): void {
|
||||
const listeners = this.messageUploadListeners.get(nonce);
|
||||
const upload = this.getMessageUploadInternal(nonce);
|
||||
if (!listeners || !upload) return;
|
||||
|
||||
listeners.forEach((listener) => listener(upload));
|
||||
}
|
||||
|
||||
async addFiles(channelId: string, files: Array<File>): Promise<void> {
|
||||
if (files.length === 0) return;
|
||||
|
||||
const newAttachments = await this.createAttachments(channelId, files);
|
||||
const subject = this.ensureTextareaSubject(channelId);
|
||||
const existing = subject.value;
|
||||
|
||||
const combined = [...existing, ...newAttachments];
|
||||
subject.next(combined);
|
||||
|
||||
newAttachments.forEach((att) => this.attachmentIndex.set(att.id, att));
|
||||
|
||||
this.notifyTextareaListeners(channelId);
|
||||
}
|
||||
|
||||
async createAndStartUploads(channelId: string, files: Array<File>): Promise<Array<CloudAttachment>> {
|
||||
const attachments = await this.createAttachments(channelId, files);
|
||||
|
||||
attachments.forEach((att) => {
|
||||
this.attachmentIndex.set(att.id, att);
|
||||
});
|
||||
|
||||
return attachments;
|
||||
}
|
||||
|
||||
removeAttachment(channelId: string, attachmentId: number): void {
|
||||
const subject = this.ensureTextareaSubject(channelId);
|
||||
const attachments = subject.value;
|
||||
|
||||
const attachment = attachments.find((a) => a.id === attachmentId);
|
||||
|
||||
if (attachment) {
|
||||
this.attachmentIndex.delete(attachmentId);
|
||||
|
||||
if (attachment.previewURL) URL.revokeObjectURL(attachment.previewURL);
|
||||
if (attachment.thumbnailURL) URL.revokeObjectURL(attachment.thumbnailURL);
|
||||
}
|
||||
|
||||
const next = attachments.filter((a) => a.id !== attachmentId);
|
||||
subject.next(next);
|
||||
|
||||
this.notifyTextareaListeners(channelId);
|
||||
}
|
||||
|
||||
updateAttachment(channelId: string, attachmentId: number, patch: Partial<CloudAttachment>): void {
|
||||
const subject = this.ensureTextareaSubject(channelId);
|
||||
const attachments = subject.value;
|
||||
const index = attachments.findIndex((a) => a.id === attachmentId);
|
||||
if (index === -1) return;
|
||||
|
||||
const updated = {...attachments[index], ...patch};
|
||||
|
||||
const next = [...attachments];
|
||||
next[index] = updated;
|
||||
subject.next(next);
|
||||
|
||||
this.attachmentIndex.set(attachmentId, updated);
|
||||
|
||||
this.notifyTextareaListeners(channelId);
|
||||
}
|
||||
|
||||
clearTextarea(channelId: string, preserveBlobs = false): void {
|
||||
const subject = this.ensureTextareaSubject(channelId);
|
||||
const attachments = subject.value;
|
||||
|
||||
attachments.forEach((att) => {
|
||||
this.attachmentIndex.delete(att.id);
|
||||
});
|
||||
|
||||
if (!preserveBlobs) {
|
||||
attachments.forEach((att) => {
|
||||
if (att.previewURL) URL.revokeObjectURL(att.previewURL);
|
||||
if (att.thumbnailURL) URL.revokeObjectURL(att.thumbnailURL);
|
||||
});
|
||||
}
|
||||
|
||||
subject.next([]);
|
||||
|
||||
this.notifyTextareaListeners(channelId);
|
||||
}
|
||||
|
||||
reorderAttachments(channelId: string, newOrder: Array<CloudAttachment>): void {
|
||||
this.ensureTextareaSubject(channelId).next([...newOrder]);
|
||||
|
||||
newOrder.forEach((att) => this.attachmentIndex.set(att.id, att));
|
||||
|
||||
this.notifyTextareaListeners(channelId);
|
||||
}
|
||||
|
||||
claimAttachmentsForMessage(
|
||||
channelId: string,
|
||||
nonce: string,
|
||||
attachments?: Array<CloudAttachment>,
|
||||
metadata?: {
|
||||
content?: string;
|
||||
messageReference?: {message_id: string};
|
||||
allowedMentions?: AllowedMentions;
|
||||
flags?: number;
|
||||
},
|
||||
): Array<CloudAttachment> {
|
||||
const subject = this.ensureTextareaSubject(channelId);
|
||||
const attachmentsToUse = attachments ?? subject.value;
|
||||
if (attachmentsToUse.length === 0) return [];
|
||||
|
||||
const clonedAttachments = attachmentsToUse.map((att) => ({...att}));
|
||||
|
||||
const upload: MessageUpload = {
|
||||
nonce,
|
||||
channelId,
|
||||
attachments: clonedAttachments,
|
||||
stage: 'queued',
|
||||
...metadata,
|
||||
};
|
||||
|
||||
this.setMessageUploadInternal(nonce, upload);
|
||||
|
||||
clonedAttachments.forEach((att) => {
|
||||
this.attachmentIndex.set(att.id, att);
|
||||
});
|
||||
|
||||
if (!attachments) {
|
||||
subject.next([]);
|
||||
this.notifyTextareaListeners(channelId);
|
||||
}
|
||||
|
||||
this.notifyMessageUploadListeners(nonce);
|
||||
|
||||
return clonedAttachments;
|
||||
}
|
||||
|
||||
moveMessageUpload(oldNonce: string, newNonce: string): void {
|
||||
const upload = this.getMessageUploadInternal(oldNonce);
|
||||
if (!upload) return;
|
||||
|
||||
const moved: MessageUpload = {
|
||||
...upload,
|
||||
nonce: newNonce,
|
||||
};
|
||||
|
||||
this.setMessageUploadInternal(newNonce, moved);
|
||||
this.setMessageUploadInternal(oldNonce, null);
|
||||
|
||||
const listeners = this.messageUploadListeners.get(oldNonce);
|
||||
if (listeners) {
|
||||
this.messageUploadListeners.delete(oldNonce);
|
||||
if (!this.messageUploadListeners.has(newNonce)) {
|
||||
this.messageUploadListeners.set(newNonce, new Set());
|
||||
}
|
||||
const target = this.messageUploadListeners.get(newNonce)!;
|
||||
for (const listener of listeners) {
|
||||
target.add(listener);
|
||||
}
|
||||
}
|
||||
|
||||
this.notifyMessageUploadListeners(newNonce);
|
||||
}
|
||||
|
||||
removeMessageUpload(nonce: string): void {
|
||||
const upload = this.getMessageUploadInternal(nonce);
|
||||
if (upload) {
|
||||
for (const att of upload.attachments) {
|
||||
this.attachmentIndex.delete(att.id);
|
||||
if (att.previewURL) URL.revokeObjectURL(att.previewURL);
|
||||
if (att.thumbnailURL) URL.revokeObjectURL(att.thumbnailURL);
|
||||
}
|
||||
}
|
||||
|
||||
this.setMessageUploadInternal(nonce, null);
|
||||
this.messageUploadListeners.delete(nonce);
|
||||
}
|
||||
|
||||
restoreAttachmentsToTextarea(nonce: string): void {
|
||||
const upload = this.getMessageUploadInternal(nonce);
|
||||
if (!upload) return;
|
||||
|
||||
const subject = this.ensureTextareaSubject(upload.channelId);
|
||||
subject.next(upload.attachments);
|
||||
|
||||
upload.attachments.forEach((att) => {
|
||||
this.attachmentIndex.set(att.id, att);
|
||||
});
|
||||
|
||||
this.notifyTextareaListeners(upload.channelId);
|
||||
|
||||
this.setMessageUploadInternal(nonce, null);
|
||||
this.messageUploadListeners.delete(nonce);
|
||||
}
|
||||
|
||||
updateSendingProgress(nonce: string, progressPercent: number): void {
|
||||
const clamped = Number.isFinite(progressPercent) ? Math.min(100, Math.max(0, progressPercent)) : 0;
|
||||
|
||||
const normalized = clamped / 100;
|
||||
|
||||
this.updateMessageUpload(nonce, (upload) => {
|
||||
const attachments = upload.attachments;
|
||||
if (attachments.length === 0) {
|
||||
return {
|
||||
...upload,
|
||||
stage: normalized >= 1 ? 'completed' : 'uploading',
|
||||
sendingProgress: clamped,
|
||||
};
|
||||
}
|
||||
|
||||
const totalSize = attachments.reduce((sum, att) => sum + (att.file?.size ?? 0), 0);
|
||||
const safeTotal = totalSize > 0 ? totalSize : attachments.length;
|
||||
|
||||
let cursor = 0;
|
||||
|
||||
const nextAttachments = attachments.map((att) => {
|
||||
const size = att.file?.size ?? 1;
|
||||
const ratio = size / safeTotal;
|
||||
|
||||
const start = cursor;
|
||||
const end = cursor + ratio;
|
||||
cursor = end;
|
||||
|
||||
let local = 0;
|
||||
if (normalized <= start) {
|
||||
local = 0;
|
||||
} else if (normalized >= end) {
|
||||
local = 1;
|
||||
} else {
|
||||
local = (normalized - start) / ratio;
|
||||
}
|
||||
|
||||
const uploadProgress = Math.round(local * 100);
|
||||
|
||||
let status = att.status;
|
||||
if (status === 'pending' && uploadProgress > 0) {
|
||||
status = 'uploading';
|
||||
}
|
||||
if (status === 'uploading' && uploadProgress === 100) {
|
||||
status = 'sending';
|
||||
}
|
||||
|
||||
return {
|
||||
...att,
|
||||
status,
|
||||
uploadProgress,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...upload,
|
||||
stage: normalized >= 1 ? 'completed' : 'uploading',
|
||||
sendingProgress: clamped,
|
||||
attachments: nextAttachments,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async waitForMessageUploads(nonce: string, timeoutMs = 60_000): Promise<Array<CloudAttachment>> {
|
||||
const source$ = this.messageUpload$(nonce).pipe(
|
||||
filter((upload): upload is MessageUpload => upload !== null),
|
||||
map((upload) => {
|
||||
const hasFailed = upload.attachments.some((att) => att.status === 'failed');
|
||||
if (hasFailed) {
|
||||
throw new Error('One or more attachments failed to upload');
|
||||
}
|
||||
|
||||
const allDone = upload.attachments.every((att) => att.status !== 'pending' && att.status !== 'uploading');
|
||||
|
||||
return allDone ? upload.attachments : null;
|
||||
}),
|
||||
filter((attachments): attachments is Array<CloudAttachment> => attachments !== null),
|
||||
take(1),
|
||||
rxTimeout({first: timeoutMs}),
|
||||
);
|
||||
|
||||
return firstValueFrom(source$);
|
||||
}
|
||||
|
||||
startSendingProgress(nonce: string): void {
|
||||
this.updateMessageUpload(nonce, (upload) => {
|
||||
const attachments = upload.attachments.map((att) => {
|
||||
if (att.status === 'pending') {
|
||||
return {...att, status: 'uploading' as const, uploadProgress: att.uploadProgress ?? 0};
|
||||
}
|
||||
return att;
|
||||
});
|
||||
|
||||
return {
|
||||
...upload,
|
||||
stage: 'uploading' as const,
|
||||
sendingProgress: upload.sendingProgress ?? 0,
|
||||
attachments,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
stopSendingProgress(_nonce: string): void {}
|
||||
|
||||
async cancelUpload(attachmentId: number): Promise<void> {
|
||||
const attachment = this.attachmentIndex.get(attachmentId);
|
||||
if (!attachment) {
|
||||
logger.warn(`Cannot cancel upload: attachment ${attachmentId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = this.activeUploadControllers.get(attachmentId);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
this.activeUploadControllers.delete(attachmentId);
|
||||
}
|
||||
|
||||
if (attachment.previewURL) URL.revokeObjectURL(attachment.previewURL);
|
||||
if (attachment.thumbnailURL) URL.revokeObjectURL(attachment.thumbnailURL);
|
||||
|
||||
this.attachmentIndex.delete(attachmentId);
|
||||
|
||||
for (const [nonce, subject] of this.messageUploadSubjects.entries()) {
|
||||
const upload = subject.value;
|
||||
if (!upload) continue;
|
||||
|
||||
const index = upload.attachments.findIndex((a) => a.id === attachmentId);
|
||||
if (index === -1) continue;
|
||||
|
||||
const nextAttachments = upload.attachments.slice();
|
||||
nextAttachments.splice(index, 1);
|
||||
|
||||
const updated: MessageUpload = {
|
||||
...upload,
|
||||
attachments: nextAttachments,
|
||||
};
|
||||
|
||||
this.setMessageUploadInternal(nonce, updated);
|
||||
this.notifyMessageUploadListeners(nonce);
|
||||
break;
|
||||
}
|
||||
|
||||
const channelId = attachment.channelId;
|
||||
const textareaSubject = this.textareaAttachmentSubjects.get(channelId);
|
||||
if (textareaSubject) {
|
||||
const next = textareaSubject.value.filter((a) => a.id !== attachmentId);
|
||||
textareaSubject.next(next);
|
||||
this.notifyTextareaListeners(channelId);
|
||||
}
|
||||
|
||||
logger.debug(`Cancelled upload for attachment ${attachmentId}`);
|
||||
}
|
||||
|
||||
private async createAttachments(channelId: string, files: Array<File>): Promise<Array<CloudAttachment>> {
|
||||
return Promise.all(
|
||||
files.map(async (file) => {
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let thumbnailURL: string | null = null;
|
||||
const spoiler = file.name.startsWith('SPOILER_');
|
||||
|
||||
if (hasDOM) {
|
||||
try {
|
||||
if (file.type.startsWith('image/')) {
|
||||
const dims = await this.getImageDimensions(file);
|
||||
width = dims.width;
|
||||
height = dims.height;
|
||||
} else if (file.type.startsWith('video/')) {
|
||||
const data = await this.getVideoData(file);
|
||||
width = data.width;
|
||||
height = data.height;
|
||||
thumbnailURL = data.thumbnailURL;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Error getting file data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const isMedia =
|
||||
file.type.startsWith('image/') || file.type.startsWith('video/') || file.type.startsWith('audio/');
|
||||
const previewURL = hasDOM && isMedia ? URL.createObjectURL(file) : null;
|
||||
const flags = spoiler ? MessageAttachmentFlags.IS_SPOILER : 0;
|
||||
|
||||
const attachment: CloudAttachment = {
|
||||
id: this.nextAttachmentId++,
|
||||
channelId,
|
||||
file,
|
||||
filename: file.name,
|
||||
description: undefined,
|
||||
flags,
|
||||
previewURL,
|
||||
thumbnailURL,
|
||||
status: 'pending',
|
||||
width,
|
||||
height,
|
||||
uploadProgress: 0,
|
||||
spoiler,
|
||||
};
|
||||
|
||||
return attachment;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async getImageDimensions(file: File): Promise<{width: number; height: number}> {
|
||||
if (!hasDOM) return {width: 0, height: 0};
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
const {naturalWidth: width, naturalHeight: height} = img;
|
||||
URL.revokeObjectURL(url);
|
||||
resolve({width, height});
|
||||
};
|
||||
|
||||
img.onerror = (error) => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
private async getVideoData(file: File): Promise<{width: number; height: number; thumbnailURL: string | null}> {
|
||||
if (!hasDOM) return {width: 0, height: 0, thumbnailURL: null};
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const video = document.createElement('video');
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
const {videoWidth: width, videoHeight: height} = video;
|
||||
|
||||
if (!ctx) {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve({width, height, thumbnailURL: null});
|
||||
return;
|
||||
}
|
||||
|
||||
video.currentTime = 0;
|
||||
});
|
||||
|
||||
video.addEventListener('seeked', () => {
|
||||
const {videoWidth: width, videoHeight: height} = video;
|
||||
|
||||
if (!ctx) {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve({width, height, thumbnailURL: null});
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
ctx.drawImage(video, 0, 0, width, height);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
URL.revokeObjectURL(url);
|
||||
const thumbnailURL = blob ? URL.createObjectURL(blob) : null;
|
||||
resolve({width, height, thumbnailURL});
|
||||
},
|
||||
'image/jpeg',
|
||||
0.8,
|
||||
);
|
||||
});
|
||||
|
||||
video.addEventListener('error', (e) => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(e);
|
||||
});
|
||||
|
||||
video.src = url;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const CloudUpload = new CloudUploadManager();
|
||||
137
fluxer_app/src/lib/ComponentDispatch.tsx
Normal file
137
fluxer_app/src/lib/ComponentDispatch.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import EventEmitter from 'eventemitter3';
|
||||
|
||||
export type ComponentActionType =
|
||||
| 'CAMERA_DEVICE_REFRESH'
|
||||
| 'CHANNEL_DETAILS_OPEN'
|
||||
| 'CHANNEL_MEMBER_LIST_TOGGLE'
|
||||
| 'CHANNEL_NOTIFICATION_SETTINGS_OPEN'
|
||||
| 'CHANNEL_PINS_OPEN'
|
||||
| 'EMOJI_PICKER_OPEN'
|
||||
| 'EMOJI_PICKER_RERENDER'
|
||||
| 'EMOJI_SELECT'
|
||||
| 'ESCAPE_PRESSED'
|
||||
| 'FAVORITE_MEME_SELECT'
|
||||
| 'FOCUS_BOTTOMMOST_MESSAGE'
|
||||
| 'FOCUS_TEXTAREA'
|
||||
| 'FORCE_JUMP_TO_PRESENT'
|
||||
| 'GIF_SELECT'
|
||||
| 'INBOX_OPEN'
|
||||
| 'INSERT_MENTION'
|
||||
| 'LAYOUT_RESIZED'
|
||||
| 'MEMES_PICKER_RERENDER'
|
||||
| 'MESSAGE_SEARCH_OPEN'
|
||||
| 'MESSAGE_SENT'
|
||||
| 'OPEN_MEMES_TAB'
|
||||
| 'POPOUT_CLOSE'
|
||||
| 'SAVED_MESSAGES_OPEN'
|
||||
| 'SCROLLTO_PRESENT'
|
||||
| 'SCROLL_PAGE_DOWN'
|
||||
| 'SCROLL_PAGE_UP'
|
||||
| 'STICKER_PICKER_RERENDER'
|
||||
| 'STICKER_SELECT'
|
||||
| 'TEXTAREA_AUTOCOMPLETE_CHANGED'
|
||||
| 'TEXTAREA_UPLOAD_FILE'
|
||||
| 'USER_SETTINGS_TAB_SELECT';
|
||||
|
||||
type ComponentDispatchEvents = {
|
||||
[K in ComponentActionType]: (...args: Array<unknown>) => void;
|
||||
};
|
||||
|
||||
class Dispatch extends EventEmitter<ComponentDispatchEvents> {
|
||||
private _savedDispatches: Partial<Record<ComponentActionType, Array<unknown>>> = {};
|
||||
|
||||
safeDispatch(type: ComponentActionType, args?: unknown) {
|
||||
if (!this.hasSubscribers(type)) {
|
||||
if (!this._savedDispatches[type]) {
|
||||
this._savedDispatches[type] = [];
|
||||
}
|
||||
this._savedDispatches[type].push(args);
|
||||
return;
|
||||
}
|
||||
this.dispatch(type, args);
|
||||
}
|
||||
|
||||
dispatch(type: ComponentActionType, args?: unknown) {
|
||||
this.emit(type, args);
|
||||
}
|
||||
|
||||
dispatchToLastSubscribed(type: ComponentActionType, args?: unknown) {
|
||||
const listeners = this.listeners(type);
|
||||
if (listeners.length > 0) {
|
||||
listeners[listeners.length - 1](args);
|
||||
}
|
||||
}
|
||||
|
||||
dispatchToFirst(types: Array<ComponentActionType>, args?: unknown) {
|
||||
for (const type of types) {
|
||||
if (this.hasSubscribers(type)) {
|
||||
this.dispatch(type, args);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasSubscribers(type: ComponentActionType) {
|
||||
return this.listenerCount(type) > 0;
|
||||
}
|
||||
|
||||
private _checkSavedDispatches(type: ComponentActionType) {
|
||||
if (this._savedDispatches[type]) {
|
||||
for (const args of this._savedDispatches[type]) {
|
||||
this.dispatch(type, args);
|
||||
}
|
||||
delete this._savedDispatches[type];
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(type: ComponentActionType, callback: (...args: Array<unknown>) => void): () => void {
|
||||
if (this.listeners(type).includes(callback)) {
|
||||
console.warn('ComponentDispatch.subscribe: Attempting to add a duplicate listener', type);
|
||||
return () => {
|
||||
this.unsubscribe(type, callback);
|
||||
};
|
||||
}
|
||||
this.on(type, callback);
|
||||
this._checkSavedDispatches(type);
|
||||
return () => {
|
||||
this.unsubscribe(type, callback);
|
||||
};
|
||||
}
|
||||
|
||||
subscribeOnce(type: ComponentActionType, callback: (...args: Array<unknown>) => void): () => void {
|
||||
this.once(type, callback);
|
||||
this._checkSavedDispatches(type);
|
||||
return () => {
|
||||
this.unsubscribe(type, callback);
|
||||
};
|
||||
}
|
||||
|
||||
unsubscribe(type: ComponentActionType, callback: (...args: Array<unknown>) => void) {
|
||||
this.removeListener(type, callback);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.removeAllListeners();
|
||||
}
|
||||
}
|
||||
|
||||
export const ComponentDispatch = new Dispatch();
|
||||
100
fluxer_app/src/lib/ExponentialBackoff.tsx
Normal file
100
fluxer_app/src/lib/ExponentialBackoff.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
interface ExponentialBackoffOptions {
|
||||
minDelay: number;
|
||||
maxDelay: number;
|
||||
maxNumOfAttempts?: number;
|
||||
factor?: number;
|
||||
jitter?: boolean;
|
||||
jitterFactor?: number;
|
||||
}
|
||||
|
||||
export class ExponentialBackoff {
|
||||
private attempts = 0;
|
||||
private readonly factor: number;
|
||||
private readonly maxAttempts: number;
|
||||
private readonly useJitter: boolean;
|
||||
private readonly jitterFactor: number;
|
||||
|
||||
constructor(private readonly options: ExponentialBackoffOptions) {
|
||||
if (options.minDelay <= 0) {
|
||||
throw new Error('minDelay must be greater than 0');
|
||||
}
|
||||
if (options.maxDelay < options.minDelay) {
|
||||
throw new Error('maxDelay must be greater than or equal to minDelay');
|
||||
}
|
||||
this.factor = options.factor ?? 2;
|
||||
this.maxAttempts = options.maxNumOfAttempts ?? Number.POSITIVE_INFINITY;
|
||||
this.useJitter = options.jitter ?? true;
|
||||
this.jitterFactor = options.jitterFactor ?? 0.25;
|
||||
if (this.factor <= 1) {
|
||||
throw new Error('factor must be greater than 1');
|
||||
}
|
||||
if (this.maxAttempts <= 0) {
|
||||
throw new Error('maxNumOfAttempts must be greater than 0');
|
||||
}
|
||||
if (this.jitterFactor < 0 || this.jitterFactor > 1) {
|
||||
throw new Error('jitterFactor must be between 0 and 1');
|
||||
}
|
||||
}
|
||||
|
||||
next(): number {
|
||||
this.attempts++;
|
||||
const baseDelay = Math.min(this.options.minDelay * this.factor ** (this.attempts - 1), this.options.maxDelay);
|
||||
if (this.useJitter) {
|
||||
const maxJitter = baseDelay * this.jitterFactor;
|
||||
const jitter = (Math.random() * 2 - 1) * maxJitter;
|
||||
return Math.max(this.options.minDelay, Math.min(baseDelay + jitter, this.options.maxDelay));
|
||||
}
|
||||
return baseDelay;
|
||||
}
|
||||
|
||||
getCurrentAttempts(): number {
|
||||
return this.attempts;
|
||||
}
|
||||
|
||||
getMaxAttempts(): number {
|
||||
return this.maxAttempts;
|
||||
}
|
||||
|
||||
isExhausted(): boolean {
|
||||
return this.attempts >= this.maxAttempts;
|
||||
}
|
||||
|
||||
getMinDelay(): number {
|
||||
return this.options.minDelay;
|
||||
}
|
||||
|
||||
getMaxDelay(): number {
|
||||
return this.options.maxDelay;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.attempts = 0;
|
||||
}
|
||||
|
||||
static create(minDelay: number, maxDelay: number, maxAttempts?: number): ExponentialBackoff {
|
||||
return new ExponentialBackoff({
|
||||
minDelay,
|
||||
maxDelay,
|
||||
maxNumOfAttempts: maxAttempts,
|
||||
});
|
||||
}
|
||||
}
|
||||
78
fluxer_app/src/lib/FocusManager.tsx
Normal file
78
fluxer_app/src/lib/FocusManager.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {autorun} from 'mobx';
|
||||
import WindowStore from '~/stores/WindowStore';
|
||||
|
||||
type FocusChangeListener = (focused: boolean) => void;
|
||||
|
||||
class FocusManager {
|
||||
private static instance: FocusManager;
|
||||
private listeners: Set<FocusChangeListener> = new Set();
|
||||
private initialized = false;
|
||||
private disposer: (() => void) | null = null;
|
||||
|
||||
static getInstance(): FocusManager {
|
||||
if (!FocusManager.instance) {
|
||||
FocusManager.instance = new FocusManager();
|
||||
}
|
||||
return FocusManager.instance;
|
||||
}
|
||||
|
||||
init(): void {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
|
||||
this.disposer = autorun(() => {
|
||||
this.notifyListeners(WindowStore.focused);
|
||||
});
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.listeners.clear();
|
||||
this.disposer?.();
|
||||
this.disposer = null;
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
subscribe(listener: FocusChangeListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(WindowStore.isFocused());
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private notifyListeners(focused: boolean): void {
|
||||
this.listeners.forEach((listener) => {
|
||||
try {
|
||||
listener(focused);
|
||||
} catch (error) {
|
||||
console.error('FocusManager: Error in listener:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isFocused(): boolean {
|
||||
return WindowStore.isFocused();
|
||||
}
|
||||
}
|
||||
|
||||
export default FocusManager.getInstance();
|
||||
70
fluxer_app/src/lib/GatewayCompression.ts
Normal file
70
fluxer_app/src/lib/GatewayCompression.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {decompressZstdFrame} from '~/lib/libfluxcore';
|
||||
|
||||
export type CompressionType = 'none' | 'zstd-stream' | (string & {});
|
||||
|
||||
export class GatewayDecompressor {
|
||||
private readonly type: CompressionType;
|
||||
|
||||
constructor(type: CompressionType) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
async decompress(data: ArrayBuffer): Promise<string> {
|
||||
const input = new Uint8Array(data);
|
||||
|
||||
switch (this.type) {
|
||||
case 'none':
|
||||
return new TextDecoder().decode(input);
|
||||
|
||||
case 'zstd-stream':
|
||||
return this.decompressZstd(input);
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported compression type: ${this.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async decompressZstd(data: Uint8Array): Promise<string> {
|
||||
const wasmDecoded = await decompressZstdFrame(data);
|
||||
if (!wasmDecoded) {
|
||||
throw new Error('Gateway zstd WASM not available');
|
||||
}
|
||||
const decompressed = wasmDecoded;
|
||||
return new TextDecoder().decode(decompressed);
|
||||
}
|
||||
|
||||
destroy(): void {}
|
||||
}
|
||||
|
||||
export function getPreferredCompression(): CompressionType {
|
||||
return 'zstd-stream';
|
||||
}
|
||||
|
||||
export function isCompressionSupported(type: CompressionType): boolean {
|
||||
switch (type) {
|
||||
case 'none':
|
||||
case 'zstd-stream':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
964
fluxer_app/src/lib/GatewaySocket.tsx
Normal file
964
fluxer_app/src/lib/GatewaySocket.tsx
Normal file
@@ -0,0 +1,964 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import EventEmitter from 'eventemitter3';
|
||||
import type {GatewayErrorCode} from '~/Constants';
|
||||
import {GatewayCloseCodes, GatewayOpcodes} from '~/Constants';
|
||||
import AppStorage from '~/lib/AppStorage';
|
||||
import type {GatewayCustomStatusPayload} from '~/lib/customStatus';
|
||||
import {ExponentialBackoff} from '~/lib/ExponentialBackoff';
|
||||
import {type CompressionType, GatewayDecompressor} from '~/lib/GatewayCompression';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import AuthenticationStore from '~/stores/AuthenticationStore';
|
||||
import ConnectionStore from '~/stores/ConnectionStore';
|
||||
import GeoIPStore from '~/stores/GeoIPStore';
|
||||
import LayerManager from '~/stores/LayerManager';
|
||||
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
||||
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
|
||||
|
||||
const GATEWAY_TIMEOUTS = {
|
||||
HeartbeatAck: 15000,
|
||||
ResumeWindow: 180000,
|
||||
MinReconnect: 1000,
|
||||
MaxReconnect: 10000,
|
||||
Hello: 20000,
|
||||
} as const;
|
||||
|
||||
export const GatewayState = {
|
||||
Disconnected: 'DISCONNECTED',
|
||||
Connecting: 'CONNECTING',
|
||||
Connected: 'CONNECTED',
|
||||
Reconnecting: 'RECONNECTING',
|
||||
} as const;
|
||||
export type GatewayState = (typeof GatewayState)[keyof typeof GatewayState];
|
||||
|
||||
export interface GatewayPayload {
|
||||
op: number;
|
||||
d?: unknown;
|
||||
s?: number;
|
||||
t?: string;
|
||||
}
|
||||
|
||||
export interface GatewaySocketProperties {
|
||||
os: string;
|
||||
browser: string;
|
||||
device: string;
|
||||
locale: string;
|
||||
user_agent: string;
|
||||
browser_version: string;
|
||||
os_version: string;
|
||||
build_timestamp: string;
|
||||
desktop_app_version?: string | null;
|
||||
desktop_app_channel?: string | null;
|
||||
desktop_arch?: string | null;
|
||||
desktop_os?: string | null;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
}
|
||||
|
||||
export interface GatewayPresence {
|
||||
status: string;
|
||||
afk: boolean;
|
||||
mobile: boolean;
|
||||
custom_status?: GatewayCustomStatusPayload | null;
|
||||
}
|
||||
|
||||
export interface GatewaySocketOptions {
|
||||
token: string;
|
||||
apiVersion: number;
|
||||
properties: GatewaySocketProperties;
|
||||
presence?: GatewayPresence;
|
||||
compression?: CompressionType;
|
||||
identifyFlags?: number;
|
||||
initialGuildId?: string | null;
|
||||
}
|
||||
|
||||
export interface GatewayErrorData {
|
||||
code: GatewayErrorCode;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface GatewaySocketEvents {
|
||||
connecting: () => void;
|
||||
connected: () => void;
|
||||
ready: (data: unknown) => void;
|
||||
resumed: () => void;
|
||||
disconnect: (event: {code: number; reason: string; wasClean: boolean}) => void;
|
||||
error: (error: Error | Event | CloseEvent) => void;
|
||||
gatewayError: (error: GatewayErrorData) => void;
|
||||
message: (payload: GatewayPayload) => void;
|
||||
dispatch: (type: string, data: unknown) => void;
|
||||
stateChange: (newState: GatewayState, oldState: GatewayState) => void;
|
||||
heartbeat: (sequence: number) => void;
|
||||
heartbeatAck: () => void;
|
||||
networkStatusChange: (online: boolean) => void;
|
||||
}
|
||||
|
||||
export class GatewaySocket extends EventEmitter<GatewaySocketEvents> {
|
||||
private readonly log: Logger;
|
||||
private readonly reconnectBackoff: ExponentialBackoff;
|
||||
|
||||
private socket: WebSocket | null = null;
|
||||
private connectionState: GatewayState = GatewayState.Disconnected;
|
||||
|
||||
private activeSessionId: string | null = null;
|
||||
private lastSequenceNumber = 0;
|
||||
private lastReconnectAt = 0;
|
||||
|
||||
private heartbeatIntervalMs: number | null = null;
|
||||
private heartbeatTimeoutId: number | null = null;
|
||||
private heartbeatAckTimeoutId: number | null = null;
|
||||
private awaitingHeartbeatAck = false;
|
||||
private lastHeartbeatAckAt: number | null = null;
|
||||
private lastHeartbeatSentAt: number | null = null;
|
||||
|
||||
private helloTimeoutId: number | null = null;
|
||||
private reconnectTimeoutId: number | null = null;
|
||||
private invalidSessionTimeoutId: number | null = null;
|
||||
private isUserInitiatedDisconnect = false;
|
||||
private shouldReconnectImmediately = false;
|
||||
private payloadDecompressor: GatewayDecompressor | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly gatewayUrlBase: string,
|
||||
private readonly options: GatewaySocketOptions,
|
||||
private readonly gatewayUrlWrapper?: (url: string) => string,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.log = new Logger('Gateway');
|
||||
this.reconnectBackoff = new ExponentialBackoff({
|
||||
minDelay: GATEWAY_TIMEOUTS.MinReconnect,
|
||||
maxDelay: GATEWAY_TIMEOUTS.MaxReconnect,
|
||||
});
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.connectionState === GatewayState.Connecting || this.connectionState === GatewayState.Connected) {
|
||||
this.log.debug('Ignoring connect: already connecting or connected');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isUserInitiatedDisconnect = false;
|
||||
this.updateState(GatewayState.Connecting);
|
||||
this.openSocket();
|
||||
}
|
||||
|
||||
disconnect(code = 1000, reason = 'Client disconnecting', resumable = false): void {
|
||||
this.log.info(`Disconnect requested: [${code}] ${reason}, resumable=${resumable}`);
|
||||
this.isUserInitiatedDisconnect = !resumable;
|
||||
|
||||
this.clearHelloTimeout();
|
||||
if (this.reconnectTimeoutId != null) {
|
||||
clearTimeout(this.reconnectTimeoutId);
|
||||
this.reconnectTimeoutId = null;
|
||||
}
|
||||
if (this.invalidSessionTimeoutId != null) {
|
||||
clearTimeout(this.invalidSessionTimeoutId);
|
||||
this.invalidSessionTimeoutId = null;
|
||||
}
|
||||
|
||||
this.stopHeartbeat();
|
||||
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this.socket.close(code, reason);
|
||||
} catch (error) {
|
||||
this.log.error('Error while closing WebSocket', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (resumable) {
|
||||
this.updateState(GatewayState.Reconnecting);
|
||||
this.scheduleReconnect();
|
||||
} else {
|
||||
this.updateState(GatewayState.Disconnected);
|
||||
}
|
||||
}
|
||||
|
||||
simulateNetworkDisconnect(): void {
|
||||
if (!this.isConnected()) {
|
||||
this.log.warn('Cannot simulate network disconnect: not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
this.log.info('Simulating network disconnect with resumable close');
|
||||
this.disconnect(4000, 'Simulated network disconnect', true);
|
||||
}
|
||||
|
||||
reset(shouldReconnect = true): void {
|
||||
this.log.info(`Resetting gateway connection (reconnect=${shouldReconnect})`);
|
||||
|
||||
this.clearHelloTimeout();
|
||||
if (this.reconnectTimeoutId != null) {
|
||||
clearTimeout(this.reconnectTimeoutId);
|
||||
this.reconnectTimeoutId = null;
|
||||
}
|
||||
|
||||
this.stopHeartbeat();
|
||||
this.clearSession();
|
||||
this.resetBackoffInternal();
|
||||
this.teardownSocket();
|
||||
|
||||
this.updateState(GatewayState.Disconnected);
|
||||
|
||||
if (shouldReconnect) {
|
||||
this.shouldReconnectImmediately = true;
|
||||
this.connect();
|
||||
}
|
||||
}
|
||||
|
||||
handleNetworkStatusChange(online: boolean): void {
|
||||
this.log.info(`Network status: ${online ? 'online' : 'offline'}`);
|
||||
this.emit('networkStatusChange', online);
|
||||
|
||||
if (online) {
|
||||
if (this.connectionState === GatewayState.Disconnected || this.connectionState === GatewayState.Reconnecting) {
|
||||
this.shouldReconnectImmediately = true;
|
||||
this.connect();
|
||||
}
|
||||
} else if (this.connectionState === GatewayState.Connected) {
|
||||
this.disconnect(1000, 'Network offline', true);
|
||||
}
|
||||
}
|
||||
|
||||
updatePresence(
|
||||
status: string,
|
||||
afk?: boolean,
|
||||
mobile?: boolean,
|
||||
customStatus?: GatewayCustomStatusPayload | null,
|
||||
): void {
|
||||
if (!this.isConnected()) return;
|
||||
|
||||
this.sendPayload({
|
||||
op: GatewayOpcodes.PRESENCE_UPDATE,
|
||||
d: {
|
||||
status,
|
||||
...(afk !== undefined && {afk}),
|
||||
...(mobile !== undefined && {mobile}),
|
||||
...(customStatus !== undefined && {custom_status: customStatus}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
updateVoiceState(params: {
|
||||
guild_id: string | null;
|
||||
channel_id: string | null;
|
||||
self_mute: boolean;
|
||||
self_deaf: boolean;
|
||||
self_video: boolean;
|
||||
self_stream: boolean;
|
||||
viewer_stream_key?: string | null;
|
||||
connection_id: string | null;
|
||||
}): void {
|
||||
const isMobileLayout = MobileLayoutStore.isMobileLayout();
|
||||
const {latitude, longitude} = GeoIPStore;
|
||||
|
||||
this.sendPayload({
|
||||
op: GatewayOpcodes.VOICE_STATE_UPDATE,
|
||||
d: {
|
||||
...params,
|
||||
connection_id: params.connection_id || MediaEngineStore.connectionId,
|
||||
is_mobile: isMobileLayout,
|
||||
latitude: latitude ?? undefined,
|
||||
longitude: longitude ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
requestGuildMembers(params: {
|
||||
guildId: string;
|
||||
query?: string;
|
||||
limit?: number;
|
||||
userIds?: Array<string>;
|
||||
presences?: boolean;
|
||||
nonce?: string;
|
||||
}): void {
|
||||
if (!this.isConnected()) return;
|
||||
|
||||
this.sendPayload({
|
||||
op: GatewayOpcodes.REQUEST_GUILD_MEMBERS,
|
||||
d: {
|
||||
guild_id: params.guildId,
|
||||
...(params.query !== undefined && {query: params.query}),
|
||||
...(params.limit !== undefined && {limit: params.limit}),
|
||||
...(params.userIds !== undefined && {user_ids: [...new Set(params.userIds)]}),
|
||||
...(params.presences !== undefined && {presences: params.presences}),
|
||||
...(params.nonce !== undefined && {nonce: params.nonce}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
updateGuildSubscriptions(params: {
|
||||
subscriptions: Record<
|
||||
string,
|
||||
{
|
||||
active?: boolean;
|
||||
member_list_channels?: Record<string, Array<[number, number]>>;
|
||||
typing?: boolean;
|
||||
members?: Array<string>;
|
||||
sync?: boolean;
|
||||
}
|
||||
>;
|
||||
}): void {
|
||||
if (!this.isConnected()) return;
|
||||
|
||||
this.sendPayload({
|
||||
op: GatewayOpcodes.LAZY_REQUEST,
|
||||
d: params,
|
||||
});
|
||||
}
|
||||
|
||||
setToken(token: string): void {
|
||||
this.options.token = token;
|
||||
}
|
||||
|
||||
getState(): GatewayState {
|
||||
return this.connectionState;
|
||||
}
|
||||
|
||||
getSessionId(): string | null {
|
||||
return this.activeSessionId;
|
||||
}
|
||||
|
||||
getSequence(): number {
|
||||
return this.lastSequenceNumber;
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connectionState === GatewayState.Connected && this.socket?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
isConnecting(): boolean {
|
||||
return this.connectionState === GatewayState.Connecting;
|
||||
}
|
||||
|
||||
private openSocket(): void {
|
||||
this.teardownSocket();
|
||||
|
||||
const url = this.buildGatewayUrl();
|
||||
this.log.debug(`Opening WebSocket connection to ${url}`);
|
||||
|
||||
try {
|
||||
this.socket = new WebSocket(url);
|
||||
|
||||
const compression: CompressionType = this.options.compression ?? 'zstd-stream';
|
||||
if (compression !== 'none') {
|
||||
this.socket.binaryType = 'arraybuffer';
|
||||
this.payloadDecompressor = new GatewayDecompressor(compression);
|
||||
} else {
|
||||
this.socket.binaryType = 'blob';
|
||||
this.payloadDecompressor = null;
|
||||
}
|
||||
|
||||
this.socket.addEventListener('open', this.handleSocketOpen);
|
||||
this.socket.addEventListener('message', this.handleSocketMessage);
|
||||
this.socket.addEventListener('close', this.handleSocketClose);
|
||||
this.socket.addEventListener('error', this.handleSocketError);
|
||||
|
||||
this.startHelloTimeout();
|
||||
this.emit('connecting');
|
||||
} catch (error) {
|
||||
this.log.error('Failed to create WebSocket', error);
|
||||
this.handleConnectionFailure();
|
||||
}
|
||||
}
|
||||
|
||||
private teardownSocket(): void {
|
||||
if (this.payloadDecompressor) {
|
||||
this.payloadDecompressor.destroy();
|
||||
this.payloadDecompressor = null;
|
||||
}
|
||||
|
||||
if (!this.socket) return;
|
||||
|
||||
try {
|
||||
this.socket.removeEventListener('open', this.handleSocketOpen);
|
||||
this.socket.removeEventListener('message', this.handleSocketMessage);
|
||||
this.socket.removeEventListener('close', this.handleSocketClose);
|
||||
this.socket.removeEventListener('error', this.handleSocketError);
|
||||
|
||||
if (this.socket.readyState === WebSocket.OPEN) {
|
||||
this.socket.close(1000, 'Disposing stale socket');
|
||||
}
|
||||
} catch (error) {
|
||||
this.log.error('Error while disposing socket', error);
|
||||
} finally {
|
||||
this.socket = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleSocketOpen = (): void => {
|
||||
this.log.info('WebSocket connection established');
|
||||
this.emit('connected');
|
||||
};
|
||||
|
||||
private handleSocketMessage = async (event: MessageEvent): Promise<void> => {
|
||||
try {
|
||||
const json = await this.extractPayload(event);
|
||||
if (!json) return;
|
||||
|
||||
const payload = JSON.parse(json) as GatewayPayload;
|
||||
|
||||
this.log.debug('Gateway message received', payload);
|
||||
|
||||
if (
|
||||
this.connectionState === GatewayState.Connected &&
|
||||
payload.op === GatewayOpcodes.DISPATCH &&
|
||||
typeof payload.s === 'number' &&
|
||||
payload.s > this.lastSequenceNumber
|
||||
) {
|
||||
this.lastSequenceNumber = payload.s;
|
||||
}
|
||||
|
||||
this.routeGatewayPayload(payload);
|
||||
this.emit('message', payload);
|
||||
} catch (error) {
|
||||
this.log.error('Error while handling gateway message', error);
|
||||
|
||||
if (this.options.compression && this.options.compression !== 'none') {
|
||||
this.log.warn(`Decompression failed (compression=${this.options.compression}), retrying without compression`);
|
||||
this.options.compression = 'none';
|
||||
|
||||
if (this.payloadDecompressor) {
|
||||
this.payloadDecompressor.destroy();
|
||||
this.payloadDecompressor = null;
|
||||
}
|
||||
|
||||
this.shouldReconnectImmediately = true;
|
||||
this.disconnect(GatewayCloseCodes.DECODE_ERROR, 'Message decode error', true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.disconnect(GatewayCloseCodes.DECODE_ERROR, 'Message decode error');
|
||||
}
|
||||
};
|
||||
|
||||
private async extractPayload(event: MessageEvent): Promise<string | null> {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
if (!this.payloadDecompressor) {
|
||||
throw new Error('Received binary data but no decompressor is configured');
|
||||
}
|
||||
|
||||
const chunk = await this.payloadDecompressor.decompress(event.data);
|
||||
if (!chunk) {
|
||||
this.log.debug('Awaiting additional compressed chunks');
|
||||
return null;
|
||||
}
|
||||
return chunk;
|
||||
}
|
||||
|
||||
if (event.data instanceof Blob) {
|
||||
return await event.data.text();
|
||||
}
|
||||
|
||||
return event.data;
|
||||
}
|
||||
|
||||
private handleSocketClose = (event: CloseEvent): void => {
|
||||
this.log.warn(`WebSocket closed [${event.code}] ${event.reason || ''}`);
|
||||
this.clearHelloTimeout();
|
||||
this.stopHeartbeat();
|
||||
|
||||
if (this.invalidSessionTimeoutId != null) {
|
||||
clearTimeout(this.invalidSessionTimeoutId);
|
||||
this.invalidSessionTimeoutId = null;
|
||||
}
|
||||
|
||||
const compressionChanged = this.maybeAdjustCompression(event);
|
||||
if (compressionChanged) {
|
||||
this.shouldReconnectImmediately = true;
|
||||
this.resetBackoffInternal();
|
||||
}
|
||||
|
||||
this.emit('disconnect', {
|
||||
code: event.code,
|
||||
reason: event.reason,
|
||||
wasClean: event.wasClean,
|
||||
});
|
||||
|
||||
if (event.code === GatewayCloseCodes.AUTHENTICATION_FAILED) {
|
||||
this.handleAuthFailure();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isUserInitiatedDisconnect) {
|
||||
this.handleConnectionFailure();
|
||||
} else {
|
||||
this.updateState(GatewayState.Disconnected);
|
||||
}
|
||||
};
|
||||
|
||||
private handleSocketError = (event: Event): void => {
|
||||
this.log.error('WebSocket error', event);
|
||||
this.emit('error', event);
|
||||
|
||||
if (this.connectionState !== GatewayState.Reconnecting) {
|
||||
this.handleConnectionFailure();
|
||||
}
|
||||
};
|
||||
|
||||
private maybeAdjustCompression(event: CloseEvent): boolean {
|
||||
if (event.code !== GatewayCloseCodes.DECODE_ERROR) return false;
|
||||
const normalizedReason = (event.reason || '').toLowerCase();
|
||||
if (!normalizedReason.includes('encode failed') && !normalizedReason.includes('compression failed')) return false;
|
||||
|
||||
const currentCompression = this.options.compression ?? 'none';
|
||||
if (currentCompression === 'zstd-stream') {
|
||||
this.log.warn('Disabling gateway compression due to encode failure');
|
||||
this.options.compression = 'none';
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private routeGatewayPayload(payload: GatewayPayload): void {
|
||||
switch (payload.op) {
|
||||
case GatewayOpcodes.DISPATCH:
|
||||
this.handleDispatchPayload(payload);
|
||||
break;
|
||||
|
||||
case GatewayOpcodes.HEARTBEAT:
|
||||
this.log.debug('Heartbeat requested by server');
|
||||
this.sendHeartbeat(true);
|
||||
break;
|
||||
|
||||
case GatewayOpcodes.HEARTBEAT_ACK:
|
||||
this.handleHeartbeatAck();
|
||||
break;
|
||||
|
||||
case GatewayOpcodes.HELLO:
|
||||
this.handleHelloPayload(payload);
|
||||
break;
|
||||
|
||||
case GatewayOpcodes.INVALID_SESSION:
|
||||
this.handleInvalidSessionPayload(payload);
|
||||
break;
|
||||
|
||||
case GatewayOpcodes.RECONNECT:
|
||||
this.log.info('Server requested reconnect');
|
||||
this.shouldReconnectImmediately = true;
|
||||
this.disconnect(4000, 'Server requested reconnect', true);
|
||||
break;
|
||||
|
||||
case GatewayOpcodes.GATEWAY_ERROR: {
|
||||
const errorData = payload.d as GatewayErrorData;
|
||||
this.log.warn(`Gateway error received [${errorData.code}] ${errorData.message}`);
|
||||
this.emit('gatewayError', errorData);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleDispatchPayload(payload: GatewayPayload): void {
|
||||
if (!payload.t) return;
|
||||
|
||||
switch (payload.t) {
|
||||
case 'READY': {
|
||||
const data = payload.d as {session_id: string};
|
||||
this.activeSessionId = data.session_id;
|
||||
this.resetBackoffInternal();
|
||||
this.updateState(GatewayState.Connected);
|
||||
this.log.info(`Gateway READY, session=${this.activeSessionId}`);
|
||||
this.emit('ready', payload.d);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'RESUMED':
|
||||
this.updateState(GatewayState.Connected);
|
||||
this.resetBackoffInternal();
|
||||
this.log.info('Gateway session resumed');
|
||||
this.emit('resumed');
|
||||
break;
|
||||
}
|
||||
|
||||
this.emit('dispatch', payload.t, payload.d);
|
||||
}
|
||||
|
||||
private handleHelloPayload(payload: GatewayPayload): void {
|
||||
this.clearHelloTimeout();
|
||||
|
||||
if (this.invalidSessionTimeoutId != null) {
|
||||
clearTimeout(this.invalidSessionTimeoutId);
|
||||
this.invalidSessionTimeoutId = null;
|
||||
}
|
||||
|
||||
const helloData = payload.d as {heartbeat_interval: number};
|
||||
this.startHeartbeat(helloData.heartbeat_interval);
|
||||
|
||||
if (this.canResumeSession()) {
|
||||
this.sendResume();
|
||||
} else {
|
||||
this.sendIdentify();
|
||||
}
|
||||
}
|
||||
|
||||
private handleInvalidSessionPayload(payload: GatewayPayload): void {
|
||||
const isResumable = payload.d as boolean;
|
||||
this.log.info(`Session invalidated (resumable=${isResumable})`);
|
||||
|
||||
if (this.invalidSessionTimeoutId != null) {
|
||||
clearTimeout(this.invalidSessionTimeoutId);
|
||||
this.invalidSessionTimeoutId = null;
|
||||
}
|
||||
|
||||
const delay = 2500 + Math.random() * 1000;
|
||||
|
||||
if (isResumable) {
|
||||
this.invalidSessionTimeoutId = window.setTimeout(() => {
|
||||
this.invalidSessionTimeoutId = null;
|
||||
this.sendResume();
|
||||
}, delay);
|
||||
} else {
|
||||
this.clearSession();
|
||||
this.invalidSessionTimeoutId = window.setTimeout(() => {
|
||||
this.invalidSessionTimeoutId = null;
|
||||
this.sendIdentify();
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
private sendIdentify(): void {
|
||||
this.log.info('Sending IDENTIFY to gateway');
|
||||
|
||||
const flags = this.options.identifyFlags ?? 0;
|
||||
|
||||
this.sendPayload({
|
||||
op: GatewayOpcodes.IDENTIFY,
|
||||
d: {
|
||||
token: this.options.token,
|
||||
properties: this.options.properties,
|
||||
...(this.options.presence && {presence: this.options.presence}),
|
||||
flags,
|
||||
...(this.options.initialGuildId ? {initial_guild_id: this.options.initialGuildId} : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private sendResume(): void {
|
||||
if (!this.activeSessionId) {
|
||||
this.log.warn('Cannot RESUME without an active session, falling back to IDENTIFY');
|
||||
this.sendIdentify();
|
||||
return;
|
||||
}
|
||||
|
||||
this.log.info(`Sending RESUME for session ${this.activeSessionId}`);
|
||||
|
||||
this.sendPayload({
|
||||
op: GatewayOpcodes.RESUME,
|
||||
d: {
|
||||
token: this.options.token,
|
||||
session_id: this.activeSessionId,
|
||||
seq: this.lastSequenceNumber,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private startHeartbeat(intervalMs: number): void {
|
||||
this.stopHeartbeat();
|
||||
this.heartbeatIntervalMs = intervalMs;
|
||||
|
||||
const initialDelay = this.computeNextHeartbeatDelay();
|
||||
this.scheduleHeartbeat(initialDelay);
|
||||
|
||||
this.log.debug(`Heartbeat scheduled (interval=${intervalMs}ms, next=${initialDelay}ms)`);
|
||||
}
|
||||
|
||||
private computeNextHeartbeatDelay(): number {
|
||||
if (!this.heartbeatIntervalMs || this.heartbeatIntervalMs <= 0) {
|
||||
return 1000;
|
||||
}
|
||||
|
||||
const base = Math.max(1000, Math.floor(this.heartbeatIntervalMs * 0.8));
|
||||
const jitter = Math.min(1500, Math.floor(this.heartbeatIntervalMs * 0.05));
|
||||
|
||||
return base + Math.floor(Math.random() * (jitter + 1));
|
||||
}
|
||||
|
||||
private scheduleHeartbeat(delayMs?: number): void {
|
||||
if (!this.heartbeatIntervalMs) return;
|
||||
|
||||
const delay = delayMs ?? this.computeNextHeartbeatDelay();
|
||||
|
||||
if (this.heartbeatTimeoutId != null) {
|
||||
clearTimeout(this.heartbeatTimeoutId);
|
||||
}
|
||||
|
||||
this.heartbeatTimeoutId = window.setTimeout(() => this.handleHeartbeatTick(), delay);
|
||||
}
|
||||
|
||||
private handleHeartbeatTick(): void {
|
||||
this.heartbeatTimeoutId = null;
|
||||
this.sendHeartbeat();
|
||||
|
||||
if (this.heartbeatIntervalMs) {
|
||||
this.scheduleHeartbeat();
|
||||
}
|
||||
}
|
||||
|
||||
private heartbeatSkipThreshold(): number {
|
||||
if (!this.heartbeatIntervalMs || this.heartbeatIntervalMs <= 0) {
|
||||
return GATEWAY_TIMEOUTS.HeartbeatAck;
|
||||
}
|
||||
|
||||
const derived = Math.floor(this.heartbeatIntervalMs * 0.75);
|
||||
return Math.max(500, Math.min(GATEWAY_TIMEOUTS.HeartbeatAck, derived));
|
||||
}
|
||||
|
||||
private sendHeartbeat(serverRequested = false): void {
|
||||
if (this.awaitingHeartbeatAck && !serverRequested) {
|
||||
const now = Date.now();
|
||||
const elapsedSinceLastHeartbeat = this.lastHeartbeatSentAt ? now - this.lastHeartbeatSentAt : 0;
|
||||
const skipThreshold = this.heartbeatSkipThreshold();
|
||||
|
||||
if (elapsedSinceLastHeartbeat < skipThreshold) {
|
||||
const retryDelay = Math.max(500, skipThreshold - elapsedSinceLastHeartbeat);
|
||||
this.log.debug(`Deferring heartbeat while awaiting ACK (retry in ${retryDelay}ms)`);
|
||||
this.scheduleHeartbeat(retryDelay);
|
||||
return;
|
||||
}
|
||||
|
||||
if (elapsedSinceLastHeartbeat < GATEWAY_TIMEOUTS.HeartbeatAck) {
|
||||
const retryDelay = Math.max(500, GATEWAY_TIMEOUTS.HeartbeatAck - elapsedSinceLastHeartbeat);
|
||||
this.log.debug(`Still waiting for heartbeat ACK, delaying retry by ${retryDelay}ms`);
|
||||
this.scheduleHeartbeat(retryDelay);
|
||||
return;
|
||||
}
|
||||
|
||||
this.log.warn('Heartbeat ACK not received, forcing reconnect');
|
||||
this.handleHeartbeatFailure();
|
||||
return;
|
||||
}
|
||||
|
||||
const didSend = this.sendPayload({
|
||||
op: GatewayOpcodes.HEARTBEAT,
|
||||
d: this.lastSequenceNumber,
|
||||
});
|
||||
|
||||
if (!didSend) {
|
||||
this.log.error('Failed to transmit heartbeat');
|
||||
this.handleHeartbeatFailure();
|
||||
return;
|
||||
}
|
||||
|
||||
this.awaitingHeartbeatAck = true;
|
||||
this.lastHeartbeatSentAt = Date.now();
|
||||
this.emit('heartbeat', this.lastSequenceNumber);
|
||||
|
||||
if (serverRequested && this.heartbeatAckTimeoutId != null) {
|
||||
clearTimeout(this.heartbeatAckTimeoutId);
|
||||
}
|
||||
|
||||
this.startHeartbeatAckTimeout();
|
||||
|
||||
if (serverRequested && this.heartbeatIntervalMs) {
|
||||
this.scheduleHeartbeat();
|
||||
}
|
||||
|
||||
this.log.debug(`Heartbeat sent (seq=${this.lastSequenceNumber}${serverRequested ? ', serverRequested' : ''})`);
|
||||
}
|
||||
|
||||
private startHeartbeatAckTimeout(): void {
|
||||
this.heartbeatAckTimeoutId = window.setTimeout(() => {
|
||||
if (!this.awaitingHeartbeatAck) return;
|
||||
|
||||
this.log.warn('Heartbeat ACK timeout');
|
||||
this.handleHeartbeatFailure();
|
||||
}, GATEWAY_TIMEOUTS.HeartbeatAck);
|
||||
}
|
||||
|
||||
private handleHeartbeatAck(): void {
|
||||
this.awaitingHeartbeatAck = false;
|
||||
this.lastHeartbeatAckAt = Date.now();
|
||||
|
||||
if (this.heartbeatAckTimeoutId != null) {
|
||||
clearTimeout(this.heartbeatAckTimeoutId);
|
||||
this.heartbeatAckTimeoutId = null;
|
||||
}
|
||||
|
||||
this.log.debug('Heartbeat acknowledgment received');
|
||||
this.emit('heartbeatAck');
|
||||
}
|
||||
|
||||
private handleHeartbeatFailure(): void {
|
||||
this.log.warn('Heartbeat failed, reconnecting');
|
||||
this.shouldReconnectImmediately = true;
|
||||
this.disconnect(4000, 'Heartbeat ACK timeout', true);
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimeoutId != null) {
|
||||
clearTimeout(this.heartbeatTimeoutId);
|
||||
this.heartbeatTimeoutId = null;
|
||||
}
|
||||
|
||||
if (this.heartbeatAckTimeoutId != null) {
|
||||
clearTimeout(this.heartbeatAckTimeoutId);
|
||||
this.heartbeatAckTimeoutId = null;
|
||||
}
|
||||
|
||||
this.awaitingHeartbeatAck = false;
|
||||
this.heartbeatIntervalMs = null;
|
||||
|
||||
this.log.debug('Heartbeat stopped');
|
||||
}
|
||||
|
||||
private handleConnectionFailure(): void {
|
||||
if (this.isUserInitiatedDisconnect) {
|
||||
this.updateState(GatewayState.Disconnected);
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateState(GatewayState.Reconnecting);
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimeoutId != null) {
|
||||
this.log.debug('Reconnect already scheduled, ignoring');
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = this.shouldReconnectImmediately ? 0 : this.nextReconnectDelay();
|
||||
const wasImmediate = this.shouldReconnectImmediately;
|
||||
this.shouldReconnectImmediately = false;
|
||||
|
||||
this.log.info(`Scheduling reconnect in ${delay}ms${wasImmediate ? ' (immediate)' : ''}`);
|
||||
|
||||
this.reconnectTimeoutId = window.setTimeout(() => {
|
||||
this.reconnectTimeoutId = null;
|
||||
|
||||
if (!this.canResumeSession()) {
|
||||
this.log.info('Session no longer resumable, clearing state');
|
||||
this.clearSession();
|
||||
}
|
||||
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private nextReconnectDelay(): number {
|
||||
const now = Date.now();
|
||||
const elapsed = now - this.lastReconnectAt;
|
||||
|
||||
if (elapsed < GATEWAY_TIMEOUTS.MinReconnect) {
|
||||
this.log.debug(`Last reconnect ${elapsed}ms ago, enforcing minimum delay (${GATEWAY_TIMEOUTS.MinReconnect}ms)`);
|
||||
return GATEWAY_TIMEOUTS.MinReconnect;
|
||||
}
|
||||
|
||||
this.lastReconnectAt = now;
|
||||
const delay = this.reconnectBackoff.next();
|
||||
this.log.debug(`Reconnect backoff attempt=${this.reconnectBackoff.getCurrentAttempts()} delay=${delay}ms`);
|
||||
return delay;
|
||||
}
|
||||
|
||||
private resetBackoffInternal(): void {
|
||||
this.reconnectBackoff.reset();
|
||||
}
|
||||
|
||||
private canResumeSession(): boolean {
|
||||
const now = Date.now();
|
||||
|
||||
if (!this.activeSessionId) return false;
|
||||
|
||||
if (this.lastHeartbeatAckAt != null) {
|
||||
return now - this.lastHeartbeatAckAt <= GATEWAY_TIMEOUTS.ResumeWindow;
|
||||
}
|
||||
|
||||
if (this.lastHeartbeatSentAt != null) {
|
||||
return now - this.lastHeartbeatSentAt <= GATEWAY_TIMEOUTS.ResumeWindow;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private clearSession(): void {
|
||||
const hadSession = Boolean(this.activeSessionId);
|
||||
this.activeSessionId = null;
|
||||
this.lastSequenceNumber = 0;
|
||||
|
||||
if (hadSession) {
|
||||
this.log.info('Gateway session cleared');
|
||||
}
|
||||
}
|
||||
|
||||
private startHelloTimeout(): void {
|
||||
this.clearHelloTimeout();
|
||||
|
||||
this.helloTimeoutId = window.setTimeout(() => {
|
||||
this.log.warn('HELLO not received in time');
|
||||
this.disconnect(4000, 'Hello timeout');
|
||||
}, GATEWAY_TIMEOUTS.Hello);
|
||||
}
|
||||
|
||||
private clearHelloTimeout(): void {
|
||||
if (this.helloTimeoutId != null) {
|
||||
clearTimeout(this.helloTimeoutId);
|
||||
this.helloTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private buildGatewayUrl(): string {
|
||||
const url = new URL(this.gatewayUrlBase);
|
||||
url.searchParams.set('v', this.options.apiVersion.toString());
|
||||
url.searchParams.set('encoding', 'json');
|
||||
const compression: CompressionType = this.options.compression ?? 'zstd-stream';
|
||||
url.searchParams.set('compress', compression);
|
||||
const built = url.toString();
|
||||
return this.gatewayUrlWrapper ? this.gatewayUrlWrapper(built) : built;
|
||||
}
|
||||
|
||||
private sendPayload(payload: GatewayPayload): boolean {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
this.log.warn('Attempted to send gateway payload while socket is not open');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.stringify(payload);
|
||||
this.socket.send(data);
|
||||
this.log.debug('Gateway payload sent', payload);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.log.error('Error while sending gateway payload', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private updateState(nextState: GatewayState): void {
|
||||
if (this.connectionState === nextState) return;
|
||||
|
||||
const previous = this.connectionState;
|
||||
this.connectionState = nextState;
|
||||
|
||||
this.log.info(`Gateway state ${previous} -> ${nextState}`);
|
||||
this.emit('stateChange', nextState, previous);
|
||||
}
|
||||
|
||||
private handleAuthFailure(): void {
|
||||
this.log.error('Authentication failed: clearing client state and logging out');
|
||||
this.updateState(GatewayState.Disconnected);
|
||||
|
||||
AppStorage.clear();
|
||||
LayerManager.closeAll();
|
||||
ConnectionStore.logout();
|
||||
AuthenticationStore.handleConnectionClosed({code: 4004});
|
||||
}
|
||||
}
|
||||
893
fluxer_app/src/lib/HttpClient.ts
Normal file
893
fluxer_app/src/lib/HttpClient.ts
Normal file
@@ -0,0 +1,893 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Config from '~/Config';
|
||||
import {ExponentialBackoff} from '~/lib/ExponentialBackoff';
|
||||
import {HttpError} from '~/lib/HttpError';
|
||||
import type {HttpMethod} from '~/lib/HttpTypes';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import type {SudoVerificationPayload} from '~/types/Sudo';
|
||||
import {getElectronApiProxyBaseUrl, isCustomInstanceUrl, isElectronApiProxyUrl} from '~/utils/ApiProxyUtils';
|
||||
|
||||
const SUDO_MODE_HEADER = 'x-fluxer-sudo-mode-jwt';
|
||||
|
||||
interface Attachment {
|
||||
name: string;
|
||||
file: File | Blob;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
interface FormField {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface HttpRequestConfig {
|
||||
url: string;
|
||||
method?: HttpMethod;
|
||||
query?: Record<string, string | number | boolean | null> | URLSearchParams;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
retries?: number;
|
||||
timeout?: number;
|
||||
signal?: AbortSignal;
|
||||
skipAuth?: boolean;
|
||||
skipParsing?: boolean;
|
||||
binary?: boolean;
|
||||
reason?: string;
|
||||
attachments?: Array<Attachment>;
|
||||
fields?: Array<FormField>;
|
||||
rejectWithError?: boolean;
|
||||
failImmediatelyWhenRateLimited?: boolean;
|
||||
interceptResponse?: ResponseInterceptor;
|
||||
onRateLimit?: (retryAfter: number, retry: () => void) => void;
|
||||
onRequestCreated?: (state: RequestState) => void;
|
||||
onRequestProgress?: (progress: ProgressEvent) => void;
|
||||
sudoRetry?: boolean;
|
||||
sudoApplied?: boolean;
|
||||
}
|
||||
|
||||
type BodyWithoutBodyKey<T> = [T] extends [object] ? ('body' extends keyof T ? never : T) : T;
|
||||
|
||||
type HttpRequestBody = BodyWithoutBodyKey<HttpRequestConfig['body']>;
|
||||
|
||||
export interface HttpResponse<T = unknown> {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText?: string;
|
||||
headers: Record<string, string>;
|
||||
body: T;
|
||||
text?: string;
|
||||
hasErr?: boolean;
|
||||
err?: Error;
|
||||
}
|
||||
|
||||
interface RequestState {
|
||||
abortController?: AbortController;
|
||||
request?: XMLHttpRequest;
|
||||
abort?: () => void;
|
||||
}
|
||||
|
||||
interface RateLimitEntry {
|
||||
queue: Array<() => void>;
|
||||
retryAfterTimestamp: number;
|
||||
latestErrorMessage: string;
|
||||
timeoutId: number;
|
||||
}
|
||||
|
||||
type InterceptorFn = (
|
||||
response: HttpResponse,
|
||||
retryWithHeaders: (
|
||||
headers: Record<string, string>,
|
||||
overrideInterceptor?: ResponseInterceptor,
|
||||
) => Promise<HttpResponse>,
|
||||
reject: (error: Error) => void,
|
||||
) => boolean | Promise<HttpResponse> | undefined;
|
||||
|
||||
type ResponseInterceptor = InterceptorFn;
|
||||
|
||||
type PrepareRequestInterceptor = (state: RequestState) => void;
|
||||
|
||||
type SudoHandler = (config: HttpRequestConfig) => Promise<SudoVerificationPayload>;
|
||||
|
||||
type SudoTokenProvider = () => string | null;
|
||||
|
||||
type SudoTokenListener = (token: string | null) => void;
|
||||
|
||||
type SudoFailureHandler = (error: HttpError | HttpResponse | string | unknown) => void;
|
||||
|
||||
type AuthTokenProvider = () => string | null;
|
||||
|
||||
const RETRYABLE_STATUS_CODES = new Set([502, 504, 507, 598, 599, 522, 523, 524]);
|
||||
|
||||
function hasElectronProxy(): boolean {
|
||||
return getElectronApiProxyBaseUrl() !== null;
|
||||
}
|
||||
|
||||
class HttpClient {
|
||||
private readonly log = new Logger('HttpClient');
|
||||
|
||||
private baseUrl: string;
|
||||
private apiVersion: number;
|
||||
|
||||
private defaultTimeoutMs = 30000;
|
||||
private defaultRetryCount = 0;
|
||||
|
||||
private readonly rateLimitMap = new Map<string, RateLimitEntry>();
|
||||
|
||||
private prepareRequestHandler?: PrepareRequestInterceptor;
|
||||
private responseInterceptor?: ResponseInterceptor;
|
||||
|
||||
private sudoHandler?: SudoHandler;
|
||||
private sudoTokenProvider?: SudoTokenProvider;
|
||||
private sudoTokenListener?: SudoTokenListener;
|
||||
private sudoTokenInvalidator?: () => void;
|
||||
private sudoFailureHandler?: SudoFailureHandler;
|
||||
private authTokenProvider?: AuthTokenProvider;
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = Config.PUBLIC_BOOTSTRAP_API_ENDPOINT;
|
||||
this.apiVersion = Config.PUBLIC_API_VERSION;
|
||||
|
||||
this.request = this.request.bind(this);
|
||||
this.get = this.get.bind(this);
|
||||
this.post = this.post.bind(this);
|
||||
this.put = this.put.bind(this);
|
||||
this.patch = this.patch.bind(this);
|
||||
this.delete = this.delete.bind(this);
|
||||
}
|
||||
|
||||
setInterceptors(params: {prepareRequest?: PrepareRequestInterceptor; interceptResponse?: ResponseInterceptor}): void {
|
||||
this.prepareRequestHandler = params.prepareRequest;
|
||||
this.responseInterceptor = params.interceptResponse;
|
||||
}
|
||||
|
||||
setBaseUrl(baseUrl: string, apiVersion?: number): void {
|
||||
this.baseUrl = baseUrl;
|
||||
if (typeof apiVersion === 'number') {
|
||||
this.apiVersion = apiVersion;
|
||||
}
|
||||
}
|
||||
|
||||
setSudoHandler(handler?: SudoHandler): void {
|
||||
this.sudoHandler = handler;
|
||||
}
|
||||
|
||||
setSudoTokenProvider(provider?: SudoTokenProvider): void {
|
||||
this.sudoTokenProvider = provider;
|
||||
}
|
||||
|
||||
setSudoTokenListener(listener?: SudoTokenListener): void {
|
||||
this.sudoTokenListener = listener;
|
||||
}
|
||||
|
||||
setSudoTokenInvalidator(invalidator?: () => void): void {
|
||||
this.sudoTokenInvalidator = invalidator;
|
||||
}
|
||||
|
||||
setSudoFailureHandler(handler?: SudoFailureHandler): void {
|
||||
this.sudoFailureHandler = handler;
|
||||
}
|
||||
|
||||
setAuthTokenProvider(provider?: AuthTokenProvider): void {
|
||||
this.authTokenProvider = provider;
|
||||
}
|
||||
|
||||
setDefaults(options: {timeout?: number; retries?: number} = {}): void {
|
||||
if (typeof options.timeout === 'number') {
|
||||
this.defaultTimeoutMs = options.timeout;
|
||||
}
|
||||
if (typeof options.retries === 'number') {
|
||||
this.defaultRetryCount = options.retries;
|
||||
}
|
||||
}
|
||||
|
||||
async request<T = unknown>(method: HttpMethod, urlOrConfig: string | HttpRequestConfig): Promise<HttpResponse<T>> {
|
||||
const config: HttpRequestConfig = typeof urlOrConfig === 'string' ? {url: urlOrConfig} : urlOrConfig;
|
||||
return this.executeRequest<T>(method, config);
|
||||
}
|
||||
|
||||
async get<T = unknown>(urlOrConfig: string | HttpRequestConfig): Promise<HttpResponse<T>> {
|
||||
return this.request<T>('GET', urlOrConfig);
|
||||
}
|
||||
|
||||
async post<T = unknown>(urlOrConfig: string | HttpRequestConfig, data?: HttpRequestBody): Promise<HttpResponse<T>> {
|
||||
return this.request<T>('POST', this.normalizeConfig(urlOrConfig, data));
|
||||
}
|
||||
|
||||
async put<T = unknown>(urlOrConfig: string | HttpRequestConfig, data?: HttpRequestBody): Promise<HttpResponse<T>> {
|
||||
return this.request<T>('PUT', this.normalizeConfig(urlOrConfig, data));
|
||||
}
|
||||
|
||||
async patch<T = unknown>(urlOrConfig: string | HttpRequestConfig, data?: HttpRequestBody): Promise<HttpResponse<T>> {
|
||||
return this.request<T>('PATCH', this.normalizeConfig(urlOrConfig, data));
|
||||
}
|
||||
|
||||
async delete<T = unknown>(urlOrConfig: string | HttpRequestConfig): Promise<HttpResponse<T>> {
|
||||
return this.request<T>('DELETE', urlOrConfig);
|
||||
}
|
||||
|
||||
private normalizeConfig(urlOrConfig: string | HttpRequestConfig, body?: HttpRequestBody): HttpRequestConfig {
|
||||
if (typeof urlOrConfig === 'string') {
|
||||
return {url: urlOrConfig, body};
|
||||
}
|
||||
return {...urlOrConfig, body: body ?? urlOrConfig.body};
|
||||
}
|
||||
|
||||
private resolveRequestUrl(
|
||||
path: string,
|
||||
query?: Record<string, string | number | boolean | null> | URLSearchParams,
|
||||
): string {
|
||||
if (path.startsWith('//') || path.includes('://')) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const base = `${this.baseUrl}/v${this.apiVersion}${path}`;
|
||||
if (!query) return base;
|
||||
|
||||
const params =
|
||||
query instanceof URLSearchParams
|
||||
? query
|
||||
: new URLSearchParams(
|
||||
Object.entries(query)
|
||||
.filter(([, value]) => value != null)
|
||||
.map(([key, value]) => [key, String(value)]),
|
||||
);
|
||||
|
||||
const queryString = params.toString();
|
||||
return queryString ? `${base}?${queryString}` : base;
|
||||
}
|
||||
|
||||
private buildRequestHeaders(config: HttpRequestConfig, retryCount: number): Record<string, string> {
|
||||
const headers: Record<string, string> = {...(config.headers ?? {})};
|
||||
|
||||
if (!config.skipAuth && !config.url.includes('://')) {
|
||||
const authToken = this.authTokenProvider?.();
|
||||
if (authToken) {
|
||||
headers.Authorization = authToken;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.reason) {
|
||||
headers['X-Audit-Log-Reason'] = encodeURIComponent(config.reason);
|
||||
}
|
||||
|
||||
if (retryCount > 0) {
|
||||
headers['X-Failed-Requests'] = String(retryCount);
|
||||
}
|
||||
|
||||
if (config.body && !headers['Content-Type'] && !(config.body instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const sudoToken = this.sudoTokenProvider?.();
|
||||
if (sudoToken) {
|
||||
headers[SUDO_MODE_HEADER] = sudoToken;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private buildFormData(config: HttpRequestConfig): FormData | null {
|
||||
if (!config.attachments && !config.fields) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
|
||||
for (const attachment of config.attachments ?? []) {
|
||||
form.append(attachment.name, attachment.file, attachment.filename);
|
||||
}
|
||||
|
||||
for (const field of config.fields ?? []) {
|
||||
form.append(field.name, field.value);
|
||||
}
|
||||
|
||||
return form;
|
||||
}
|
||||
|
||||
private serializeBody(config: HttpRequestConfig): string | FormData | Blob | ArrayBuffer | undefined {
|
||||
const form = this.buildFormData(config);
|
||||
if (form) return form;
|
||||
|
||||
const {body} = config;
|
||||
if (!body) return;
|
||||
|
||||
if (typeof body === 'string' || body instanceof Blob || body instanceof ArrayBuffer || body instanceof FormData) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
private parseXHRResponse<T>(xhr: XMLHttpRequest, config: HttpRequestConfig): {body: T; text?: string} {
|
||||
if (config.skipParsing) {
|
||||
return {body: undefined as T};
|
||||
}
|
||||
|
||||
if (xhr.status === 204) {
|
||||
return {body: undefined as T};
|
||||
}
|
||||
|
||||
if (config.binary) {
|
||||
return {body: xhr.response as T};
|
||||
}
|
||||
|
||||
const contentType = xhr.getResponseHeader('content-type') || '';
|
||||
const text = xhr.responseText;
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
if (!text) {
|
||||
return {body: undefined as T};
|
||||
}
|
||||
|
||||
try {
|
||||
return {body: JSON.parse(text) as T, text};
|
||||
} catch {
|
||||
return {body: text as T, text};
|
||||
}
|
||||
}
|
||||
|
||||
return {body: text as T, text};
|
||||
}
|
||||
|
||||
private parseXHRHeaders(xhr: XMLHttpRequest): Record<string, string> {
|
||||
const headerMap: Record<string, string> = {};
|
||||
const raw = xhr.getAllResponseHeaders();
|
||||
|
||||
if (!raw) return headerMap;
|
||||
|
||||
for (const line of raw.trim().split(/[\r\n]+/)) {
|
||||
const parts = line.split(': ');
|
||||
const name = parts.shift();
|
||||
const value = parts.join(': ');
|
||||
if (name) {
|
||||
headerMap[name.toLowerCase()] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return headerMap;
|
||||
}
|
||||
|
||||
private shouldUseProxy(url: string): boolean {
|
||||
return hasElectronProxy() && !isElectronApiProxyUrl(url) && isCustomInstanceUrl(url);
|
||||
}
|
||||
|
||||
private async executeViaProxy<T>(
|
||||
method: HttpMethod,
|
||||
fullUrl: string,
|
||||
headers: Record<string, string>,
|
||||
body: string | FormData | Blob | ArrayBuffer | undefined,
|
||||
config: HttpRequestConfig,
|
||||
): Promise<HttpResponse<T>> {
|
||||
const baseUrl = getElectronApiProxyBaseUrl();
|
||||
if (!baseUrl) {
|
||||
throw new Error('Electron API proxy is unavailable');
|
||||
}
|
||||
|
||||
const proxyUrl = new URL(baseUrl.toString());
|
||||
proxyUrl.searchParams.set('target', fullUrl);
|
||||
|
||||
const requestHeaders = new Headers();
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (value != null) {
|
||||
requestHeaders.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const initiator =
|
||||
typeof window !== 'undefined'
|
||||
? window.location.origin
|
||||
: Config.PUBLIC_PROJECT_ENV === 'canary'
|
||||
? 'https://web.canary.fluxer.app'
|
||||
: 'https://web.fluxer.app';
|
||||
requestHeaders.set('X-Fluxer-Proxy-Initiator', initiator);
|
||||
|
||||
const controller = new AbortController();
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
if (config.timeout && config.timeout > 0) {
|
||||
timeoutId = setTimeout(() => controller.abort(), config.timeout);
|
||||
}
|
||||
|
||||
let signalListener: (() => void) | null = null;
|
||||
if (config.signal) {
|
||||
if (config.signal.aborted) {
|
||||
controller.abort();
|
||||
} else {
|
||||
signalListener = () => controller.abort();
|
||||
config.signal.addEventListener('abort', signalListener);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(proxyUrl.toString(), {
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const responseHeaders: Record<string, string> = {};
|
||||
response.headers.forEach((value, name) => {
|
||||
responseHeaders[name.toLowerCase()] = value;
|
||||
});
|
||||
|
||||
let parsedBody: T;
|
||||
let text: string | undefined;
|
||||
|
||||
if (config.binary) {
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
parsedBody = arrayBuffer as unknown as T;
|
||||
} else {
|
||||
text = await response.text();
|
||||
|
||||
if (config.skipParsing) {
|
||||
parsedBody = undefined as T;
|
||||
} else if (response.status === 204) {
|
||||
parsedBody = undefined as T;
|
||||
} else {
|
||||
const contentType = responseHeaders['content-type'] || '';
|
||||
if (contentType.includes('application/json') && text) {
|
||||
try {
|
||||
parsedBody = JSON.parse(text) as T;
|
||||
} catch {
|
||||
parsedBody = text as T;
|
||||
}
|
||||
} else {
|
||||
parsedBody = text as T;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
headers: responseHeaders,
|
||||
body: parsedBody,
|
||||
text,
|
||||
};
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
if (config.signal && signalListener) {
|
||||
config.signal.removeEventListener('abort', signalListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private parseRetryAfterSeconds(body: unknown): number {
|
||||
if (body && typeof body === 'object' && 'retry_after' in body) {
|
||||
const retryValue = (body as {retry_after?: number | string}).retry_after;
|
||||
const num = typeof retryValue === 'string' ? Number(retryValue) : retryValue;
|
||||
|
||||
if (typeof num === 'number' && Number.isFinite(num)) {
|
||||
return num;
|
||||
}
|
||||
}
|
||||
|
||||
return 5;
|
||||
}
|
||||
|
||||
private parseRateLimitMessage(body: unknown): string {
|
||||
if (body && typeof body === 'object' && 'message' in body) {
|
||||
const message = (body as {message?: unknown}).message;
|
||||
if (typeof message === 'string') {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private updateRateLimitEntry(urlKey: string, response?: HttpResponse): void {
|
||||
const existing = this.rateLimitMap.get(urlKey);
|
||||
|
||||
if (response?.status === 429) {
|
||||
const retryAfter = this.parseRetryAfterSeconds(response.body);
|
||||
const deadline = Date.now() + retryAfter * 1000;
|
||||
|
||||
if (existing && existing.retryAfterTimestamp >= deadline) {
|
||||
this.log.debug('Rate limit already present for', urlKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
this.log.debug('Extending rate limit for', urlKey);
|
||||
clearTimeout(existing.timeoutId);
|
||||
}
|
||||
|
||||
this.log.debug(`Rate limit for ${urlKey}, retry in ${retryAfter}s`);
|
||||
|
||||
const timeoutId = window.setTimeout(() => this.releaseRateLimitedQueue(urlKey), retryAfter * 1000);
|
||||
|
||||
this.rateLimitMap.set(urlKey, {
|
||||
queue: existing?.queue ?? [],
|
||||
retryAfterTimestamp: deadline,
|
||||
latestErrorMessage: this.parseRateLimitMessage(response.body),
|
||||
timeoutId,
|
||||
});
|
||||
} else if (existing && existing.retryAfterTimestamp < Date.now()) {
|
||||
this.log.debug('Rate limit expired for', urlKey);
|
||||
this.releaseRateLimitedQueue(urlKey);
|
||||
}
|
||||
}
|
||||
|
||||
private releaseRateLimitedQueue(urlKey: string): void {
|
||||
const entry = this.rateLimitMap.get(urlKey);
|
||||
if (!entry) {
|
||||
this.log.debug('Rate limit expired for', urlKey, 'but entry was already removed');
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(entry.timeoutId);
|
||||
this.rateLimitMap.delete(urlKey);
|
||||
|
||||
if (!entry.queue.length) {
|
||||
this.log.debug('Clearing rate-limit state for', urlKey, '(no queued jobs)');
|
||||
return;
|
||||
}
|
||||
|
||||
const queued = entry.queue.splice(0);
|
||||
this.log.debug('Releasing', queued.length, 'queued requests for', urlKey);
|
||||
|
||||
for (const fn of queued) {
|
||||
try {
|
||||
fn();
|
||||
} catch (error) {
|
||||
this.log.error('Error while executing queued rate-limited request for', urlKey, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private shouldRetryStatus(
|
||||
status: number | undefined,
|
||||
retryCount: number | undefined,
|
||||
maxRetries: number | undefined,
|
||||
): boolean {
|
||||
if (retryCount === undefined || maxRetries === undefined) return false;
|
||||
if (retryCount >= maxRetries) return false;
|
||||
return status !== undefined && RETRYABLE_STATUS_CODES.has(status);
|
||||
}
|
||||
|
||||
private ensureBackoff(backoff?: ExponentialBackoff): ExponentialBackoff {
|
||||
if (backoff) return backoff;
|
||||
|
||||
return new ExponentialBackoff({
|
||||
minDelay: 1000,
|
||||
maxDelay: 30000,
|
||||
jitter: true,
|
||||
});
|
||||
}
|
||||
|
||||
private async executeRequest<T>(
|
||||
method: HttpMethod,
|
||||
config: HttpRequestConfig,
|
||||
retryCount = 0,
|
||||
backoff?: ExponentialBackoff,
|
||||
): Promise<HttpResponse<T>> {
|
||||
const effectiveConfig: HttpRequestConfig = {
|
||||
...config,
|
||||
method,
|
||||
timeout: config.timeout !== undefined ? config.timeout : this.defaultTimeoutMs,
|
||||
retries: config.retries !== undefined ? config.retries : this.defaultRetryCount,
|
||||
};
|
||||
|
||||
const rateLimit = this.rateLimitMap.get(effectiveConfig.url);
|
||||
|
||||
if (rateLimit) {
|
||||
if (effectiveConfig.failImmediatelyWhenRateLimited) {
|
||||
const secondsRemaining = Math.max(0, Math.round((rateLimit.retryAfterTimestamp - Date.now()) / 1000));
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: 429,
|
||||
headers: {},
|
||||
body: {
|
||||
message: rateLimit.latestErrorMessage,
|
||||
retry_after: secondsRemaining,
|
||||
} as T,
|
||||
text: '',
|
||||
};
|
||||
}
|
||||
|
||||
this.log.debug('Queueing rate-limited request for', effectiveConfig.url);
|
||||
|
||||
return new Promise<HttpResponse<T>>((resolve, reject) => {
|
||||
rateLimit.queue.push(() => {
|
||||
this.executeRequest<T>(method, effectiveConfig, retryCount, backoff).then(resolve, reject);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const requestState: RequestState = {};
|
||||
effectiveConfig.onRequestCreated?.(requestState);
|
||||
this.prepareRequestHandler?.(requestState);
|
||||
|
||||
try {
|
||||
const headers = this.buildRequestHeaders(effectiveConfig, retryCount);
|
||||
const body = this.serializeBody(effectiveConfig);
|
||||
const fullUrl = this.resolveRequestUrl(effectiveConfig.url, effectiveConfig.query);
|
||||
|
||||
const viaProxy = this.shouldUseProxy(fullUrl);
|
||||
let response: HttpResponse<T>;
|
||||
|
||||
if (viaProxy) {
|
||||
response = await this.executeViaProxy<T>(method, fullUrl, headers, body, effectiveConfig);
|
||||
} else {
|
||||
response = await this.performXHRRequest<T>(method, fullUrl, headers, body, effectiveConfig, requestState);
|
||||
}
|
||||
|
||||
if (this.shouldRetryStatus(response.status, retryCount, effectiveConfig.retries)) {
|
||||
const retryBackoff = this.ensureBackoff(backoff);
|
||||
await new Promise((resolve) => setTimeout(resolve, retryBackoff.next()));
|
||||
return this.executeRequest<T>(method, effectiveConfig, retryCount + 1, retryBackoff);
|
||||
}
|
||||
|
||||
this.updateRateLimitEntry(effectiveConfig.url, response);
|
||||
|
||||
const sudoHeader = response.headers[SUDO_MODE_HEADER];
|
||||
|
||||
if (this.sudoTokenListener && response.ok) {
|
||||
if (sudoHeader) {
|
||||
this.sudoTokenListener(sudoHeader);
|
||||
} else if (effectiveConfig.sudoApplied) {
|
||||
this.sudoTokenListener(null);
|
||||
}
|
||||
}
|
||||
|
||||
let chainedRequest: Promise<HttpResponse<T>> | null = null;
|
||||
|
||||
const retryWithHeaders = (
|
||||
overrideHeaders: Record<string, string>,
|
||||
overrideInterceptor?: ResponseInterceptor,
|
||||
): Promise<HttpResponse<T>> => {
|
||||
const nextConfig: HttpRequestConfig = {
|
||||
...effectiveConfig,
|
||||
headers: {...effectiveConfig.headers, ...overrideHeaders},
|
||||
};
|
||||
|
||||
if (overrideInterceptor) {
|
||||
nextConfig.interceptResponse = overrideInterceptor;
|
||||
}
|
||||
|
||||
chainedRequest = this.executeRequest<T>(method, nextConfig, retryCount, backoff);
|
||||
|
||||
return chainedRequest;
|
||||
};
|
||||
|
||||
const rejectIntercepted = (error: Error) => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
if (effectiveConfig.interceptResponse) {
|
||||
const result = effectiveConfig.interceptResponse(response, retryWithHeaders, rejectIntercepted);
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result as Promise<HttpResponse<T>>;
|
||||
}
|
||||
|
||||
if (result === true) {
|
||||
return chainedRequest ?? response;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.responseInterceptor) {
|
||||
const result = this.responseInterceptor(response, retryWithHeaders, rejectIntercepted);
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result as Promise<HttpResponse<T>>;
|
||||
}
|
||||
|
||||
if (result === true) {
|
||||
return chainedRequest ?? response;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok && effectiveConfig.rejectWithError !== false) {
|
||||
throw new HttpError({
|
||||
method,
|
||||
url: effectiveConfig.url,
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
body: response.body,
|
||||
text: response.text,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
const urlKey = effectiveConfig.url;
|
||||
|
||||
if (
|
||||
error instanceof HttpError &&
|
||||
error.status === 403 &&
|
||||
!effectiveConfig.sudoRetry &&
|
||||
this.isSudoRequiredError(error)
|
||||
) {
|
||||
if (this.sudoTokenInvalidator) {
|
||||
this.sudoTokenInvalidator();
|
||||
}
|
||||
|
||||
if (this.sudoHandler) {
|
||||
const sudoPayload = await this.sudoHandler(effectiveConfig);
|
||||
|
||||
if (sudoPayload) {
|
||||
const retryConfig = this.buildSudoRetryConfig(effectiveConfig, sudoPayload);
|
||||
|
||||
try {
|
||||
return await this.executeRequest<T>(method, retryConfig, retryCount, backoff);
|
||||
} catch (retryError) {
|
||||
if (this.sudoFailureHandler) {
|
||||
this.sudoFailureHandler(retryError);
|
||||
}
|
||||
|
||||
if (this.sudoHandler) {
|
||||
const nextPayload = await this.sudoHandler(effectiveConfig);
|
||||
if (nextPayload) {
|
||||
const nextConfig = this.buildSudoRetryConfig(effectiveConfig, nextPayload);
|
||||
return await this.executeRequest<T>(method, nextConfig, retryCount, backoff);
|
||||
}
|
||||
}
|
||||
|
||||
throw retryError;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (effectiveConfig.sudoApplied && this.sudoHandler) {
|
||||
if (this.sudoFailureHandler) {
|
||||
this.sudoFailureHandler(error);
|
||||
}
|
||||
|
||||
const retryPayload = await this.sudoHandler(effectiveConfig);
|
||||
if (retryPayload) {
|
||||
const retryConfig = this.buildSudoRetryConfig(effectiveConfig, retryPayload);
|
||||
return this.executeRequest<T>(method, retryConfig, retryCount, backoff);
|
||||
}
|
||||
}
|
||||
|
||||
this.updateRateLimitEntry(urlKey);
|
||||
|
||||
if (
|
||||
!(error instanceof HttpError) &&
|
||||
error instanceof Error &&
|
||||
error.name !== 'AbortError' &&
|
||||
effectiveConfig.retries &&
|
||||
retryCount < effectiveConfig.retries
|
||||
) {
|
||||
const retryBackoff = this.ensureBackoff(backoff);
|
||||
await new Promise((resolve) => setTimeout(resolve, retryBackoff.next()));
|
||||
return this.executeRequest<T>(method, effectiveConfig, retryCount + 1, retryBackoff);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private performXHRRequest<T>(
|
||||
method: HttpMethod,
|
||||
fullUrl: string,
|
||||
headers: Record<string, string>,
|
||||
body: string | FormData | Blob | ArrayBuffer | undefined,
|
||||
config: HttpRequestConfig,
|
||||
state: RequestState,
|
||||
): Promise<HttpResponse<T>> {
|
||||
return new Promise<HttpResponse<T>>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
state.request = xhr;
|
||||
state.abort = () => xhr.abort();
|
||||
|
||||
if (config.onRequestProgress) {
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
config.onRequestProgress?.(event);
|
||||
});
|
||||
}
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
const {body: parsedBody, text} = this.parseXHRResponse<T>(xhr, config);
|
||||
|
||||
const response: HttpResponse<T> = {
|
||||
ok: xhr.status >= 200 && xhr.status < 300,
|
||||
status: xhr.status,
|
||||
statusText: xhr.statusText,
|
||||
headers: this.parseXHRHeaders(xhr),
|
||||
body: parsedBody,
|
||||
text,
|
||||
};
|
||||
|
||||
resolve(response);
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
reject(new Error('Network error during request'));
|
||||
});
|
||||
|
||||
xhr.addEventListener('abort', () => {
|
||||
reject(new DOMException('Request aborted', 'AbortError'));
|
||||
});
|
||||
|
||||
xhr.addEventListener('timeout', () => {
|
||||
reject(new DOMException('Request timeout', 'TimeoutError'));
|
||||
});
|
||||
|
||||
if (config.signal) {
|
||||
const abortHandler = () => xhr.abort();
|
||||
config.signal.addEventListener('abort', abortHandler);
|
||||
|
||||
xhr.addEventListener('loadend', () => {
|
||||
config.signal?.removeEventListener('abort', abortHandler);
|
||||
});
|
||||
}
|
||||
|
||||
xhr.open(method, fullUrl);
|
||||
|
||||
if (config.binary) {
|
||||
xhr.responseType = 'blob';
|
||||
}
|
||||
|
||||
if (config.timeout && config.timeout > 0) {
|
||||
xhr.timeout = config.timeout;
|
||||
}
|
||||
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
xhr.setRequestHeader(name, value);
|
||||
}
|
||||
|
||||
xhr.send(body as XMLHttpRequestBodyInit);
|
||||
});
|
||||
}
|
||||
|
||||
private isSudoRequiredError(error: HttpError): boolean {
|
||||
const body = error.body as {code?: string} | undefined;
|
||||
return body?.code === 'SUDO_MODE_REQUIRED';
|
||||
}
|
||||
|
||||
private buildSudoRetryConfig(config: HttpRequestConfig, payload: SudoVerificationPayload): HttpRequestConfig {
|
||||
return {
|
||||
...config,
|
||||
sudoRetry: true,
|
||||
sudoApplied: true,
|
||||
body: this.mergeSudoPayload(config.body, payload),
|
||||
};
|
||||
}
|
||||
|
||||
private mergeSudoPayload(
|
||||
body: HttpRequestConfig['body'],
|
||||
payload: SudoVerificationPayload,
|
||||
): HttpRequestConfig['body'] {
|
||||
if (!body) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof body === 'object' &&
|
||||
!(body instanceof FormData) &&
|
||||
!(body instanceof Blob) &&
|
||||
!(body instanceof ArrayBuffer)
|
||||
) {
|
||||
return {...(body as Record<string, unknown>), ...payload};
|
||||
}
|
||||
|
||||
throw new Error('Cannot apply sudo verification to this request');
|
||||
}
|
||||
}
|
||||
|
||||
export default new HttpClient();
|
||||
export {HttpError};
|
||||
52
fluxer_app/src/lib/HttpError.ts
Normal file
52
fluxer_app/src/lib/HttpError.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {HttpMethod} from '~/lib/HttpTypes';
|
||||
|
||||
export class HttpError extends Error {
|
||||
method: HttpMethod;
|
||||
url: string;
|
||||
status?: number;
|
||||
ok: boolean;
|
||||
body?: unknown;
|
||||
text?: string;
|
||||
headers?: Record<string, string>;
|
||||
|
||||
constructor(params: {
|
||||
method: HttpMethod;
|
||||
url: string;
|
||||
ok: boolean;
|
||||
status: number;
|
||||
body?: unknown;
|
||||
text?: string;
|
||||
headers?: Record<string, string>;
|
||||
}) {
|
||||
const redactedUrl = params.url.replace(/\d+/g, 'xxx');
|
||||
super(`${params.method.toUpperCase()} ${redactedUrl} [${params.status}]`);
|
||||
|
||||
this.name = 'HTTPResponseError';
|
||||
this.method = params.method;
|
||||
this.url = params.url;
|
||||
this.ok = params.ok;
|
||||
this.status = params.status;
|
||||
this.body = params.body;
|
||||
this.text = params.text;
|
||||
this.headers = params.headers;
|
||||
}
|
||||
}
|
||||
20
fluxer_app/src/lib/HttpTypes.ts
Normal file
20
fluxer_app/src/lib/HttpTypes.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
98
fluxer_app/src/lib/InputFocusManager.tsx
Normal file
98
fluxer_app/src/lib/InputFocusManager.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
||||
import ModalStore from '~/stores/ModalStore';
|
||||
import PopoutStore from '~/stores/PopoutStore';
|
||||
|
||||
export type FocusableElementType = HTMLInputElement | HTMLTextAreaElement | HTMLDivElement;
|
||||
|
||||
class InputFocusManager {
|
||||
private static instance: InputFocusManager | null = null;
|
||||
|
||||
static getInstance(): InputFocusManager {
|
||||
if (!InputFocusManager.instance) {
|
||||
InputFocusManager.instance = new InputFocusManager();
|
||||
}
|
||||
return InputFocusManager.instance;
|
||||
}
|
||||
|
||||
private constructor() {}
|
||||
|
||||
private isFocusableElement(element: Element | null): element is FocusableElementType {
|
||||
if (!element) return false;
|
||||
|
||||
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (element instanceof HTMLDivElement && (element as HTMLDivElement).contentEditable === 'true') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
isInputFocused(excludingElement?: FocusableElementType): boolean {
|
||||
const activeElement = document.activeElement;
|
||||
if (!this.isFocusableElement(activeElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (excludingElement && activeElement === excludingElement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
canFocusTextarea(textareaElement?: FocusableElementType): boolean {
|
||||
const hasModalOpen = ModalStore?.hasModalOpen?.() ?? false;
|
||||
const hasPopoutsOpen = (PopoutStore?.getPopouts?.() ?? []).length > 0;
|
||||
const isMobileLayout = !!MobileLayoutStore?.enabled;
|
||||
|
||||
const inputFocused = this.isInputFocused(textareaElement);
|
||||
|
||||
return !(isMobileLayout || hasModalOpen || hasPopoutsOpen || inputFocused);
|
||||
}
|
||||
|
||||
safeFocus(element: FocusableElementType, force: boolean = false): boolean {
|
||||
if (!force && !this.canFocusTextarea(element)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (element instanceof HTMLElement) {
|
||||
element.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const inputFocusManager = InputFocusManager.getInstance();
|
||||
|
||||
export const isInputFocused = (excludingElement?: FocusableElementType) =>
|
||||
inputFocusManager.isInputFocused(excludingElement);
|
||||
|
||||
export const canFocusTextarea = (textareaElement?: FocusableElementType) =>
|
||||
inputFocusManager.canFocusTextarea(textareaElement);
|
||||
|
||||
export const safeFocus = (element: FocusableElementType, force?: boolean) =>
|
||||
inputFocusManager.safeFocus(element, force);
|
||||
978
fluxer_app/src/lib/KeybindManager.ts
Normal file
978
fluxer_app/src/lib/KeybindManager.ts
Normal file
@@ -0,0 +1,978 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import CombokeysImport from 'combokeys';
|
||||
import {autorun} from 'mobx';
|
||||
import React from 'react';
|
||||
import * as CallActionCreators from '~/actions/CallActionCreators';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as NavigationActionCreators from '~/actions/NavigationActionCreators';
|
||||
import * as ReadStateActionCreators from '~/actions/ReadStateActionCreators';
|
||||
import * as VoiceStateActionCreators from '~/actions/VoiceStateActionCreators';
|
||||
import {ChannelTypes, JumpTypes, ME} from '~/Constants';
|
||||
import {AddGuildModal} from '~/components/modals/AddGuildModal';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import {CreateDMModal} from '~/components/modals/CreateDMModal';
|
||||
import {UserSettingsModal} from '~/components/modals/UserSettingsModal';
|
||||
import {Routes} from '~/Routes';
|
||||
import AccessibilityStore from '~/stores/AccessibilityStore';
|
||||
import ChannelStore from '~/stores/ChannelStore';
|
||||
import GuildListStore from '~/stores/GuildListStore';
|
||||
import GuildStore from '~/stores/GuildStore';
|
||||
import InboxStore from '~/stores/InboxStore';
|
||||
import KeybindStore, {type KeybindAction, type KeybindConfig, type KeyCombo} from '~/stores/KeybindStore';
|
||||
import KeyboardModeStore from '~/stores/KeyboardModeStore';
|
||||
import QuickSwitcherStore from '~/stores/QuickSwitcherStore';
|
||||
import ReadStateStore from '~/stores/ReadStateStore';
|
||||
import RecentMentionsStore from '~/stores/RecentMentionsStore';
|
||||
import SavedMessagesStore from '~/stores/SavedMessagesStore';
|
||||
import SelectedChannelStore from '~/stores/SelectedChannelStore';
|
||||
import SelectedGuildStore from '~/stores/SelectedGuildStore';
|
||||
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
|
||||
import {goToMessage} from '~/utils/MessageNavigator';
|
||||
import {checkNativePermission} from '~/utils/NativePermissions';
|
||||
import {getElectronAPI, isNativeMacOS} from '~/utils/NativeUtils';
|
||||
import * as RouterUtils from '~/utils/RouterUtils';
|
||||
import {jsKeyToUiohookKeycode} from '~/utils/UiohookKeycodes';
|
||||
import {ComponentDispatch} from './ComponentDispatch';
|
||||
|
||||
interface CombokeysInstance {
|
||||
bind: (
|
||||
key: string,
|
||||
callback: (event?: KeyboardEvent | undefined) => void,
|
||||
action?: 'keydown' | 'keyup' | 'keypress',
|
||||
) => void;
|
||||
unbind: (key: string, action?: 'keydown' | 'keyup' | 'keypress') => void;
|
||||
detach: () => void;
|
||||
reset: () => void;
|
||||
stopCallback: () => boolean;
|
||||
}
|
||||
|
||||
type CombokeysConstructor = new (element?: HTMLElement) => CombokeysInstance;
|
||||
|
||||
const Combokeys = CombokeysImport as unknown as CombokeysConstructor;
|
||||
|
||||
type ShortcutSource = 'local' | 'global';
|
||||
|
||||
type KeybindHandler = (payload: {type: 'press' | 'release'; source: ShortcutSource}) => void;
|
||||
|
||||
const NON_TEXT_INPUT_TYPES = new Set([
|
||||
'button',
|
||||
'checkbox',
|
||||
'radio',
|
||||
'range',
|
||||
'color',
|
||||
'file',
|
||||
'image',
|
||||
'submit',
|
||||
'reset',
|
||||
]);
|
||||
|
||||
const isEditableElement = (target: EventTarget | null): target is HTMLElement => {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target.isContentEditable) return true;
|
||||
const tagName = target.tagName;
|
||||
if (tagName === 'TEXTAREA') return true;
|
||||
if (tagName === 'INPUT') {
|
||||
const type = ((target as HTMLInputElement).type || '').toLowerCase();
|
||||
return !NON_TEXT_INPUT_TYPES.has(type);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isAltOnlyArrowCombo = (combo: KeyCombo): boolean => {
|
||||
if (!combo.alt || combo.ctrlOrMeta || combo.ctrl || combo.meta) return false;
|
||||
const key = combo.code ?? combo.key;
|
||||
return key === 'ArrowLeft' || key === 'ArrowRight' || key === 'ArrowUp' || key === 'ArrowDown';
|
||||
};
|
||||
|
||||
const comboToShortcutString = (combo: KeyCombo): string => {
|
||||
const parts: Array<string> = [];
|
||||
if (combo.ctrl) {
|
||||
parts.push('Control');
|
||||
} else if (combo.ctrlOrMeta) {
|
||||
parts.push('CommandOrControl');
|
||||
}
|
||||
if (combo.meta) parts.push('Super');
|
||||
if (combo.shift) parts.push('Shift');
|
||||
if (combo.alt) parts.push('Alt');
|
||||
const rawKey = combo.code ?? combo.key;
|
||||
const key = rawKey === ' ' ? 'Space' : rawKey;
|
||||
parts.push(key && key.length === 1 ? key.toUpperCase() : (key ?? ''));
|
||||
return parts.filter(Boolean).join('+');
|
||||
};
|
||||
|
||||
const keyFromComboForCombokeys = (combo: KeyCombo): string | null => {
|
||||
const raw = combo.code ?? combo.key;
|
||||
if (!raw) return null;
|
||||
|
||||
if (raw === ' ') return 'space';
|
||||
if (raw.length === 1) {
|
||||
return raw.toLowerCase();
|
||||
}
|
||||
|
||||
if (/^Key[A-Z]$/.test(raw)) {
|
||||
return raw.slice(3).toLowerCase();
|
||||
}
|
||||
|
||||
if (/^Digit[0-9]$/.test(raw)) {
|
||||
return raw.slice(5);
|
||||
}
|
||||
|
||||
switch (raw) {
|
||||
case 'Space':
|
||||
case 'Spacebar':
|
||||
return 'space';
|
||||
case 'Escape':
|
||||
case 'Esc':
|
||||
return 'esc';
|
||||
case 'Enter':
|
||||
return 'enter';
|
||||
case 'Tab':
|
||||
return 'tab';
|
||||
case 'Backspace':
|
||||
return 'backspace';
|
||||
case 'ArrowUp':
|
||||
case 'Up':
|
||||
return 'up';
|
||||
case 'ArrowDown':
|
||||
case 'Down':
|
||||
return 'down';
|
||||
case 'ArrowLeft':
|
||||
case 'Left':
|
||||
return 'left';
|
||||
case 'ArrowRight':
|
||||
case 'Right':
|
||||
return 'right';
|
||||
default:
|
||||
return raw.toLowerCase();
|
||||
}
|
||||
};
|
||||
|
||||
const comboToCombokeysString = (combo: KeyCombo): string | null => {
|
||||
const parts: Array<string> = [];
|
||||
if (combo.ctrl) {
|
||||
parts.push('ctrl');
|
||||
} else if (combo.ctrlOrMeta) {
|
||||
parts.push('mod');
|
||||
}
|
||||
if (combo.meta) parts.push('meta');
|
||||
if (combo.shift) parts.push('shift');
|
||||
if (combo.alt) parts.push('alt');
|
||||
|
||||
const key = keyFromComboForCombokeys(combo);
|
||||
if (!key) return null;
|
||||
|
||||
parts.push(key);
|
||||
return parts.join('+');
|
||||
};
|
||||
|
||||
class KeybindManager {
|
||||
private handlers = new Map<KeybindAction, KeybindHandler>();
|
||||
private initialized = false;
|
||||
private globalShortcutsEnabled = false;
|
||||
private suspended = false;
|
||||
private disposers: Array<() => void> = [];
|
||||
private combokeys: CombokeysInstance | null = null;
|
||||
private accessibilityStatus: 'unknown' | 'granted' | 'denied' = 'unknown';
|
||||
private pttReleaseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private globalShortcutUnsubscribe: (() => void) | null = null;
|
||||
private globalKeyHookUnsubscribes: Array<() => void> = [];
|
||||
private globalKeyHookStarted = false;
|
||||
private pttKeycode: number | null = null;
|
||||
private pttMouseButton: number | null = null;
|
||||
|
||||
private get currentChannelId(): string | null {
|
||||
return SelectedChannelStore.currentChannelId;
|
||||
}
|
||||
|
||||
private get currentGuildId(): string | null {
|
||||
return SelectedGuildStore.selectedGuildId;
|
||||
}
|
||||
|
||||
private navigateToChannel(guildId: string | null, channelId: string): void {
|
||||
const channel = ChannelStore.getChannel(channelId);
|
||||
const effectiveGuildId = guildId ?? channel?.guildId ?? null;
|
||||
|
||||
if (channel?.guildId) {
|
||||
RouterUtils.transitionTo(Routes.guildChannel(channel.guildId, channelId));
|
||||
NavigationActionCreators.selectGuild(channel.guildId);
|
||||
NavigationActionCreators.selectChannel(channel.guildId, channelId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (channel && !channel.guildId) {
|
||||
RouterUtils.transitionTo(Routes.dmChannel(channelId));
|
||||
NavigationActionCreators.selectChannel(ME, channelId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (effectiveGuildId) {
|
||||
RouterUtils.transitionTo(Routes.guildChannel(effectiveGuildId, channelId));
|
||||
NavigationActionCreators.selectGuild(effectiveGuildId);
|
||||
NavigationActionCreators.selectChannel(effectiveGuildId, channelId);
|
||||
}
|
||||
}
|
||||
|
||||
private get activeKeybinds(): Array<KeybindConfig & {combo: KeyCombo}> {
|
||||
return KeybindStore.getAll().filter(({combo}) => combo.enabled ?? true);
|
||||
}
|
||||
|
||||
private get activeGlobalKeybinds(): Array<KeybindConfig & {combo: KeyCombo}> {
|
||||
return this.activeKeybinds.filter(
|
||||
(k) => k.allowGlobal && (k.combo.global ?? false) && ((k.combo.key ?? '') !== '' || (k.combo.code ?? '') !== ''),
|
||||
);
|
||||
}
|
||||
|
||||
private getOrderedGuilds(): Array<ReturnType<typeof GuildStore.getGuilds>[number]> {
|
||||
if (GuildListStore.guilds.length > 0) {
|
||||
return GuildListStore.guilds;
|
||||
}
|
||||
return GuildStore.getGuilds();
|
||||
}
|
||||
|
||||
private getFirstSelectableChannelId(guildId: string): string | undefined {
|
||||
const channels = ChannelStore.getGuildChannels(guildId);
|
||||
const selectableChannel = channels.find((c) => c.type !== ChannelTypes.GUILD_CATEGORY);
|
||||
return selectableChannel?.id;
|
||||
}
|
||||
|
||||
private ensureCombokeys(): CombokeysInstance | null {
|
||||
if (!this.combokeys && Combokeys) {
|
||||
this.combokeys = new Combokeys(document.documentElement);
|
||||
this.combokeys.stopCallback = () => false;
|
||||
}
|
||||
return this.combokeys;
|
||||
}
|
||||
|
||||
private async checkInputMonitoringPermission(): Promise<boolean> {
|
||||
if (!isNativeMacOS()) return true;
|
||||
if (this.accessibilityStatus === 'granted') return true;
|
||||
|
||||
const result = await checkNativePermission('input-monitoring');
|
||||
if (result === 'granted') {
|
||||
this.accessibilityStatus = 'granted';
|
||||
return true;
|
||||
}
|
||||
|
||||
if (result === 'denied') {
|
||||
this.accessibilityStatus = 'denied';
|
||||
} else {
|
||||
this.accessibilityStatus = 'unknown';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async init(i18n: I18n) {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
|
||||
this.ensureCombokeys();
|
||||
|
||||
this.registerDefaultHandlers(i18n);
|
||||
|
||||
this.refreshLocalShortcuts();
|
||||
|
||||
this.disposers.push(
|
||||
autorun(() => {
|
||||
this.refreshLocalShortcuts();
|
||||
}),
|
||||
);
|
||||
|
||||
this.disposers.push(
|
||||
autorun(() => {
|
||||
void this.refreshGlobalShortcuts();
|
||||
}),
|
||||
);
|
||||
|
||||
this.disposers.push(
|
||||
autorun(() => {
|
||||
MediaEngineStore.handlePushToTalkModeChange();
|
||||
}),
|
||||
);
|
||||
|
||||
this.disposers.push(
|
||||
autorun(() => {
|
||||
void this.refreshGlobalKeyHook();
|
||||
}),
|
||||
);
|
||||
|
||||
await this.refreshGlobalShortcuts();
|
||||
await this.refreshGlobalKeyHook();
|
||||
}
|
||||
|
||||
private async refreshGlobalKeyHook(): Promise<void> {
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi?.globalKeyHookStart) return;
|
||||
|
||||
const pttKeybind = KeybindStore.getByAction('push_to_talk');
|
||||
const isPttEnabled = KeybindStore.isPushToTalkEnabled();
|
||||
const hasPttKeybind = !!(pttKeybind.combo.key || pttKeybind.combo.code);
|
||||
const shouldUseGlobalHook = isPttEnabled && hasPttKeybind && (pttKeybind.combo.global ?? false);
|
||||
|
||||
if (shouldUseGlobalHook) {
|
||||
const started = await this.startGlobalKeyHook();
|
||||
if (started) {
|
||||
this.pttKeycode = jsKeyToUiohookKeycode(pttKeybind.combo.code ?? pttKeybind.combo.key);
|
||||
this.pttMouseButton = null;
|
||||
}
|
||||
} else {
|
||||
this.stopGlobalKeyHook();
|
||||
this.pttKeycode = null;
|
||||
this.pttMouseButton = null;
|
||||
}
|
||||
}
|
||||
|
||||
async reapplyGlobalShortcuts() {
|
||||
if (!this.initialized) return;
|
||||
await this.refreshGlobalShortcuts();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (!this.initialized) return;
|
||||
this.initialized = false;
|
||||
|
||||
this.disposers.forEach((dispose) => dispose());
|
||||
this.disposers = [];
|
||||
|
||||
if (this.globalShortcutUnsubscribe) {
|
||||
this.globalShortcutUnsubscribe();
|
||||
this.globalShortcutUnsubscribe = null;
|
||||
}
|
||||
|
||||
this.stopGlobalKeyHook();
|
||||
|
||||
const electronApi = getElectronAPI();
|
||||
if (electronApi) {
|
||||
void electronApi.unregisterAllGlobalShortcuts?.().catch(() => {});
|
||||
}
|
||||
|
||||
this.handlers.clear();
|
||||
|
||||
this.combokeys?.detach();
|
||||
this.combokeys = null;
|
||||
}
|
||||
|
||||
async startGlobalKeyHook(): Promise<boolean> {
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi?.globalKeyHookStart) return false;
|
||||
|
||||
if (this.globalKeyHookStarted) return true;
|
||||
|
||||
if (!(await this.checkInputMonitoringPermission())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const started = await electronApi.globalKeyHookStart();
|
||||
if (!started) return false;
|
||||
|
||||
this.globalKeyHookStarted = true;
|
||||
|
||||
const keyEventUnsub = electronApi.onGlobalKeyEvent?.((event) => {
|
||||
this.handleGlobalKeyEvent(event);
|
||||
});
|
||||
if (keyEventUnsub) this.globalKeyHookUnsubscribes.push(keyEventUnsub);
|
||||
|
||||
const mouseEventUnsub = electronApi.onGlobalMouseEvent?.((event) => {
|
||||
this.handleGlobalMouseEvent(event);
|
||||
});
|
||||
if (mouseEventUnsub) this.globalKeyHookUnsubscribes.push(mouseEventUnsub);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
stopGlobalKeyHook(): void {
|
||||
const electronApi = getElectronAPI();
|
||||
|
||||
this.globalKeyHookUnsubscribes.forEach((unsub) => unsub());
|
||||
this.globalKeyHookUnsubscribes = [];
|
||||
|
||||
if (electronApi?.globalKeyHookStop && this.globalKeyHookStarted) {
|
||||
void electronApi.globalKeyHookStop();
|
||||
}
|
||||
|
||||
this.globalKeyHookStarted = false;
|
||||
}
|
||||
|
||||
private handleGlobalKeyEvent(event: {type: 'keydown' | 'keyup'; keycode: number; keyName: string}): void {
|
||||
if (this.pttKeycode !== null && event.keycode === this.pttKeycode) {
|
||||
const handler = this.handlers.get('push_to_talk');
|
||||
if (handler) {
|
||||
handler({
|
||||
type: event.type === 'keydown' ? 'press' : 'release',
|
||||
source: 'global',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleGlobalMouseEvent(event: {type: 'mousedown' | 'mouseup'; button: number}): void {
|
||||
if (this.pttMouseButton !== null && event.button === this.pttMouseButton) {
|
||||
const handler = this.handlers.get('push_to_talk');
|
||||
if (handler) {
|
||||
handler({
|
||||
type: event.type === 'mousedown' ? 'press' : 'release',
|
||||
source: 'global',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setPttKeybind(keycode: number | null, mouseButton: number | null): void {
|
||||
this.pttKeycode = keycode;
|
||||
this.pttMouseButton = mouseButton;
|
||||
}
|
||||
|
||||
suspend(): void {
|
||||
this.suspended = true;
|
||||
this.combokeys?.reset();
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
this.suspended = false;
|
||||
this.refreshLocalShortcuts();
|
||||
}
|
||||
|
||||
register(action: KeybindAction, handler: KeybindHandler) {
|
||||
this.handlers.set(action, handler);
|
||||
}
|
||||
|
||||
private registerDefaultHandlers(i18n: I18n) {
|
||||
this.register('quick_switcher', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
|
||||
if (QuickSwitcherStore.getIsOpen()) QuickSwitcherStore.hide();
|
||||
else QuickSwitcherStore.show();
|
||||
});
|
||||
|
||||
this.register('toggle_hotkeys', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
ModalActionCreators.push(modal(() => React.createElement(UserSettingsModal, {initialTab: 'keybinds'})));
|
||||
ComponentDispatch.dispatch('USER_SETTINGS_TAB_SELECT', {tab: 'keybinds'});
|
||||
});
|
||||
|
||||
this.register('get_help', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
window.open(Routes.help(), '_blank', 'noopener');
|
||||
});
|
||||
|
||||
this.register('search', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
ComponentDispatch.dispatch('MESSAGE_SEARCH_OPEN');
|
||||
});
|
||||
|
||||
this.register('toggle_mute', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
const connectedGuildId = MediaEngineStore.guildId;
|
||||
const voiceState = MediaEngineStore.getVoiceState(connectedGuildId);
|
||||
const isGuildMuted = voiceState?.mute ?? false;
|
||||
|
||||
if (isGuildMuted) {
|
||||
ModalActionCreators.push(
|
||||
modal(() =>
|
||||
React.createElement(ConfirmModal, {
|
||||
title: i18n._(msg`Community Muted`),
|
||||
description: i18n._(msg`You cannot unmute yourself because you have been muted by a moderator.`),
|
||||
primaryText: i18n._(msg`Okay`),
|
||||
primaryVariant: 'primary',
|
||||
secondaryText: false,
|
||||
onPrimary: () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void VoiceStateActionCreators.toggleSelfMute(null);
|
||||
});
|
||||
|
||||
this.register('toggle_deafen', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
const connectedGuildId = MediaEngineStore.guildId;
|
||||
const voiceState = MediaEngineStore.getVoiceState(connectedGuildId);
|
||||
const isGuildDeafened = voiceState?.deaf ?? false;
|
||||
|
||||
if (isGuildDeafened) {
|
||||
ModalActionCreators.push(
|
||||
modal(() =>
|
||||
React.createElement(ConfirmModal, {
|
||||
title: i18n._(msg`Community Deafened`),
|
||||
description: i18n._(msg`You cannot undeafen yourself because you have been deafened by a moderator.`),
|
||||
primaryText: i18n._(msg`Okay`),
|
||||
primaryVariant: 'primary',
|
||||
secondaryText: false,
|
||||
onPrimary: () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void VoiceStateActionCreators.toggleSelfDeaf(null);
|
||||
});
|
||||
|
||||
this.register('toggle_video', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
void MediaEngineStore.toggleCameraFromKeybind();
|
||||
});
|
||||
|
||||
this.register('toggle_screen_share', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
void MediaEngineStore.toggleScreenShareFromKeybind();
|
||||
});
|
||||
|
||||
this.register('toggle_settings', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
ModalActionCreators.push(modal(() => React.createElement(UserSettingsModal)));
|
||||
});
|
||||
|
||||
this.register('toggle_push_to_talk_mode', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
KeybindStore.setTransmitMode(KeybindStore.isPushToTalkEnabled() ? 'voice_activity' : 'push_to_talk');
|
||||
MediaEngineStore.handlePushToTalkModeChange();
|
||||
});
|
||||
|
||||
this.register('push_to_talk', ({type}) => {
|
||||
if (type === 'press') {
|
||||
if (this.pttReleaseTimer) {
|
||||
clearTimeout(this.pttReleaseTimer);
|
||||
this.pttReleaseTimer = null;
|
||||
}
|
||||
const shouldUnmute = KeybindStore.handlePushToTalkPress();
|
||||
if (shouldUnmute) {
|
||||
MediaEngineStore.applyPushToTalkHold(true);
|
||||
}
|
||||
} else {
|
||||
const shouldMute = KeybindStore.handlePushToTalkRelease();
|
||||
if (shouldMute) {
|
||||
const delay = KeybindStore.pushToTalkReleaseDelay;
|
||||
this.pttReleaseTimer = setTimeout(() => {
|
||||
this.pttReleaseTimer = null;
|
||||
MediaEngineStore.applyPushToTalkHold(false);
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.register('scroll_chat_up', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
ComponentDispatch.dispatch('SCROLL_PAGE_UP');
|
||||
});
|
||||
|
||||
this.register('scroll_chat_down', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
ComponentDispatch.dispatch('SCROLL_PAGE_DOWN');
|
||||
});
|
||||
|
||||
this.register('jump_to_oldest_unread', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
const channelId = this.currentChannelId;
|
||||
if (!channelId) return;
|
||||
const targetId = ReadStateStore.getOldestUnreadMessageId(channelId);
|
||||
if (!targetId) return;
|
||||
goToMessage(channelId, targetId, {jumpType: JumpTypes.ANIMATED});
|
||||
});
|
||||
|
||||
this.register('mark_channel_read', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
const channelId = this.currentChannelId;
|
||||
if (!channelId) return;
|
||||
if (ReadStateStore.hasUnread(channelId)) {
|
||||
ReadStateActionCreators.ack(channelId, true, true);
|
||||
}
|
||||
});
|
||||
|
||||
this.register('mark_server_read', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
const guildId = this.currentGuildId;
|
||||
if (!guildId) return;
|
||||
const channels = ChannelStore.getGuildChannels(guildId);
|
||||
const channelIds = channels
|
||||
.filter((channel) => ReadStateStore.hasUnread(channel.id))
|
||||
.map((channel) => channel.id);
|
||||
if (channelIds.length > 0) {
|
||||
void ReadStateActionCreators.bulkAckChannels(channelIds);
|
||||
}
|
||||
});
|
||||
|
||||
this.register('mark_top_inbox_read', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
|
||||
const inboxTab = InboxStore.selectedTab;
|
||||
|
||||
if (inboxTab === 'bookmarks') {
|
||||
const savedMessages = SavedMessagesStore.savedMessages;
|
||||
const unreadBookmark = savedMessages.find((message) => {
|
||||
return ReadStateStore.hasUnread(message.channelId);
|
||||
});
|
||||
|
||||
if (unreadBookmark) {
|
||||
ReadStateActionCreators.ack(unreadBookmark.channelId, true, true);
|
||||
}
|
||||
} else {
|
||||
const mentions = RecentMentionsStore.recentMentions;
|
||||
const unreadMention = mentions.find((message) => {
|
||||
return ReadStateStore.hasUnread(message.channelId);
|
||||
});
|
||||
|
||||
if (unreadMention) {
|
||||
ReadStateActionCreators.ack(unreadMention.channelId, true, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.register('navigate_history_back', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
const history = RouterUtils.getHistory();
|
||||
if (history?.go) history.go(-1);
|
||||
});
|
||||
|
||||
this.register('navigate_history_forward', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
const history = RouterUtils.getHistory();
|
||||
if (history?.go) history.go(1);
|
||||
});
|
||||
|
||||
this.register('navigate_to_current_call', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
const channelId = MediaEngineStore.channelId;
|
||||
const guildId = MediaEngineStore.guildId;
|
||||
if (!channelId) return;
|
||||
this.navigateToChannel(guildId, channelId);
|
||||
});
|
||||
|
||||
this.register('navigate_last_server_or_dm', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
const lastGuild = SelectedGuildStore.lastSelectedGuildId;
|
||||
const dmChannel = SelectedChannelStore.selectedChannelIds.get(ME);
|
||||
if (lastGuild) {
|
||||
const channelId = SelectedChannelStore.selectedChannelIds.get(lastGuild);
|
||||
if (channelId) {
|
||||
this.navigateToChannel(lastGuild, channelId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (dmChannel) {
|
||||
this.navigateToChannel(ME, dmChannel);
|
||||
}
|
||||
});
|
||||
|
||||
this.register('navigate_channel_next', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
const guildId = this.currentGuildId;
|
||||
if (!guildId) return;
|
||||
const channels = ChannelStore.getGuildChannels(guildId);
|
||||
const current = this.currentChannelId;
|
||||
if (!channels.length || !current) return;
|
||||
const idx = channels.findIndex((c) => c.id === current);
|
||||
const next = channels[(idx + 1) % channels.length];
|
||||
this.navigateToChannel(guildId, next.id);
|
||||
});
|
||||
|
||||
this.register('navigate_channel_previous', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
const guildId = this.currentGuildId;
|
||||
if (!guildId) return;
|
||||
const channels = ChannelStore.getGuildChannels(guildId);
|
||||
const current = this.currentChannelId;
|
||||
if (!channels.length || !current) return;
|
||||
const idx = channels.findIndex((c) => c.id === current);
|
||||
const prev = channels[(idx - 1 + channels.length) % channels.length];
|
||||
this.navigateToChannel(guildId, prev.id);
|
||||
});
|
||||
|
||||
this.register('navigate_server_next', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
const guilds = this.getOrderedGuilds();
|
||||
if (!guilds.length) return;
|
||||
const currentId = this.currentGuildId ?? guilds[0].id;
|
||||
const idx = guilds.findIndex((g) => g.id === currentId);
|
||||
const safeIdx = idx === -1 ? 0 : idx;
|
||||
const next = guilds[(safeIdx + 1) % guilds.length];
|
||||
const channelId =
|
||||
SelectedChannelStore.selectedChannelIds.get(next.id) ?? this.getFirstSelectableChannelId(next.id);
|
||||
if (!channelId) return;
|
||||
this.navigateToChannel(next.id, channelId);
|
||||
});
|
||||
|
||||
this.register('navigate_server_previous', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
const guilds = this.getOrderedGuilds();
|
||||
if (!guilds.length) return;
|
||||
const currentId = this.currentGuildId ?? guilds[0].id;
|
||||
const idx = guilds.findIndex((g) => g.id === currentId);
|
||||
const safeIdx = idx === -1 ? 0 : idx;
|
||||
const prev = guilds[(safeIdx - 1 + guilds.length) % guilds.length];
|
||||
const channelId =
|
||||
SelectedChannelStore.selectedChannelIds.get(prev.id) ?? this.getFirstSelectableChannelId(prev.id);
|
||||
if (!channelId) return;
|
||||
this.navigateToChannel(prev.id, channelId);
|
||||
});
|
||||
|
||||
this.register('navigate_unread_channel_next', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
const guildId = this.currentGuildId;
|
||||
if (!guildId) return;
|
||||
const unread = ChannelStore.getGuildChannels(guildId).filter((c) => ReadStateStore.hasUnread(c.id));
|
||||
if (!unread.length) return;
|
||||
const current = this.currentChannelId;
|
||||
const idx = unread.findIndex((c) => c.id === current);
|
||||
const next = unread[(idx + 1) % unread.length];
|
||||
this.navigateToChannel(guildId, next.id);
|
||||
});
|
||||
|
||||
this.register('navigate_unread_channel_previous', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
const guildId = this.currentGuildId;
|
||||
if (!guildId) return;
|
||||
const unread = ChannelStore.getGuildChannels(guildId).filter((c) => ReadStateStore.hasUnread(c.id));
|
||||
if (!unread.length) return;
|
||||
const current = this.currentChannelId;
|
||||
const idx = unread.findIndex((c) => c.id === current);
|
||||
const prev = unread[(idx - 1 + unread.length) % unread.length];
|
||||
this.navigateToChannel(guildId, prev.id);
|
||||
});
|
||||
|
||||
this.register('navigate_unread_mentions_next', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
const guildId = this.currentGuildId;
|
||||
if (!guildId) return;
|
||||
const unread = ChannelStore.getGuildChannels(guildId).filter((c) => ReadStateStore.getMentionCount(c.id) > 0);
|
||||
if (!unread.length) return;
|
||||
const current = this.currentChannelId;
|
||||
const idx = unread.findIndex((c) => c.id === current);
|
||||
const next = unread[(idx + 1) % unread.length];
|
||||
this.navigateToChannel(guildId, next.id);
|
||||
});
|
||||
|
||||
this.register('navigate_unread_mentions_previous', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
const guildId = this.currentGuildId;
|
||||
if (!guildId) return;
|
||||
const unread = ChannelStore.getGuildChannels(guildId).filter((c) => ReadStateStore.getMentionCount(c.id) > 0);
|
||||
if (!unread.length) return;
|
||||
const current = this.currentChannelId;
|
||||
const idx = unread.findIndex((c) => c.id === current);
|
||||
const prev = unread[(idx - 1 + unread.length) % unread.length];
|
||||
this.navigateToChannel(guildId, prev.id);
|
||||
});
|
||||
|
||||
this.register('return_previous_text_channel', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
const current = this.currentChannelId;
|
||||
const prevVisit = SelectedChannelStore.recentChannelVisits.find((visit) => visit.channelId !== current);
|
||||
if (!prevVisit) return;
|
||||
this.navigateToChannel(prevVisit.guildId, prevVisit.channelId);
|
||||
});
|
||||
|
||||
this.register('return_previous_text_channel_alt', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
const visits = SelectedChannelStore.recentChannelVisits;
|
||||
if (visits.length < 2) return;
|
||||
const target = visits[1];
|
||||
this.navigateToChannel(target.guildId, target.channelId);
|
||||
});
|
||||
|
||||
this.register('return_connected_audio_channel', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
const channelId = MediaEngineStore.channelId;
|
||||
if (!channelId) return;
|
||||
this.navigateToChannel(MediaEngineStore.guildId, channelId);
|
||||
});
|
||||
|
||||
this.register('return_connected_audio_channel_alt', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
const channelId = MediaEngineStore.channelId;
|
||||
if (!channelId) return;
|
||||
this.navigateToChannel(MediaEngineStore.guildId, channelId);
|
||||
});
|
||||
|
||||
this.register('start_pm_call', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
const channelId = this.currentChannelId;
|
||||
if (!channelId) return;
|
||||
const channel = ChannelStore.getChannel(channelId);
|
||||
if (!channel || channel.guildId) return;
|
||||
CallActionCreators.startCall(channelId);
|
||||
});
|
||||
|
||||
this.register('toggle_pins_popout', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
ComponentDispatch.dispatch('CHANNEL_PINS_OPEN');
|
||||
});
|
||||
|
||||
this.register('toggle_mentions_popout', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
ComponentDispatch.dispatch('INBOX_OPEN');
|
||||
});
|
||||
|
||||
this.register('toggle_channel_member_list', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
ComponentDispatch.dispatch('CHANNEL_MEMBER_LIST_TOGGLE');
|
||||
});
|
||||
|
||||
this.register('create_or_join_server', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
ModalActionCreators.push(modal(() => React.createElement(AddGuildModal)));
|
||||
});
|
||||
|
||||
this.register('create_private_group', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
ModalActionCreators.push(modal(() => React.createElement(CreateDMModal)));
|
||||
});
|
||||
|
||||
this.register('focus_text_area', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
const channelId = this.currentChannelId;
|
||||
if (!channelId) return;
|
||||
ComponentDispatch.dispatch('FOCUS_TEXTAREA', {channelId});
|
||||
});
|
||||
|
||||
this.register('upload_file', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
const channelId = this.currentChannelId;
|
||||
if (!channelId) return;
|
||||
ComponentDispatch.dispatch('TEXTAREA_UPLOAD_FILE', {channelId});
|
||||
});
|
||||
|
||||
this.register('zoom_in', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
void AccessibilityStore.adjustZoom(0.1);
|
||||
});
|
||||
|
||||
this.register('zoom_out', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
void AccessibilityStore.adjustZoom(-0.1);
|
||||
});
|
||||
|
||||
this.register('zoom_reset', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
AccessibilityStore.updateSettings({zoomLevel: 1.0});
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshGlobalShortcuts() {
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi) return;
|
||||
|
||||
const keybinds = this.activeGlobalKeybinds;
|
||||
|
||||
try {
|
||||
await electronApi.unregisterAllGlobalShortcuts?.();
|
||||
} catch (error) {
|
||||
console.error('Failed to unregister global shortcuts', error);
|
||||
}
|
||||
|
||||
if (!keybinds.length) {
|
||||
this.globalShortcutsEnabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await this.checkInputMonitoringPermission())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.globalShortcutUnsubscribe) {
|
||||
this.globalShortcutUnsubscribe =
|
||||
electronApi.onGlobalShortcut?.((id: string) => {
|
||||
const keybind = keybinds.find((k) => comboToShortcutString(k.combo) === id);
|
||||
if (!keybind) return;
|
||||
|
||||
const handler = this.handlers.get(keybind.action);
|
||||
if (!handler) return;
|
||||
|
||||
handler({
|
||||
type: 'press',
|
||||
source: 'global',
|
||||
});
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
const shortcuts = keybinds
|
||||
.map((k) => ({entry: k, shortcut: comboToShortcutString(k.combo)}))
|
||||
.filter((s): s is {entry: KeybindConfig & {combo: KeyCombo}; shortcut: string} => !!s.shortcut);
|
||||
|
||||
if (!shortcuts.length) return;
|
||||
|
||||
for (const {shortcut} of shortcuts) {
|
||||
try {
|
||||
await electronApi.registerGlobalShortcut?.(shortcut, shortcut);
|
||||
} catch (error) {
|
||||
console.error(`Failed to register global shortcut ${shortcut}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.globalShortcutsEnabled = true;
|
||||
}
|
||||
|
||||
private refreshLocalShortcuts() {
|
||||
if (!this.combokeys || this.suspended) return;
|
||||
|
||||
this.combokeys.reset();
|
||||
|
||||
this.activeKeybinds.forEach((entry) => this.bindLocalShortcut(entry));
|
||||
}
|
||||
|
||||
private bindLocalShortcut(entry: KeybindConfig & {combo: KeyCombo}) {
|
||||
const {combo, action} = entry;
|
||||
const handler = this.handlers.get(action);
|
||||
if (!handler) return;
|
||||
|
||||
const shortcut = comboToCombokeysString(combo);
|
||||
if (!shortcut) return;
|
||||
|
||||
const hasModifier = !!(combo.ctrl || combo.ctrlOrMeta || combo.alt || combo.meta);
|
||||
const ignoreInEditable = isAltOnlyArrowCombo(combo);
|
||||
|
||||
const shouldIgnoreEvent = (event: KeyboardEvent): boolean => {
|
||||
const target = event.target ?? null;
|
||||
if (!isEditableElement(target)) return false;
|
||||
if (!hasModifier) return true;
|
||||
return ignoreInEditable;
|
||||
};
|
||||
|
||||
const wrapHandler = (type: 'press' | 'release') => (event?: KeyboardEvent) => {
|
||||
if (!event) return;
|
||||
|
||||
if (shouldIgnoreEvent(event)) return;
|
||||
|
||||
if (this.globalShortcutsEnabled && (combo.global ?? false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'focus_text_area' && KeyboardModeStore.keyboardModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
handler({type, source: 'local'});
|
||||
if (type === 'press') event.preventDefault();
|
||||
};
|
||||
|
||||
const combokeys = this.ensureCombokeys();
|
||||
if (combokeys) {
|
||||
combokeys.bind(shortcut, wrapHandler('press'), 'keydown');
|
||||
combokeys.bind(shortcut, wrapHandler('release'), 'keyup');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new KeybindManager();
|
||||
152
fluxer_app/src/lib/Logger.tsx
Normal file
152
fluxer_app/src/lib/Logger.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import AppStorage from '~/lib/AppStorage';
|
||||
import {IS_DEV} from '~/lib/env';
|
||||
import {HttpError} from '~/lib/HttpError';
|
||||
|
||||
export const LogLevel = {
|
||||
Trace: 0,
|
||||
Debug: 1,
|
||||
Info: 2,
|
||||
Warn: 3,
|
||||
Error: 4,
|
||||
Fatal: 5,
|
||||
Silent: 6,
|
||||
} as const;
|
||||
export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel];
|
||||
|
||||
const LEVEL_NAME_BY_VALUE: Record<number, keyof typeof LogLevel> = {
|
||||
[LogLevel.Trace]: 'Trace',
|
||||
[LogLevel.Debug]: 'Debug',
|
||||
[LogLevel.Info]: 'Info',
|
||||
[LogLevel.Warn]: 'Warn',
|
||||
[LogLevel.Error]: 'Error',
|
||||
[LogLevel.Fatal]: 'Fatal',
|
||||
[LogLevel.Silent]: 'Silent',
|
||||
};
|
||||
|
||||
const DEFAULT_STYLES = {
|
||||
Trace: {color: '#6c757d', fontWeight: 'normal'},
|
||||
Debug: {color: '#17a2b8', fontWeight: 'normal'},
|
||||
Info: {color: '#28a745', fontWeight: 'normal'},
|
||||
Warn: {color: '#ffc107', fontWeight: 'normal'},
|
||||
Error: {color: '#dc3545', fontWeight: 'normal'},
|
||||
Fatal: {color: '#dc3545', fontWeight: 'bold'},
|
||||
};
|
||||
|
||||
const pad2 = (value: number): string => (value < 10 ? `0${value}` : `${value}`);
|
||||
const formatTimestamp = (date: Date): string =>
|
||||
`${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
|
||||
|
||||
const resolveDefaultMinLevel = (): LogLevel =>
|
||||
AppStorage.getItem('debugLoggingEnabled') === 'true' || IS_DEV ? LogLevel.Debug : LogLevel.Info;
|
||||
|
||||
export class Logger {
|
||||
private name: string;
|
||||
private minLevelOverride?: LogLevel;
|
||||
private static globalMinLevel: LogLevel = resolveDefaultMinLevel();
|
||||
|
||||
constructor(name = 'default', minLevelOverride?: LogLevel) {
|
||||
this.name = name;
|
||||
this.minLevelOverride = minLevelOverride;
|
||||
}
|
||||
|
||||
static refreshGlobalLogLevel(): void {
|
||||
Logger.globalMinLevel = resolveDefaultMinLevel();
|
||||
}
|
||||
|
||||
private getCurrentLogLevel(): LogLevel {
|
||||
if (this.minLevelOverride !== undefined) {
|
||||
return this.minLevelOverride;
|
||||
}
|
||||
return Logger.globalMinLevel;
|
||||
}
|
||||
|
||||
child(suffix: string): Logger {
|
||||
return new Logger(`${this.name}:${suffix}`, this.minLevelOverride);
|
||||
}
|
||||
|
||||
trace(...args: Array<unknown>): void {
|
||||
this.log(LogLevel.Trace, ...args);
|
||||
}
|
||||
debug(...args: Array<unknown>): void {
|
||||
this.log(LogLevel.Debug, ...args);
|
||||
}
|
||||
info(...args: Array<unknown>): void {
|
||||
this.log(LogLevel.Info, ...args);
|
||||
}
|
||||
warn(...args: Array<unknown>): void {
|
||||
this.log(LogLevel.Warn, ...args);
|
||||
}
|
||||
error(...args: Array<unknown>): void {
|
||||
if (this.shouldDemoteHttp404(args)) {
|
||||
this.log(LogLevel.Debug, ...args);
|
||||
return;
|
||||
}
|
||||
|
||||
this.log(LogLevel.Error, ...args);
|
||||
}
|
||||
|
||||
private shouldDemoteHttp404(args: Array<unknown>): boolean {
|
||||
return args.some((value) => value instanceof HttpError && value.status === 404);
|
||||
}
|
||||
fatal(...args: Array<unknown>): void {
|
||||
this.log(LogLevel.Fatal, ...args);
|
||||
}
|
||||
|
||||
private log(level: LogLevel, ...args: Array<unknown>): void {
|
||||
const minLevel = this.getCurrentLogLevel();
|
||||
if (level < minLevel) return;
|
||||
|
||||
const levelName = LEVEL_NAME_BY_VALUE[level] || 'Unknown';
|
||||
const timestamp = formatTimestamp(new Date());
|
||||
const prefix = `[${timestamp}] [${this.name}] [${levelName}]`;
|
||||
|
||||
const style = DEFAULT_STYLES[levelName as keyof typeof DEFAULT_STYLES];
|
||||
const consoleMethod = this.getConsoleMethod(level);
|
||||
|
||||
if (style) {
|
||||
console[consoleMethod](
|
||||
`%c${prefix}`,
|
||||
`color:${style.color};font-weight:${style.fontWeight || 'normal'}`,
|
||||
...args,
|
||||
);
|
||||
} else {
|
||||
console[consoleMethod](prefix, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
private getConsoleMethod(level: LogLevel): 'log' | 'debug' | 'info' | 'warn' | 'error' {
|
||||
switch (level) {
|
||||
case LogLevel.Trace:
|
||||
case LogLevel.Debug:
|
||||
return 'debug';
|
||||
case LogLevel.Info:
|
||||
return 'info';
|
||||
case LogLevel.Warn:
|
||||
return 'warn';
|
||||
case LogLevel.Error:
|
||||
case LogLevel.Fatal:
|
||||
return 'error';
|
||||
default:
|
||||
return 'log';
|
||||
}
|
||||
}
|
||||
}
|
||||
118
fluxer_app/src/lib/MediaDeviceCache.ts
Normal file
118
fluxer_app/src/lib/MediaDeviceCache.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
interface CacheEntry {
|
||||
devices: Array<MediaDeviceInfo>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
type PermissionType = 'audio' | 'video';
|
||||
|
||||
class MediaDeviceCache {
|
||||
private cache: Map<PermissionType, CacheEntry> = new Map();
|
||||
private readonly STALE_TIME = 5000;
|
||||
private revalidationPromises: Map<PermissionType, Promise<Array<MediaDeviceInfo>>> = new Map();
|
||||
|
||||
public async getDevices(
|
||||
type: PermissionType,
|
||||
fetchFn: () => Promise<Array<MediaDeviceInfo>>,
|
||||
): Promise<{devices: Array<MediaDeviceInfo>; isStale: boolean}> {
|
||||
const cached = this.cache.get(type);
|
||||
const now = Date.now();
|
||||
|
||||
if (cached && now - cached.timestamp < this.STALE_TIME) {
|
||||
return {devices: cached.devices, isStale: false};
|
||||
}
|
||||
|
||||
if (cached) {
|
||||
if (!this.revalidationPromises.has(type)) {
|
||||
const revalidationPromise = this.revalidate(type, fetchFn);
|
||||
this.revalidationPromises.set(type, revalidationPromise);
|
||||
revalidationPromise.finally(() => {
|
||||
this.revalidationPromises.delete(type);
|
||||
});
|
||||
}
|
||||
return {devices: cached.devices, isStale: true};
|
||||
}
|
||||
|
||||
try {
|
||||
const devices = await fetchFn();
|
||||
this.cache.set(type, {devices, timestamp: now});
|
||||
return {devices, isStale: false};
|
||||
} catch (_error) {
|
||||
return {devices: [], isStale: false};
|
||||
}
|
||||
}
|
||||
|
||||
private async revalidate(
|
||||
type: PermissionType,
|
||||
fetchFn: () => Promise<Array<MediaDeviceInfo>>,
|
||||
): Promise<Array<MediaDeviceInfo>> {
|
||||
try {
|
||||
const devices = await fetchFn();
|
||||
this.cache.set(type, {devices, timestamp: Date.now()});
|
||||
return devices;
|
||||
} catch (_error) {
|
||||
return this.cache.get(type)?.devices ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
public invalidate(type: PermissionType): void {
|
||||
this.cache.delete(type);
|
||||
this.revalidationPromises.delete(type);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.cache.clear();
|
||||
this.revalidationPromises.clear();
|
||||
}
|
||||
|
||||
public startDeviceChangeListener(): () => void {
|
||||
const mediaDevices = navigator.mediaDevices;
|
||||
if (!mediaDevices || typeof mediaDevices.addEventListener !== 'function') {
|
||||
const previousHandler = mediaDevices?.ondevicechange ?? null;
|
||||
const handleDeviceChange = (event: Event) => {
|
||||
this.clear();
|
||||
previousHandler?.call(mediaDevices ?? undefined, event);
|
||||
};
|
||||
|
||||
if (mediaDevices) {
|
||||
mediaDevices.ondevicechange = handleDeviceChange;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (mediaDevices) {
|
||||
mediaDevices.ondevicechange = previousHandler;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleDeviceChange = () => {
|
||||
this.clear();
|
||||
};
|
||||
|
||||
mediaDevices.addEventListener('devicechange', handleDeviceChange);
|
||||
|
||||
return () => {
|
||||
mediaDevices.removeEventListener('devicechange', handleDeviceChange);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const mediaDeviceCache = new MediaDeviceCache();
|
||||
477
fluxer_app/src/lib/MessageQueue.tsx
Normal file
477
fluxer_app/src/lib/MessageQueue.tsx
Normal file
@@ -0,0 +1,477 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import * as DraftActionCreators from '~/actions/DraftActionCreators';
|
||||
import * as MessageActionCreators from '~/actions/MessageActionCreators';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as SlowmodeActionCreators from '~/actions/SlowmodeActionCreators';
|
||||
import {APIErrorCodes} from '~/Constants';
|
||||
import {FeatureTemporarilyDisabledModal} from '~/components/alerts/FeatureTemporarilyDisabledModal';
|
||||
import {FileSizeTooLargeModal} from '~/components/alerts/FileSizeTooLargeModal';
|
||||
import {MessageEditFailedModal} from '~/components/alerts/MessageEditFailedModal';
|
||||
import {MessageEditTooQuickModal} from '~/components/alerts/MessageEditTooQuickModal';
|
||||
import {MessageSendFailedModal} from '~/components/alerts/MessageSendFailedModal';
|
||||
import {MessageSendTooQuickModal} from '~/components/alerts/MessageSendTooQuickModal';
|
||||
import {NSFWContentRejectedModal} from '~/components/alerts/NSFWContentRejectedModal';
|
||||
import {SlowmodeRateLimitedModal} from '~/components/alerts/SlowmodeRateLimitedModal';
|
||||
import {Endpoints} from '~/Endpoints';
|
||||
import i18n from '~/i18n';
|
||||
import {CloudUpload} from '~/lib/CloudUpload';
|
||||
import http, {type HttpError, type HttpResponse} from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import {Queue, type QueueEntry} from '~/lib/Queue';
|
||||
import type {AllowedMentions, Message, MessageStickerItem} from '~/records/MessageRecord';
|
||||
import DeveloperOptionsStore from '~/stores/DeveloperOptionsStore';
|
||||
import {createSystemMessage} from '~/utils/CommandUtils';
|
||||
import {prepareAttachmentsForNonce} from '~/utils/MessageAttachmentUtils';
|
||||
import {
|
||||
type ApiAttachmentMetadata,
|
||||
buildMessageCreateRequest,
|
||||
type MessageCreateRequest,
|
||||
type MessageEditRequest,
|
||||
type MessageReference,
|
||||
} from '~/utils/MessageRequestUtils';
|
||||
|
||||
const logger = new Logger('MessageQueue');
|
||||
|
||||
const DEFAULT_MAX_SIZE = 5;
|
||||
const DEV_MESSAGE_DELAY = 3000;
|
||||
|
||||
interface BaseMessagePayload {
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
interface SendMessagePayload extends BaseMessagePayload {
|
||||
type: 'send';
|
||||
nonce: string;
|
||||
content: string;
|
||||
hasAttachments?: boolean;
|
||||
allowedMentions?: AllowedMentions;
|
||||
messageReference?: MessageReference;
|
||||
flags?: number;
|
||||
favoriteMemeId?: string;
|
||||
stickers?: Array<MessageStickerItem>;
|
||||
tts?: boolean;
|
||||
}
|
||||
|
||||
interface EditMessagePayload extends BaseMessagePayload {
|
||||
type: 'edit';
|
||||
messageId: string;
|
||||
content?: string;
|
||||
flags?: number;
|
||||
}
|
||||
|
||||
export type MessageQueuePayload = SendMessagePayload | EditMessagePayload;
|
||||
|
||||
export interface RetryError {
|
||||
retryAfter?: number;
|
||||
}
|
||||
|
||||
export interface ApiErrorBody {
|
||||
code?: number | string;
|
||||
retry_after?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const getApiErrorBody = (error: HttpError): ApiErrorBody | undefined => {
|
||||
return typeof error?.body === 'object' && error.body !== null ? (error.body as ApiErrorBody) : undefined;
|
||||
};
|
||||
|
||||
function isSendPayload(payload: MessageQueuePayload): payload is SendMessagePayload {
|
||||
return payload.type === 'send';
|
||||
}
|
||||
|
||||
function isEditPayload(payload: MessageQueuePayload): payload is EditMessagePayload {
|
||||
return payload.type === 'edit';
|
||||
}
|
||||
|
||||
function isRateLimitError(error: HttpError): boolean {
|
||||
return error?.status === 429;
|
||||
}
|
||||
|
||||
function isSlowmodeError(error: HttpError): boolean {
|
||||
return error?.status === 400 && getApiErrorBody(error)?.code === APIErrorCodes.SLOWMODE_RATE_LIMITED;
|
||||
}
|
||||
|
||||
function isFeatureDisabledError(error: HttpError): boolean {
|
||||
return error?.status === 403 && getApiErrorBody(error)?.code === APIErrorCodes.FEATURE_TEMPORARILY_DISABLED;
|
||||
}
|
||||
|
||||
function isExplicitContentError(error: HttpError): boolean {
|
||||
return getApiErrorBody(error)?.code === APIErrorCodes.EXPLICIT_CONTENT_CANNOT_BE_SENT;
|
||||
}
|
||||
|
||||
function isFileTooLargeError(error: HttpError): boolean {
|
||||
return getApiErrorBody(error)?.code === APIErrorCodes.FILE_SIZE_TOO_LARGE;
|
||||
}
|
||||
|
||||
function isDMRestrictedError(error: HttpError): boolean {
|
||||
return getApiErrorBody(error)?.code === APIErrorCodes.CANNOT_SEND_MESSAGES_TO_USER;
|
||||
}
|
||||
|
||||
function isUnclaimedAccountError(error: HttpError): boolean {
|
||||
return getApiErrorBody(error)?.code === APIErrorCodes.UNCLAIMED_ACCOUNT_RESTRICTED;
|
||||
}
|
||||
|
||||
class MessageQueue extends Queue<MessageQueuePayload, HttpResponse<Message> | undefined> {
|
||||
private readonly maxSize: number;
|
||||
private readonly abortControllers = new Map<string, AbortController>();
|
||||
|
||||
constructor(maxSize = DEFAULT_MAX_SIZE) {
|
||||
super({logger, defaultRetryAfter: 100});
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
isFull(): boolean {
|
||||
return this.queueLength >= this.maxSize;
|
||||
}
|
||||
|
||||
drain(
|
||||
message: MessageQueuePayload,
|
||||
completed: (err: RetryError | null, result?: HttpResponse<Message>) => void,
|
||||
): void {
|
||||
if (isSendPayload(message)) {
|
||||
this.handleSend(message, completed);
|
||||
} else if (isEditPayload(message)) {
|
||||
this.handleEdit(message, completed);
|
||||
} else {
|
||||
logger.error('Unknown message type, completing with null');
|
||||
completed(null);
|
||||
}
|
||||
}
|
||||
|
||||
cancelRequest(nonce: string): void {
|
||||
logger.info('Cancel message send:', nonce);
|
||||
const controller = this.abortControllers.get(nonce);
|
||||
controller?.abort();
|
||||
this.abortControllers.delete(nonce);
|
||||
}
|
||||
|
||||
cancelPendingSendRequests(channelId: string): Array<SendMessagePayload> {
|
||||
const cancelled: Array<SendMessagePayload> = [];
|
||||
const remaining: Array<QueueEntry<MessageQueuePayload, HttpResponse<Message> | undefined>> = [];
|
||||
|
||||
while (this.queue.length > 0) {
|
||||
const entry = this.queue.shift()!;
|
||||
|
||||
if (isSendPayload(entry.message) && entry.message.channelId === channelId) {
|
||||
cancelled.push(entry.message);
|
||||
this.cancelRequest(entry.message.nonce);
|
||||
} else {
|
||||
remaining.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
this.queue.push(...remaining);
|
||||
logger.info('Cancel pending send requests', cancelled.length);
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
private async handleSend(
|
||||
payload: SendMessagePayload,
|
||||
completed: (err: RetryError | null, result?: HttpResponse<Message>) => void,
|
||||
): Promise<void> {
|
||||
const {channelId, nonce, hasAttachments} = payload;
|
||||
|
||||
try {
|
||||
await this.applyDevDelay();
|
||||
|
||||
if (DeveloperOptionsStore.forceFailMessageSends) {
|
||||
throw new Error('Forced message send failure');
|
||||
}
|
||||
|
||||
let attachments: Array<ApiAttachmentMetadata> | undefined;
|
||||
let files: Array<File> | undefined;
|
||||
|
||||
if (hasAttachments) {
|
||||
const result = await prepareAttachmentsForNonce(nonce, payload.favoriteMemeId);
|
||||
attachments = result.attachments;
|
||||
files = result.files;
|
||||
}
|
||||
|
||||
const requestBody = buildMessageCreateRequest({
|
||||
content: payload.content,
|
||||
nonce,
|
||||
attachments,
|
||||
allowedMentions: payload.allowedMentions,
|
||||
messageReference: payload.messageReference,
|
||||
flags: payload.flags,
|
||||
favoriteMemeId: payload.favoriteMemeId,
|
||||
stickers: payload.stickers,
|
||||
tts: payload.tts,
|
||||
});
|
||||
|
||||
logger.debug(`Sending message to channel ${channelId}`);
|
||||
|
||||
const response = await this.sendMessageRequest(channelId, nonce, requestBody, files);
|
||||
|
||||
logger.debug(`Successfully sent message to channel ${channelId}`);
|
||||
|
||||
if (hasAttachments) {
|
||||
CloudUpload.removeMessageUpload(nonce);
|
||||
}
|
||||
|
||||
completed(null, response);
|
||||
} catch (error) {
|
||||
const httpError = error as HttpError;
|
||||
logger.error(`Failed to send message to channel ${channelId}:`, error);
|
||||
|
||||
if (isRateLimitError(httpError)) {
|
||||
this.handleSendRateLimit(httpError, completed);
|
||||
} else {
|
||||
this.handleSendError(channelId, nonce, httpError, i18n, payload.hasAttachments);
|
||||
completed(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async applyDevDelay(): Promise<void> {
|
||||
if (!DeveloperOptionsStore.slowMessageSend) return;
|
||||
|
||||
logger.debug(`Slow message send enabled, delaying by ${DEV_MESSAGE_DELAY}ms`);
|
||||
await new Promise((resolve) => setTimeout(resolve, DEV_MESSAGE_DELAY));
|
||||
}
|
||||
|
||||
private async sendMessageRequest(
|
||||
channelId: string,
|
||||
nonce: string,
|
||||
requestBody: MessageCreateRequest,
|
||||
files?: Array<File>,
|
||||
): Promise<HttpResponse<Message>> {
|
||||
const abortController = new AbortController();
|
||||
this.abortControllers.set(nonce, abortController);
|
||||
|
||||
try {
|
||||
if (files?.length) {
|
||||
logger.debug('Sending message with multipart form data');
|
||||
return await this.sendMultipartMessage(channelId, requestBody, files, abortController.signal, nonce);
|
||||
}
|
||||
|
||||
return await http.post<Message>({
|
||||
url: Endpoints.CHANNEL_MESSAGES(channelId),
|
||||
body: requestBody,
|
||||
signal: abortController.signal,
|
||||
rejectWithError: true,
|
||||
});
|
||||
} finally {
|
||||
this.abortControllers.delete(nonce);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendMultipartMessage(
|
||||
channelId: string,
|
||||
requestBody: MessageCreateRequest,
|
||||
files: Array<File>,
|
||||
signal: AbortSignal,
|
||||
nonce?: string,
|
||||
): Promise<HttpResponse<Message>> {
|
||||
const formData = new FormData();
|
||||
formData.append('payload_json', JSON.stringify(requestBody));
|
||||
|
||||
files.forEach((file, index) => {
|
||||
formData.append(`files[${index}]`, file);
|
||||
});
|
||||
|
||||
return http.post<Message>({
|
||||
url: Endpoints.CHANNEL_MESSAGES(channelId),
|
||||
body: formData,
|
||||
signal,
|
||||
rejectWithError: true,
|
||||
onRequestProgress: nonce
|
||||
? (event) => {
|
||||
if (event.lengthComputable && event.total > 0) {
|
||||
const progress = (event.loaded / event.total) * 100;
|
||||
CloudUpload.updateSendingProgress(nonce, progress);
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private handleSendRateLimit(
|
||||
error: HttpError,
|
||||
completed: (err: RetryError | null, result?: HttpResponse<Message>) => void,
|
||||
): void {
|
||||
const retryAfterSeconds = getApiErrorBody(error)?.retry_after ?? 0;
|
||||
const retryAfterMs = retryAfterSeconds > 0 ? retryAfterSeconds * 1000 : undefined;
|
||||
|
||||
completed({retryAfter: retryAfterMs});
|
||||
|
||||
this.handleRateLimitError(retryAfterSeconds);
|
||||
}
|
||||
|
||||
private handleSendError(
|
||||
channelId: string,
|
||||
nonce: string,
|
||||
error: HttpError,
|
||||
i18n: I18n,
|
||||
hasAttachments?: boolean,
|
||||
): void {
|
||||
MessageActionCreators.sendError(channelId, nonce);
|
||||
|
||||
if (hasAttachments) {
|
||||
this.restoreFailedMessage(channelId, nonce);
|
||||
}
|
||||
|
||||
if (isDMRestrictedError(error)) {
|
||||
const systemMessage = createSystemMessage(
|
||||
channelId,
|
||||
i18n._(
|
||||
msg`Your message could not be delivered. This is usually because you don't share a community with the recipient or the recipient is only accepting direct messages from friends.`,
|
||||
),
|
||||
);
|
||||
MessageActionCreators.createOptimistic(channelId, systemMessage.toJSON());
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUnclaimedAccountError(error)) {
|
||||
const systemMessage = createSystemMessage(
|
||||
channelId,
|
||||
i18n._(msg`Your message could not be delivered. You need to claim your account to send direct messages.`),
|
||||
);
|
||||
MessageActionCreators.createOptimistic(channelId, systemMessage.toJSON());
|
||||
return;
|
||||
}
|
||||
|
||||
this.showErrorModal(error, channelId);
|
||||
}
|
||||
|
||||
private restoreFailedMessage(channelId: string, nonce: string): void {
|
||||
const messageUpload = CloudUpload.getMessageUpload(nonce);
|
||||
|
||||
CloudUpload.restoreAttachmentsToTextarea(nonce);
|
||||
|
||||
const contentToRestore = messageUpload?.content ?? '';
|
||||
DraftActionCreators.createDraft(channelId, contentToRestore);
|
||||
|
||||
if (messageUpload?.messageReference) {
|
||||
MessageActionCreators.startReply(
|
||||
channelId,
|
||||
messageUpload.messageReference.message_id,
|
||||
messageUpload.allowedMentions?.replied_user ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
MessageActionCreators.deleteOptimistic(channelId, nonce);
|
||||
}
|
||||
|
||||
private showErrorModal(error: HttpError, channelId?: string): void {
|
||||
if (isSlowmodeError(error)) {
|
||||
const retryAfter = Math.ceil(getApiErrorBody(error)?.retry_after ?? 0);
|
||||
const timestamp = Date.now() - retryAfter * 1000;
|
||||
if (channelId) {
|
||||
SlowmodeActionCreators.updateSlowmodeTimestamp(channelId, timestamp);
|
||||
}
|
||||
ModalActionCreators.push(modal(() => <SlowmodeRateLimitedModal retryAfter={retryAfter} />));
|
||||
} else if (isFeatureDisabledError(error)) {
|
||||
ModalActionCreators.push(modal(() => <FeatureTemporarilyDisabledModal />));
|
||||
} else if (isExplicitContentError(error)) {
|
||||
ModalActionCreators.push(modal(() => <NSFWContentRejectedModal />));
|
||||
} else if (isFileTooLargeError(error)) {
|
||||
ModalActionCreators.push(modal(() => <FileSizeTooLargeModal />));
|
||||
} else {
|
||||
ModalActionCreators.push(modal(() => <MessageSendFailedModal />));
|
||||
}
|
||||
}
|
||||
|
||||
private handleRateLimitError(retryAfter: number, onRetry?: () => void): void {
|
||||
ModalActionCreators.push(modal(() => <MessageSendTooQuickModal retryAfter={retryAfter} onRetry={onRetry} />));
|
||||
}
|
||||
|
||||
private async handleEdit(
|
||||
payload: EditMessagePayload,
|
||||
completed: (err: RetryError | null, result?: HttpResponse<Message>) => void,
|
||||
): Promise<void> {
|
||||
const {channelId, messageId, content, flags} = payload;
|
||||
|
||||
const abortController = new AbortController();
|
||||
this.abortControllers.set(messageId, abortController);
|
||||
|
||||
try {
|
||||
logger.debug(`Editing message ${messageId} in channel ${channelId}`);
|
||||
|
||||
const body = this.buildEditRequestBody(content, flags);
|
||||
|
||||
const response = await http.patch<Message>({
|
||||
url: Endpoints.CHANNEL_MESSAGE(channelId, messageId),
|
||||
body,
|
||||
signal: abortController.signal,
|
||||
rejectWithError: true,
|
||||
});
|
||||
|
||||
logger.debug(`Successfully edited message ${messageId} in channel ${channelId}`);
|
||||
completed(null, response);
|
||||
} catch (error) {
|
||||
const httpError = error as HttpError;
|
||||
logger.error(`Failed to edit message ${messageId} in channel ${channelId}:`, error);
|
||||
|
||||
if (isRateLimitError(httpError)) {
|
||||
this.handleEditRateLimit(httpError, completed);
|
||||
} else {
|
||||
this.showEditErrorModal(httpError);
|
||||
completed(null);
|
||||
}
|
||||
} finally {
|
||||
this.abortControllers.delete(messageId);
|
||||
}
|
||||
}
|
||||
|
||||
private buildEditRequestBody(content?: string, flags?: number): MessageEditRequest {
|
||||
const body: MessageEditRequest = {};
|
||||
|
||||
if (content !== undefined) {
|
||||
body.content = content;
|
||||
}
|
||||
|
||||
if (flags !== undefined) {
|
||||
body.flags = flags;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
private handleEditRateLimit(
|
||||
error: HttpError,
|
||||
completed: (err: RetryError | null, result?: HttpResponse<Message>) => void,
|
||||
): void {
|
||||
const retryAfterSeconds = getApiErrorBody(error)?.retry_after ?? 0;
|
||||
const retryAfterMs = retryAfterSeconds > 0 ? retryAfterSeconds * 1000 : undefined;
|
||||
|
||||
completed({retryAfter: retryAfterMs});
|
||||
|
||||
this.handleEditRateLimitError(retryAfterSeconds);
|
||||
}
|
||||
|
||||
private showEditErrorModal(error: HttpError): void {
|
||||
if (isFeatureDisabledError(error)) {
|
||||
ModalActionCreators.push(modal(() => <FeatureTemporarilyDisabledModal />));
|
||||
} else {
|
||||
ModalActionCreators.push(modal(() => <MessageEditFailedModal />));
|
||||
}
|
||||
}
|
||||
|
||||
private handleEditRateLimitError(retryAfter: number, onRetry?: () => void): void {
|
||||
ModalActionCreators.push(modal(() => <MessageEditTooQuickModal retryAfter={retryAfter} onRetry={onRetry} />));
|
||||
}
|
||||
}
|
||||
|
||||
export default new MessageQueue();
|
||||
80
fluxer_app/src/lib/MobXPersistence.ts
Normal file
80
fluxer_app/src/lib/MobXPersistence.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {configurePersistable, makePersistable, stopPersisting} from 'mobx-persist-store';
|
||||
import {Logger} from './Logger';
|
||||
|
||||
const logger = new Logger('MobXPersistence');
|
||||
|
||||
const persistedStores = new Set<string>();
|
||||
|
||||
const getStorage = () => {
|
||||
return 'localStorage' in window ? window.localStorage : undefined;
|
||||
};
|
||||
|
||||
configurePersistable({
|
||||
storage: getStorage(),
|
||||
expireIn: undefined,
|
||||
removeOnExpiration: false,
|
||||
stringify: true,
|
||||
debugMode: false,
|
||||
});
|
||||
|
||||
export const makePersistent = async <T extends object>(
|
||||
store: T,
|
||||
storageKey: string,
|
||||
properties: Array<keyof T>,
|
||||
options?: {
|
||||
expireIn?: number;
|
||||
removeOnExpiration?: boolean;
|
||||
version?: number;
|
||||
},
|
||||
): Promise<void> => {
|
||||
try {
|
||||
if (persistedStores.has(storageKey)) {
|
||||
logger.debug(`Store ${storageKey} is already being persisted, skipping...`);
|
||||
return;
|
||||
}
|
||||
|
||||
await makePersistable(store, {
|
||||
name: storageKey,
|
||||
properties: properties as Array<keyof T & string>,
|
||||
storage: getStorage(),
|
||||
expireIn: options?.expireIn,
|
||||
removeOnExpiration: options?.removeOnExpiration,
|
||||
stringify: true,
|
||||
version: options?.version ?? 1,
|
||||
});
|
||||
|
||||
persistedStores.add(storageKey);
|
||||
logger.debug(`Store ${storageKey} hydrated from localStorage and is now persisting.`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to hydrate store ${storageKey}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
export const stopPersistent = (storageKey: string, store: object): void => {
|
||||
try {
|
||||
stopPersisting(store);
|
||||
persistedStores.delete(storageKey);
|
||||
logger.debug(`Stopped persisting store: ${storageKey}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to stop persisting store ${storageKey}:`, error);
|
||||
}
|
||||
};
|
||||
107
fluxer_app/src/lib/PlaceholderSpecs.ts
Normal file
107
fluxer_app/src/lib/PlaceholderSpecs.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useMemo} from 'react';
|
||||
|
||||
export function generatePlaceholderSpecs(options: {
|
||||
compact: boolean;
|
||||
messageGroups: number;
|
||||
groupRange: number;
|
||||
attachments: number;
|
||||
fontSize: number;
|
||||
groupSpacing: number;
|
||||
}): {
|
||||
messages: Array<number>;
|
||||
attachmentSpecs: Array<[number, {width: number; height: number}] | undefined>;
|
||||
totalHeight: number;
|
||||
groupSpacing: number;
|
||||
} {
|
||||
const {compact, messageGroups, groupRange, attachments, fontSize, groupSpacing} = options;
|
||||
|
||||
if (attachments > messageGroups) {
|
||||
throw new Error(
|
||||
`generatePlaceholderSpecs: too many attachments relative to messageGroups: ${messageGroups}, ${attachments}`,
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_FONT_SIZE = 16;
|
||||
const scale = fontSize / DEFAULT_FONT_SIZE;
|
||||
|
||||
const MESSAGE_HEIGHT_COZY = 22;
|
||||
const MESSAGE_HEIGHT_COMPACT = 16;
|
||||
const ATTACHMENT_MARGIN = 8;
|
||||
|
||||
const messageHeight = compact ? MESSAGE_HEIGHT_COMPACT : MESSAGE_HEIGHT_COZY;
|
||||
|
||||
let totalHeight = 0;
|
||||
const messageCounts: Array<number> = [];
|
||||
|
||||
for (let i = 0; i < messageGroups; i++) {
|
||||
const count = Math.floor(Math.random() * groupRange) + 1;
|
||||
messageCounts.push(count);
|
||||
|
||||
totalHeight += groupSpacing * scale;
|
||||
totalHeight += messageHeight * scale;
|
||||
totalHeight += (count - 1) * messageHeight * scale;
|
||||
}
|
||||
|
||||
const availableGroupIndices = messageCounts.map((_, i) => i);
|
||||
const attachmentSpecs: Array<[number, {width: number; height: number}] | undefined> =
|
||||
Array(messageGroups).fill(undefined);
|
||||
|
||||
for (let i = 0; i < attachments; i++) {
|
||||
const randomIndex = Math.floor(Math.random() * availableGroupIndices.length);
|
||||
const groupIndex = availableGroupIndices.splice(randomIndex, 1)[0];
|
||||
|
||||
const width = Math.floor(Math.random() * (400 - 140 + 1)) + 140;
|
||||
const height = Math.floor(Math.random() * (320 - 100 + 1)) + 100;
|
||||
|
||||
attachmentSpecs[groupIndex] = [groupIndex, {width, height}];
|
||||
totalHeight += height + ATTACHMENT_MARGIN * scale;
|
||||
}
|
||||
|
||||
return {
|
||||
messages: messageCounts,
|
||||
attachmentSpecs,
|
||||
totalHeight,
|
||||
groupSpacing,
|
||||
};
|
||||
}
|
||||
|
||||
export function usePlaceholderSpecs(compact: boolean, groupSpacing: number, fontSize: number) {
|
||||
return useMemo(() => {
|
||||
return compact
|
||||
? generatePlaceholderSpecs({
|
||||
compact: true,
|
||||
messageGroups: 30,
|
||||
groupRange: 4,
|
||||
attachments: 8,
|
||||
fontSize,
|
||||
groupSpacing,
|
||||
})
|
||||
: generatePlaceholderSpecs({
|
||||
compact: false,
|
||||
messageGroups: 26,
|
||||
groupRange: 4,
|
||||
attachments: 8,
|
||||
fontSize,
|
||||
groupSpacing,
|
||||
});
|
||||
}, [compact, fontSize, groupSpacing]);
|
||||
}
|
||||
84
fluxer_app/src/lib/Platform.ts
Normal file
84
fluxer_app/src/lib/Platform.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const userAgent = navigator.userAgent;
|
||||
const hasNavigator = typeof navigator !== 'undefined';
|
||||
|
||||
const isIOSDevice = (() => {
|
||||
if (/iPhone|iPad|iPod/.test(userAgent)) return true;
|
||||
if (/Mac/.test(userAgent) && typeof navigator.maxTouchPoints === 'number' && navigator.maxTouchPoints > 1) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
const isAndroidDevice = /Android/.test(userAgent);
|
||||
const isMobileBrowser = isIOSDevice || isAndroidDevice;
|
||||
const isIOSWeb = isIOSDevice;
|
||||
const isElectron = typeof (window as {electron?: unknown}).electron !== 'undefined';
|
||||
const isPWA = typeof window.matchMedia === 'function' && window.matchMedia('(display-mode: standalone)').matches;
|
||||
|
||||
type PlatformSpecifics<T> = Partial<Record<string, T | undefined>> & {
|
||||
default?: T | undefined;
|
||||
};
|
||||
|
||||
const selectValue = <T>(specifics: PlatformSpecifics<T>): T | undefined => {
|
||||
if (isElectron && specifics.electron !== undefined) {
|
||||
return specifics.electron;
|
||||
}
|
||||
if (specifics.web !== undefined) {
|
||||
return specifics.web;
|
||||
}
|
||||
if (specifics.default !== undefined) {
|
||||
return specifics.default;
|
||||
}
|
||||
return Object.values(specifics).find((value) => value !== undefined);
|
||||
};
|
||||
|
||||
export const Platform = {
|
||||
OS: 'web' as const,
|
||||
isWeb: true,
|
||||
isIOS: isIOSDevice,
|
||||
isAndroid: isAndroidDevice,
|
||||
isElectron,
|
||||
isIOSWeb,
|
||||
isPWA,
|
||||
isAppleDevice: isIOSDevice,
|
||||
isMobileBrowser,
|
||||
select: selectValue,
|
||||
};
|
||||
|
||||
export function isWebPlatform(): boolean {
|
||||
return Platform.isWeb;
|
||||
}
|
||||
|
||||
export function isElectronPlatform(): boolean {
|
||||
return Platform.isElectron;
|
||||
}
|
||||
|
||||
export function getNativeLocaleIdentifier(): string | null {
|
||||
if (!hasNavigator) {
|
||||
return null;
|
||||
}
|
||||
const languages = navigator.languages;
|
||||
if (Array.isArray(languages) && languages.length > 0) {
|
||||
return languages[0];
|
||||
}
|
||||
return navigator.language ?? null;
|
||||
}
|
||||
139
fluxer_app/src/lib/Queue.ts
Normal file
139
fluxer_app/src/lib/Queue.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Logger} from '~/lib/Logger';
|
||||
|
||||
export interface QueueEntry<TMessage, TResult = void> {
|
||||
message: TMessage;
|
||||
success: (result?: TResult) => void;
|
||||
}
|
||||
|
||||
interface RetryInfo {
|
||||
retryAfter?: number;
|
||||
}
|
||||
|
||||
export interface QueueConfig {
|
||||
logger?: Logger;
|
||||
defaultRetryAfter?: number;
|
||||
}
|
||||
|
||||
export abstract class Queue<TMessage, TResult = void> {
|
||||
protected readonly logger: Logger;
|
||||
protected readonly defaultRetryAfter: number;
|
||||
protected readonly queue: Array<QueueEntry<TMessage, TResult>>;
|
||||
|
||||
private retryTimerId: number | null;
|
||||
private isDraining: boolean;
|
||||
|
||||
constructor(config: QueueConfig = {}) {
|
||||
this.logger = config.logger ?? new Logger('Queue');
|
||||
this.defaultRetryAfter = config.defaultRetryAfter ?? 100;
|
||||
this.queue = [];
|
||||
this.retryTimerId = null;
|
||||
this.isDraining = false;
|
||||
}
|
||||
|
||||
protected abstract drain(message: TMessage, complete: (retry: RetryInfo | null, result?: TResult) => void): void;
|
||||
|
||||
enqueue(message: TMessage, success: (result?: TResult) => void): void {
|
||||
this.queue.push({message, success});
|
||||
this.maybeProcessNext();
|
||||
}
|
||||
|
||||
get queueLength(): number {
|
||||
return this.queue.length;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (this.retryTimerId !== null) {
|
||||
clearTimeout(this.retryTimerId);
|
||||
this.retryTimerId = null;
|
||||
}
|
||||
this.queue.length = 0;
|
||||
this.isDraining = false;
|
||||
}
|
||||
|
||||
peek(): TMessage | undefined {
|
||||
return this.queue[0]?.message;
|
||||
}
|
||||
|
||||
private maybeProcessNext(): void {
|
||||
if (this.retryTimerId !== null || this.queue.length === 0 || this.isDraining) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = this.queue.shift();
|
||||
if (!entry) {
|
||||
this.isDraining = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.isDraining = true;
|
||||
|
||||
const {message, success} = entry;
|
||||
|
||||
let hasCompleted = false;
|
||||
|
||||
const complete = (retry: RetryInfo | null, result?: TResult): void => {
|
||||
if (hasCompleted) {
|
||||
this.logger.warn('Queue completion callback invoked more than once; ignoring extra call');
|
||||
return;
|
||||
}
|
||||
|
||||
hasCompleted = true;
|
||||
this.isDraining = false;
|
||||
|
||||
this.logger.info(`Finished processing queued item; ${this.queue.length} item(s) remaining in queue`);
|
||||
|
||||
if (retry === null) {
|
||||
setTimeout(() => this.maybeProcessNext(), 0);
|
||||
|
||||
try {
|
||||
success(result);
|
||||
} catch (error) {
|
||||
this.logger.error('Error in queue success callback', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = retry.retryAfter ?? this.defaultRetryAfter;
|
||||
|
||||
this.logger.info(
|
||||
`Pausing queue processing for ${delay}ms due to retry request; ${this.queue.length} item(s) waiting`,
|
||||
);
|
||||
|
||||
this.retryTimerId = window.setTimeout(() => {
|
||||
this.queue.unshift(entry);
|
||||
this.retryTimerId = null;
|
||||
this.maybeProcessNext();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
this.logger.info(`Processing queued item; ${this.queue.length} item(s) left after dequeue`);
|
||||
|
||||
try {
|
||||
this.drain(message, complete);
|
||||
} catch (error) {
|
||||
this.logger.error('Unhandled error while draining queue item', error);
|
||||
if (!hasCompleted) {
|
||||
complete(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
110
fluxer_app/src/lib/ReadStateCleanup.ts
Normal file
110
fluxer_app/src/lib/ReadStateCleanup.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {reaction} from 'mobx';
|
||||
import {Permissions} from '~/Constants';
|
||||
import {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import AuthenticationStore from '~/stores/AuthenticationStore';
|
||||
import ChannelStore from '~/stores/ChannelStore';
|
||||
import GuildAvailabilityStore from '~/stores/GuildAvailabilityStore';
|
||||
import InitializationStore from '~/stores/InitializationStore';
|
||||
import PermissionStore from '~/stores/PermissionStore';
|
||||
import ReadStateStore from '~/stores/ReadStateStore';
|
||||
|
||||
const logger = new Logger('ReadStateCleanup');
|
||||
const CAN_READ_PERMISSIONS = Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY;
|
||||
const CLEANUP_INTERVAL_MS = 300;
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
let cleanupTask: Promise<void> | null = null;
|
||||
let cleanupReactionDisposer: (() => void) | null = null;
|
||||
|
||||
function collectStaleChannels(): Array<string> {
|
||||
const channelIds = ReadStateStore.getChannelIds();
|
||||
return channelIds.filter((channelId) => {
|
||||
const channel = ChannelStore.getChannel(channelId);
|
||||
if (channel == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (channel.guildId == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !PermissionStore.can(CAN_READ_PERMISSIONS, channel);
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteReadState(channelId: string): Promise<void> {
|
||||
try {
|
||||
await http.delete({url: Endpoints.CHANNEL_MESSAGES_ACK(channelId)});
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to delete read state for ${channelId}:`, error);
|
||||
} finally {
|
||||
ReadStateStore.clear(channelId);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCleanup(): Promise<void> {
|
||||
if (cleanupTask) {
|
||||
return cleanupTask;
|
||||
}
|
||||
|
||||
cleanupTask = (async () => {
|
||||
try {
|
||||
const staleChannels = collectStaleChannels();
|
||||
if (staleChannels.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Cleaning up ${staleChannels.length} stale read state(s)`);
|
||||
for (const [index, channelId] of staleChannels.entries()) {
|
||||
await deleteReadState(channelId);
|
||||
if (index < staleChannels.length - 1) {
|
||||
await sleep(CLEANUP_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cleanupTask = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return cleanupTask;
|
||||
}
|
||||
|
||||
export function startReadStateCleanup(): void {
|
||||
if (cleanupReactionDisposer) {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupReactionDisposer = reaction(
|
||||
() =>
|
||||
InitializationStore.isReady &&
|
||||
AuthenticationStore.isAuthenticated &&
|
||||
GuildAvailabilityStore.totalUnavailableGuilds === 0,
|
||||
(canCleanup) => {
|
||||
if (!canCleanup) return;
|
||||
void runCleanup();
|
||||
},
|
||||
{fireImmediately: true},
|
||||
);
|
||||
}
|
||||
1331
fluxer_app/src/lib/ScrollManager.ts
Normal file
1331
fluxer_app/src/lib/ScrollManager.ts
Normal file
File diff suppressed because it is too large
Load Diff
695
fluxer_app/src/lib/SessionManager.ts
Normal file
695
fluxer_app/src/lib/SessionManager.ts
Normal file
@@ -0,0 +1,695 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {action, makeAutoObservable, runInAction} from 'mobx';
|
||||
import Config from '~/Config';
|
||||
import {Endpoints} from '~/Endpoints';
|
||||
import accountStorage, {type UserData} from '~/lib/AccountStorage';
|
||||
import AppStorage from '~/lib/AppStorage';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import ConnectionStore from '~/stores/ConnectionStore';
|
||||
import LayerManager from '~/stores/LayerManager';
|
||||
import type {RuntimeConfigSnapshot} from '~/stores/RuntimeConfigStore';
|
||||
import RuntimeConfigStore from '~/stores/RuntimeConfigStore';
|
||||
import SudoStore from '~/stores/SudoStore';
|
||||
|
||||
const logger = new Logger('SessionManager');
|
||||
|
||||
export class SessionExpiredError extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message ?? 'Session expired');
|
||||
this.name = 'SessionExpiredError';
|
||||
}
|
||||
}
|
||||
|
||||
export const SessionState = {
|
||||
Idle: 'idle',
|
||||
Initializing: 'initializing',
|
||||
Authenticated: 'authenticated',
|
||||
Connecting: 'connecting',
|
||||
Connected: 'connected',
|
||||
Switching: 'switching',
|
||||
LoggingOut: 'logging_out',
|
||||
Error: 'error',
|
||||
} as const;
|
||||
|
||||
export type SessionState = (typeof SessionState)[keyof typeof SessionState];
|
||||
|
||||
const SessionEvent = {
|
||||
Initialize: 'initialize',
|
||||
TokenLoaded: 'token_loaded',
|
||||
NoToken: 'no_token',
|
||||
StartConnection: 'start_connection',
|
||||
ConnectionReady: 'connection_ready',
|
||||
ConnectionFailed: 'connection_failed',
|
||||
ConnectionClosed: 'connection_closed',
|
||||
SwitchAccount: 'switch_account',
|
||||
SwitchComplete: 'switch_complete',
|
||||
SwitchFailed: 'switch_failed',
|
||||
Logout: 'logout',
|
||||
LogoutComplete: 'logout_complete',
|
||||
SessionInvalidated: 'session_invalidated',
|
||||
Reset: 'reset',
|
||||
} as const;
|
||||
|
||||
export type SessionEvent = (typeof SessionEvent)[keyof typeof SessionEvent];
|
||||
|
||||
interface StateTransition {
|
||||
from: SessionState | Array<SessionState>;
|
||||
event: SessionEvent;
|
||||
to: SessionState;
|
||||
}
|
||||
|
||||
const VALID_TRANSITIONS: Array<StateTransition> = [
|
||||
{from: SessionState.Idle, event: SessionEvent.Initialize, to: SessionState.Initializing},
|
||||
{
|
||||
from: [SessionState.Idle, SessionState.Initializing],
|
||||
event: SessionEvent.TokenLoaded,
|
||||
to: SessionState.Authenticated,
|
||||
},
|
||||
{from: SessionState.Initializing, event: SessionEvent.NoToken, to: SessionState.Idle},
|
||||
{from: SessionState.Authenticated, event: SessionEvent.StartConnection, to: SessionState.Connecting},
|
||||
{from: SessionState.Connecting, event: SessionEvent.ConnectionReady, to: SessionState.Connected},
|
||||
{from: SessionState.Connecting, event: SessionEvent.ConnectionFailed, to: SessionState.Authenticated},
|
||||
{from: SessionState.Connected, event: SessionEvent.ConnectionClosed, to: SessionState.Authenticated},
|
||||
{
|
||||
from: [SessionState.Authenticated, SessionState.Connected],
|
||||
event: SessionEvent.SwitchAccount,
|
||||
to: SessionState.Switching,
|
||||
},
|
||||
{from: SessionState.Switching, event: SessionEvent.SwitchComplete, to: SessionState.Authenticated},
|
||||
{from: SessionState.Switching, event: SessionEvent.SwitchFailed, to: SessionState.Authenticated},
|
||||
{
|
||||
from: [SessionState.Idle, SessionState.Authenticated, SessionState.Connected, SessionState.Error],
|
||||
event: SessionEvent.Logout,
|
||||
to: SessionState.LoggingOut,
|
||||
},
|
||||
{from: SessionState.LoggingOut, event: SessionEvent.LogoutComplete, to: SessionState.Idle},
|
||||
{
|
||||
from: [SessionState.Authenticated, SessionState.Connected, SessionState.Connecting],
|
||||
event: SessionEvent.SessionInvalidated,
|
||||
to: SessionState.Idle,
|
||||
},
|
||||
{
|
||||
from: [
|
||||
SessionState.Idle,
|
||||
SessionState.Initializing,
|
||||
SessionState.Authenticated,
|
||||
SessionState.Connecting,
|
||||
SessionState.Connected,
|
||||
SessionState.Switching,
|
||||
SessionState.LoggingOut,
|
||||
SessionState.Error,
|
||||
],
|
||||
event: SessionEvent.Reset,
|
||||
to: SessionState.Idle,
|
||||
},
|
||||
];
|
||||
|
||||
const StorageKey = {
|
||||
Token: 'token',
|
||||
UserId: 'userId',
|
||||
} as const;
|
||||
|
||||
export interface Account {
|
||||
userId: string;
|
||||
token: string;
|
||||
userData?: UserData;
|
||||
lastActive: number;
|
||||
instance?: RuntimeConfigSnapshot;
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
function readNullableString(value: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
} else if (value === 'undefined' || value === 'null') {
|
||||
return null;
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function buildInstanceUserMeUrl(instance: RuntimeConfigSnapshot): string {
|
||||
const endpoint = instance.apiEndpoint.replace(/\/+$/, '');
|
||||
if (!endpoint) {
|
||||
return Endpoints.USER_ME;
|
||||
}
|
||||
return `${endpoint}/v${Config.PUBLIC_API_VERSION}${Endpoints.USER_ME}`;
|
||||
}
|
||||
|
||||
class SessionManager {
|
||||
private _state: SessionState = SessionState.Idle;
|
||||
private _token: string | null = null;
|
||||
private _userId: string | null = null;
|
||||
private _accounts: Map<string, Account> = new Map();
|
||||
private _error: Error | null = null;
|
||||
private _operationSequence = 0;
|
||||
private _initPromise: Promise<void> | null = null;
|
||||
private _mutex: Promise<void> = Promise.resolve();
|
||||
private _isInitialized = false;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
transition: action.bound,
|
||||
setToken: action.bound,
|
||||
setUserId: action.bound,
|
||||
setError: action.bound,
|
||||
},
|
||||
{autoBind: true},
|
||||
);
|
||||
}
|
||||
|
||||
get state(): SessionState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
get token(): string | null {
|
||||
return this._token;
|
||||
}
|
||||
|
||||
get userId(): string | null {
|
||||
return this._userId;
|
||||
}
|
||||
|
||||
get error(): Error | null {
|
||||
return this._error;
|
||||
}
|
||||
|
||||
get isIdle(): boolean {
|
||||
return this._state === SessionState.Idle;
|
||||
}
|
||||
|
||||
get isAuthenticated(): boolean {
|
||||
return (
|
||||
this._state === SessionState.Authenticated ||
|
||||
this._state === SessionState.Connecting ||
|
||||
this._state === SessionState.Connected
|
||||
);
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this._state === SessionState.Connected;
|
||||
}
|
||||
|
||||
get isConnecting(): boolean {
|
||||
return this._state === SessionState.Connecting;
|
||||
}
|
||||
|
||||
get isSwitching(): boolean {
|
||||
return this._state === SessionState.Switching;
|
||||
}
|
||||
|
||||
get isLoggingOut(): boolean {
|
||||
return this._state === SessionState.LoggingOut;
|
||||
}
|
||||
|
||||
canSwitchAccount(): boolean {
|
||||
return this.canTransition(SessionEvent.SwitchAccount);
|
||||
}
|
||||
|
||||
get isInitialized(): boolean {
|
||||
return this._isInitialized;
|
||||
}
|
||||
|
||||
get accounts(): Array<Account> {
|
||||
return Array.from(this._accounts.values()).sort((a, b) => b.lastActive - a.lastActive);
|
||||
}
|
||||
|
||||
get currentAccount(): Account | null {
|
||||
if (!this._userId) {
|
||||
return null;
|
||||
} else {
|
||||
return this._accounts.get(this._userId) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
private canTransition(event: SessionEvent): boolean {
|
||||
for (const transition of VALID_TRANSITIONS) {
|
||||
const fromStates = Array.isArray(transition.from) ? transition.from : [transition.from];
|
||||
if (fromStates.includes(this._state) && transition.event === event) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private getNextState(event: SessionEvent): SessionState | null {
|
||||
for (const transition of VALID_TRANSITIONS) {
|
||||
const fromStates = Array.isArray(transition.from) ? transition.from : [transition.from];
|
||||
if (fromStates.includes(this._state) && transition.event === event) {
|
||||
return transition.to;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
transition(event: SessionEvent): boolean {
|
||||
const nextState = this.getNextState(event);
|
||||
if (nextState === null) {
|
||||
logger.warn(`Invalid transition: ${this._state} + ${event}`);
|
||||
return false;
|
||||
} else {
|
||||
logger.debug(`Transition: ${this._state} + ${event} -> ${nextState}`);
|
||||
this._state = nextState;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
setToken(token: string | null): void {
|
||||
this._token = token;
|
||||
if (token) {
|
||||
AppStorage.setItem(StorageKey.Token, token);
|
||||
} else {
|
||||
AppStorage.removeItem(StorageKey.Token);
|
||||
}
|
||||
}
|
||||
|
||||
setUserId(userId: string | null): void {
|
||||
this._userId = userId;
|
||||
if (userId) {
|
||||
AppStorage.setItem(StorageKey.UserId, userId);
|
||||
} else {
|
||||
AppStorage.removeItem(StorageKey.UserId);
|
||||
}
|
||||
}
|
||||
|
||||
setError(error: Error | null): void {
|
||||
this._error = error;
|
||||
if (error) {
|
||||
this._state = SessionState.Error;
|
||||
}
|
||||
}
|
||||
|
||||
private nextOperationId(): number {
|
||||
return ++this._operationSequence;
|
||||
}
|
||||
|
||||
private async runExclusive<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const run = async (): Promise<T> => {
|
||||
return await fn();
|
||||
};
|
||||
const next = this._mutex.then(run, run);
|
||||
this._mutex = next.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this._initPromise) {
|
||||
return this._initPromise;
|
||||
}
|
||||
|
||||
this._initPromise = this.doInitialize();
|
||||
return this._initPromise;
|
||||
}
|
||||
|
||||
private async doInitialize(): Promise<void> {
|
||||
logger.debug(`doInitialize starting, current state: ${this._state}`);
|
||||
if (!this.canTransition(SessionEvent.Initialize)) {
|
||||
logger.debug(`Cannot transition Initialize from state ${this._state}`);
|
||||
return;
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
this.transition(SessionEvent.Initialize);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.loadStoredAccounts();
|
||||
|
||||
const storedToken = readNullableString(AppStorage.getItem(StorageKey.Token));
|
||||
const storedUserId = readNullableString(AppStorage.getItem(StorageKey.UserId));
|
||||
|
||||
logger.debug(`Loaded from storage: token=${storedToken ? 'present' : 'null'}, userId=${storedUserId ?? 'null'}`);
|
||||
|
||||
if (storedToken && storedUserId) {
|
||||
runInAction(() => {
|
||||
this._token = storedToken;
|
||||
this._userId = storedUserId;
|
||||
this.transition(SessionEvent.TokenLoaded);
|
||||
});
|
||||
} else if (storedToken) {
|
||||
runInAction(() => {
|
||||
this._token = storedToken;
|
||||
this.transition(SessionEvent.TokenLoaded);
|
||||
});
|
||||
} else {
|
||||
runInAction(() => {
|
||||
this.transition(SessionEvent.NoToken);
|
||||
});
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
this._isInitialized = true;
|
||||
});
|
||||
logger.debug(`Initialization complete: state=${this._state}, isAuthenticated=${this.isAuthenticated}`);
|
||||
} catch (err) {
|
||||
logger.error('Initialization failed', err);
|
||||
runInAction(() => {
|
||||
this._isInitialized = true;
|
||||
this.setError(err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async loadStoredAccounts(): Promise<void> {
|
||||
try {
|
||||
const stored = await accountStorage.getAllAccounts();
|
||||
runInAction(() => {
|
||||
this._accounts.clear();
|
||||
for (const rec of stored) {
|
||||
if (!rec.token) {
|
||||
logger.warn(`Skipping stored account for ${rec.userId} because token is missing`);
|
||||
continue;
|
||||
}
|
||||
|
||||
this._accounts.set(rec.userId, {
|
||||
userId: rec.userId,
|
||||
token: rec.token,
|
||||
userData: rec.userData,
|
||||
lastActive: rec.lastActive,
|
||||
instance: rec.instance,
|
||||
isValid: rec.isValid ?? true,
|
||||
});
|
||||
}
|
||||
});
|
||||
logger.debug(`Loaded ${stored.length} accounts`);
|
||||
} catch (err) {
|
||||
logger.error('Failed to load accounts', err);
|
||||
}
|
||||
}
|
||||
|
||||
async stashCurrentAccount(): Promise<void> {
|
||||
if (!this._userId || !this._token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const account = this._accounts.get(this._userId);
|
||||
|
||||
await accountStorage.stashAccountData(
|
||||
this._userId,
|
||||
this._token,
|
||||
account?.userData,
|
||||
RuntimeConfigStore.getSnapshot(),
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
const existing = this._accounts.get(this._userId!);
|
||||
if (existing) {
|
||||
this._accounts.set(this._userId!, {
|
||||
...existing,
|
||||
token: this._token!,
|
||||
lastActive: Date.now(),
|
||||
instance: RuntimeConfigStore.getSnapshot(),
|
||||
});
|
||||
} else {
|
||||
this._accounts.set(this._userId!, {
|
||||
userId: this._userId!,
|
||||
token: this._token!,
|
||||
lastActive: Date.now(),
|
||||
instance: RuntimeConfigStore.getSnapshot(),
|
||||
isValid: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async validateToken(token: string, instance?: RuntimeConfigSnapshot): Promise<boolean> {
|
||||
const url = instance ? buildInstanceUserMeUrl(instance) : Endpoints.USER_ME;
|
||||
|
||||
try {
|
||||
await http.get<unknown>({
|
||||
url,
|
||||
skipAuth: true,
|
||||
headers: {Authorization: token},
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
markAccountInvalid(userId: string): void {
|
||||
const account = this._accounts.get(userId);
|
||||
if (account) {
|
||||
runInAction(() => {
|
||||
this._accounts.set(userId, {...account, isValid: false});
|
||||
});
|
||||
void accountStorage.updateAccountValidity(userId, false);
|
||||
}
|
||||
}
|
||||
|
||||
async login(token: string, userId: string, userData?: UserData): Promise<void> {
|
||||
await this.initialize();
|
||||
|
||||
return await this.runExclusive(async () => {
|
||||
const snapshot = RuntimeConfigStore.getSnapshot();
|
||||
|
||||
await accountStorage.stashAccountData(userId, token, userData, snapshot);
|
||||
|
||||
runInAction(() => {
|
||||
this._token = token;
|
||||
this._userId = userId;
|
||||
AppStorage.setItem(StorageKey.Token, token);
|
||||
AppStorage.setItem(StorageKey.UserId, userId);
|
||||
|
||||
this._accounts.set(userId, {
|
||||
userId,
|
||||
token,
|
||||
userData,
|
||||
lastActive: Date.now(),
|
||||
instance: snapshot,
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
if (this._state !== SessionState.Authenticated && this.canTransition(SessionEvent.TokenLoaded)) {
|
||||
this.transition(SessionEvent.TokenLoaded);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async switchAccount(userId: string): Promise<void> {
|
||||
await this.initialize();
|
||||
|
||||
return await this.runExclusive(async () => {
|
||||
const opId = this.nextOperationId();
|
||||
|
||||
if (userId === this._userId) {
|
||||
logger.debug('Already on requested account');
|
||||
return;
|
||||
}
|
||||
|
||||
const account = this._accounts.get(userId);
|
||||
if (!account) {
|
||||
throw new Error(`No account found for ${userId}`);
|
||||
}
|
||||
|
||||
if (!this.canTransition(SessionEvent.SwitchAccount)) {
|
||||
throw new Error(`Cannot switch from state: ${this._state}`);
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
this.transition(SessionEvent.SwitchAccount);
|
||||
});
|
||||
|
||||
const previousSnapshot = RuntimeConfigStore.getSnapshot();
|
||||
|
||||
try {
|
||||
if (this._userId && this._token) {
|
||||
await this.stashCurrentAccount();
|
||||
}
|
||||
|
||||
const isValid = await this.validateToken(account.token, account.instance);
|
||||
if (!isValid) {
|
||||
this.markAccountInvalid(userId);
|
||||
throw new SessionExpiredError();
|
||||
}
|
||||
|
||||
const restored = await accountStorage.restoreAccountData(userId);
|
||||
if (!restored) {
|
||||
throw new Error(`No data found for ${userId}`);
|
||||
}
|
||||
|
||||
const nextSnapshot = restored.instance ?? account.instance ?? previousSnapshot;
|
||||
|
||||
LayerManager.closeAll();
|
||||
ConnectionStore.logout();
|
||||
SudoStore.clearToken();
|
||||
|
||||
RuntimeConfigStore.applySnapshot(nextSnapshot);
|
||||
|
||||
runInAction(() => {
|
||||
this._token = account.token;
|
||||
this._userId = userId;
|
||||
AppStorage.setItem(StorageKey.Token, account.token);
|
||||
AppStorage.setItem(StorageKey.UserId, userId);
|
||||
|
||||
this._accounts.set(userId, {
|
||||
...account,
|
||||
userData: restored.userData ?? account.userData,
|
||||
lastActive: Date.now(),
|
||||
instance: nextSnapshot,
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
this.transition(SessionEvent.SwitchComplete);
|
||||
});
|
||||
|
||||
await accountStorage.stashAccountData(
|
||||
userId,
|
||||
account.token,
|
||||
restored.userData ?? account.userData,
|
||||
nextSnapshot,
|
||||
);
|
||||
|
||||
void opId;
|
||||
} catch (err) {
|
||||
logger.error('Failed to switch account', err);
|
||||
RuntimeConfigStore.applySnapshot(previousSnapshot);
|
||||
runInAction(() => {
|
||||
this.transition(SessionEvent.SwitchFailed);
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
await this.initialize();
|
||||
|
||||
return await this.runExclusive(async () => {
|
||||
if (!this.canTransition(SessionEvent.Logout)) {
|
||||
return;
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
this.transition(SessionEvent.Logout);
|
||||
});
|
||||
|
||||
const currentUserId = this._userId;
|
||||
|
||||
try {
|
||||
try {
|
||||
await http.post({url: Endpoints.AUTH_LOGOUT, timeout: 5000, retries: 0});
|
||||
} catch (err) {
|
||||
logger.warn('Logout request failed', err);
|
||||
}
|
||||
|
||||
if (currentUserId) {
|
||||
try {
|
||||
await accountStorage.deleteAccount(currentUserId);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to delete account', err);
|
||||
}
|
||||
}
|
||||
|
||||
AppStorage.clear();
|
||||
|
||||
LayerManager.closeAll();
|
||||
ConnectionStore.logout();
|
||||
SudoStore.clearToken();
|
||||
|
||||
runInAction(() => {
|
||||
if (currentUserId) {
|
||||
this._accounts.delete(currentUserId);
|
||||
}
|
||||
this._token = null;
|
||||
this._userId = null;
|
||||
this._error = null;
|
||||
this.transition(SessionEvent.LogoutComplete);
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Logout failed', err);
|
||||
runInAction(() => {
|
||||
this.transition(SessionEvent.LogoutComplete);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async removeAccount(userId: string): Promise<void> {
|
||||
await this.initialize();
|
||||
await accountStorage.deleteAccount(userId);
|
||||
runInAction(() => {
|
||||
this._accounts.delete(userId);
|
||||
if (this._userId === userId) {
|
||||
this._userId = null;
|
||||
this._token = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleConnectionReady(): void {
|
||||
if (this.canTransition(SessionEvent.ConnectionReady)) {
|
||||
this.transition(SessionEvent.ConnectionReady);
|
||||
}
|
||||
}
|
||||
|
||||
handleConnectionClosed(code: number): void {
|
||||
if (code === 4004) {
|
||||
if (this.canTransition(SessionEvent.SessionInvalidated)) {
|
||||
this.transition(SessionEvent.SessionInvalidated);
|
||||
this.setToken(null);
|
||||
this.setUserId(null);
|
||||
}
|
||||
} else {
|
||||
if (this.canTransition(SessionEvent.ConnectionClosed)) {
|
||||
this.transition(SessionEvent.ConnectionClosed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleConnectionStarted(): void {
|
||||
if (this.canTransition(SessionEvent.StartConnection)) {
|
||||
this.transition(SessionEvent.StartConnection);
|
||||
}
|
||||
}
|
||||
|
||||
handleConnectionFailed(): void {
|
||||
if (this.canTransition(SessionEvent.ConnectionFailed)) {
|
||||
this.transition(SessionEvent.ConnectionFailed);
|
||||
}
|
||||
}
|
||||
|
||||
updateAccountUserData(userId: string, userData: UserData): void {
|
||||
const account = this._accounts.get(userId);
|
||||
if (account) {
|
||||
runInAction(() => {
|
||||
this._accounts.set(userId, {...account, userData});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.transition(SessionEvent.Reset);
|
||||
this._token = null;
|
||||
this._userId = null;
|
||||
this._error = null;
|
||||
this._initPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default new SessionManager();
|
||||
79
fluxer_app/src/lib/StickerSendUtils.ts
Normal file
79
fluxer_app/src/lib/StickerSendUtils.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as ChannelStickerActionCreators from '~/actions/ChannelStickerActionCreators';
|
||||
import * as MessageActionCreators from '~/actions/MessageActionCreators';
|
||||
import * as SlowmodeActionCreators from '~/actions/SlowmodeActionCreators';
|
||||
import {MessageStates, MessageTypes} from '~/Constants';
|
||||
import {CloudUpload} from '~/lib/CloudUpload';
|
||||
import type {GuildStickerRecord} from '~/records/GuildStickerRecord';
|
||||
import {MessageRecord} from '~/records/MessageRecord';
|
||||
import DraftStore from '~/stores/DraftStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
|
||||
import {TypingUtils} from '~/utils/TypingUtils';
|
||||
|
||||
export function handleStickerSelect(channelId: string, sticker: GuildStickerRecord): void {
|
||||
const draft = DraftStore.getDraft(channelId);
|
||||
const hasTextContent = draft && draft.trim().length > 0;
|
||||
const hasAttachments = CloudUpload.getTextareaAttachments(channelId).length > 0;
|
||||
|
||||
if (!hasTextContent && !hasAttachments) {
|
||||
sendStickerMessage(channelId, sticker);
|
||||
} else {
|
||||
ChannelStickerActionCreators.setPendingSticker(channelId, sticker);
|
||||
}
|
||||
}
|
||||
|
||||
function sendStickerMessage(channelId: string, sticker: GuildStickerRecord): void {
|
||||
const nonce = SnowflakeUtils.fromTimestamp(Date.now());
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
|
||||
if (!currentUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
TypingUtils.clear(channelId);
|
||||
|
||||
const message = new MessageRecord({
|
||||
id: nonce,
|
||||
channel_id: channelId,
|
||||
author: currentUser,
|
||||
type: MessageTypes.DEFAULT,
|
||||
flags: 0,
|
||||
pinned: false,
|
||||
mention_everyone: false,
|
||||
content: '',
|
||||
timestamp: new Date().toISOString(),
|
||||
mentions: [],
|
||||
state: MessageStates.SENDING,
|
||||
nonce,
|
||||
attachments: [],
|
||||
stickers: [sticker.toJSON()],
|
||||
});
|
||||
|
||||
MessageActionCreators.createOptimistic(channelId, message.toJSON());
|
||||
SlowmodeActionCreators.recordMessageSend(channelId);
|
||||
|
||||
MessageActionCreators.send(channelId, {
|
||||
content: '',
|
||||
nonce,
|
||||
stickers: [sticker.toJSON()],
|
||||
});
|
||||
}
|
||||
122
fluxer_app/src/lib/TextareaAutosize.tsx
Normal file
122
fluxer_app/src/lib/TextareaAutosize.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
export interface TextareaAutosizeProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
onHeightChange?: (height: number, meta: {rowHeight: number}) => void;
|
||||
}
|
||||
|
||||
function getLineHeight(style: CSSStyleDeclaration): number {
|
||||
const lh = Number.parseFloat(style.lineHeight);
|
||||
if (Number.isFinite(lh)) return lh;
|
||||
const fs = Number.parseFloat(style.fontSize);
|
||||
return Number.isFinite(fs) ? fs * 1.2 : 16 * 1.2;
|
||||
}
|
||||
|
||||
function getNumber(v: string): number {
|
||||
const n = Number.parseFloat(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function computeRowConstraints(el: HTMLTextAreaElement, minRows?: number, maxRows?: number) {
|
||||
const cs = window.getComputedStyle(el);
|
||||
const lineHeight = getLineHeight(cs);
|
||||
const paddingBlock = getNumber(cs.paddingTop) + getNumber(cs.paddingBottom);
|
||||
const borderBlock = getNumber(cs.borderTopWidth) + getNumber(cs.borderBottomWidth);
|
||||
const extra = cs.boxSizing === 'border-box' ? paddingBlock + borderBlock : 0;
|
||||
|
||||
return {
|
||||
minHeight: minRows != null ? lineHeight * minRows + extra : undefined,
|
||||
maxHeight: maxRows != null ? lineHeight * maxRows + extra : undefined,
|
||||
lineHeight,
|
||||
};
|
||||
}
|
||||
|
||||
export const TextareaAutosize = React.forwardRef<HTMLTextAreaElement, TextareaAutosizeProps>((props, forwardedRef) => {
|
||||
const {minRows: minRowsProp, maxRows, style, onHeightChange, rows, ...rest} = props;
|
||||
|
||||
const minRows = minRowsProp ?? (typeof rows === 'number' ? rows : undefined);
|
||||
|
||||
const elRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||
const onHeightChangeRef = React.useRef(onHeightChange);
|
||||
const lastHeightRef = React.useRef<number | null>(null);
|
||||
|
||||
const setRef = React.useCallback(
|
||||
(node: HTMLTextAreaElement | null) => {
|
||||
elRef.current = node;
|
||||
if (typeof forwardedRef === 'function') forwardedRef(node);
|
||||
else if (forwardedRef) (forwardedRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = node;
|
||||
},
|
||||
[forwardedRef],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
onHeightChangeRef.current = onHeightChange;
|
||||
}, [onHeightChange]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const el = elRef.current;
|
||||
if (!el || (minRows == null && maxRows == null)) return;
|
||||
|
||||
const {minHeight, maxHeight} = computeRowConstraints(el, minRows, maxRows);
|
||||
|
||||
if (minHeight != null) {
|
||||
el.style.minHeight = `${minHeight}px`;
|
||||
}
|
||||
if (maxHeight != null) {
|
||||
el.style.maxHeight = `${maxHeight}px`;
|
||||
el.style.overflowY = 'auto';
|
||||
}
|
||||
}, [minRows, maxRows]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = elRef.current;
|
||||
if (!el || typeof ResizeObserver === 'undefined') return;
|
||||
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
|
||||
const height = entry.borderBoxSize?.[0]?.blockSize ?? el.getBoundingClientRect().height;
|
||||
if (height !== lastHeightRef.current) {
|
||||
lastHeightRef.current = height;
|
||||
const cs = window.getComputedStyle(el);
|
||||
onHeightChangeRef.current?.(height, {rowHeight: getLineHeight(cs)});
|
||||
}
|
||||
});
|
||||
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const computedStyle = React.useMemo(
|
||||
(): React.CSSProperties => ({
|
||||
fieldSizing: 'content',
|
||||
...style,
|
||||
}),
|
||||
[style],
|
||||
);
|
||||
|
||||
return <textarea {...rest} ref={setRef} rows={rows} style={computedStyle} />;
|
||||
});
|
||||
|
||||
TextareaAutosize.displayName = 'TextareaAutosize';
|
||||
321
fluxer_app/src/lib/UnicodeEmojis.tsx
Normal file
321
fluxer_app/src/lib/UnicodeEmojis.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {
|
||||
BicycleIcon,
|
||||
BowlFoodIcon,
|
||||
FlagIcon,
|
||||
GameControllerIcon,
|
||||
HeartIcon,
|
||||
LeafIcon,
|
||||
MagnetIcon,
|
||||
SmileyIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import {SKIN_TONE_SURROGATES} from '~/Constants';
|
||||
import emojiShortcuts from '~/data/emoji-shortcuts.json';
|
||||
import emojiData from '~/data/emojis.json';
|
||||
import * as EmojiUtils from '~/utils/EmojiUtils';
|
||||
import * as RegexUtils from '~/utils/RegexUtils';
|
||||
|
||||
export const EMOJI_SPRITES = {
|
||||
NonDiversityPerRow: 42,
|
||||
DiversityPerRow: 10,
|
||||
PickerPerRow: 11,
|
||||
PickerCount: 50,
|
||||
};
|
||||
|
||||
export interface UnicodeEmoji {
|
||||
id?: string;
|
||||
uniqueName: string;
|
||||
name: string;
|
||||
names: ReadonlyArray<string>;
|
||||
allNamesString: string;
|
||||
url?: string;
|
||||
surrogates: string;
|
||||
hasDiversity: boolean;
|
||||
managed: boolean;
|
||||
useSpriteSheet: boolean;
|
||||
index?: number;
|
||||
diversityIndex?: number;
|
||||
guildId?: string;
|
||||
}
|
||||
|
||||
const emojisByCategory: Record<string, Array<UnicodeEmoji>> = {};
|
||||
const nameToEmoji: Record<string, UnicodeEmoji> = {};
|
||||
const nameToSurrogate: Record<string, string> = {};
|
||||
const surrogateToName: Record<string, string> = {};
|
||||
const shortcutToName: Record<string, string> = {};
|
||||
const emojis: Array<UnicodeEmoji> = [];
|
||||
|
||||
let defaultSkinTone: string = '';
|
||||
let numDiversitySprites = 0;
|
||||
let numNonDiversitySprites = 0;
|
||||
|
||||
class UnicodeEmojiClass {
|
||||
uniqueName: string;
|
||||
names: ReadonlyArray<string>;
|
||||
allNamesString: string;
|
||||
defaultUrl?: string;
|
||||
surrogates: string;
|
||||
hasDiversity: boolean;
|
||||
managed: boolean;
|
||||
useSpriteSheet: boolean;
|
||||
index?: number;
|
||||
diversityIndex?: number;
|
||||
diversitiesByName: Record<string, {url: string; name: string; surrogatePair: string}>;
|
||||
urlForDiversitySurrogate: Record<string, string>;
|
||||
|
||||
constructor(emojiObject: {names: Array<string>; surrogates: string; hasDiversity?: boolean; skins?: Array<any>}) {
|
||||
const {names, surrogates} = emojiObject;
|
||||
const name = names[0] || '';
|
||||
|
||||
this.uniqueName = name;
|
||||
this.names = names;
|
||||
this.allNamesString = names.length > 1 ? `:${names.join(': :')}:` : `:${name}:`;
|
||||
this.defaultUrl = EmojiUtils.getEmojiURL(surrogates) ?? undefined;
|
||||
this.surrogates = surrogates;
|
||||
this.useSpriteSheet = false;
|
||||
this.index = undefined;
|
||||
this.diversityIndex = undefined;
|
||||
this.urlForDiversitySurrogate = {};
|
||||
this.diversitiesByName = {};
|
||||
this.hasDiversity = emojiObject.hasDiversity || !!(emojiObject.skins && emojiObject.skins.length > 0);
|
||||
this.managed = true;
|
||||
|
||||
if (this.hasDiversity && emojiObject.skins) {
|
||||
SKIN_TONE_SURROGATES.forEach((skinTone, index) => {
|
||||
const skinData = emojiObject.skins?.[index];
|
||||
if (skinData) {
|
||||
const surrogatePair = skinData.surrogates;
|
||||
const url = EmojiUtils.getEmojiURL(surrogatePair);
|
||||
if (url) {
|
||||
this.urlForDiversitySurrogate[skinTone] = url;
|
||||
|
||||
names.forEach((name) => {
|
||||
const skinName = `${name}::skin-tone-${index + 1}`;
|
||||
this.diversitiesByName[skinName] = {
|
||||
name: skinName,
|
||||
surrogatePair,
|
||||
url,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get url(): string | undefined {
|
||||
if (this.hasDiversity && defaultSkinTone !== '' && this.urlForDiversitySurrogate[defaultSkinTone]) {
|
||||
return this.urlForDiversitySurrogate[defaultSkinTone];
|
||||
}
|
||||
return this.defaultUrl;
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
if (this.hasDiversity && defaultSkinTone !== '') {
|
||||
return `${this.uniqueName}::${surrogateToName[defaultSkinTone]}`;
|
||||
}
|
||||
return this.uniqueName;
|
||||
}
|
||||
|
||||
get surrogatePair(): string {
|
||||
const diversity = this.diversitiesByName[this.name];
|
||||
return diversity ? diversity.surrogatePair : this.surrogates;
|
||||
}
|
||||
|
||||
setSpriteSheetIndex(index: number, isDiversity = false) {
|
||||
if (isDiversity) {
|
||||
this.diversityIndex = index;
|
||||
} else {
|
||||
this.index = index;
|
||||
}
|
||||
this.useSpriteSheet = true;
|
||||
}
|
||||
|
||||
toJSON(): UnicodeEmoji {
|
||||
return {
|
||||
uniqueName: this.uniqueName,
|
||||
name: this.name,
|
||||
names: this.names,
|
||||
allNamesString: this.allNamesString,
|
||||
url: this.url,
|
||||
surrogates: this.surrogatePair,
|
||||
hasDiversity: this.hasDiversity,
|
||||
managed: this.managed,
|
||||
useSpriteSheet: this.useSpriteSheet,
|
||||
index: this.index,
|
||||
diversityIndex: this.diversityIndex,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Object.entries(emojiData).forEach(([category, emojiObjects]) => {
|
||||
emojisByCategory[category] = emojiObjects.map((emojiObject) => {
|
||||
const emoji = new UnicodeEmojiClass(emojiObject);
|
||||
|
||||
if (emoji.hasDiversity) {
|
||||
emoji.setSpriteSheetIndex(numDiversitySprites++, true);
|
||||
}
|
||||
emoji.setSpriteSheetIndex(numNonDiversitySprites++, false);
|
||||
|
||||
surrogateToName[emoji.surrogates] = emoji.uniqueName;
|
||||
|
||||
emoji.names.forEach((name) => {
|
||||
nameToEmoji[name] = emoji.toJSON();
|
||||
nameToSurrogate[name] = emoji.surrogates;
|
||||
});
|
||||
|
||||
Object.values(emoji.diversitiesByName).forEach((diversity) => {
|
||||
nameToEmoji[diversity.name] = emoji.toJSON();
|
||||
nameToSurrogate[diversity.name] = diversity.surrogatePair;
|
||||
surrogateToName[diversity.surrogatePair] = diversity.name;
|
||||
});
|
||||
|
||||
const emojiJson = emoji.toJSON();
|
||||
emojis.push(emojiJson);
|
||||
return emojiJson;
|
||||
});
|
||||
});
|
||||
|
||||
SKIN_TONE_SURROGATES.forEach((surrogatePair, index) => {
|
||||
nameToSurrogate[`skin-tone-${index + 1}`] = surrogatePair;
|
||||
surrogateToName[surrogatePair] = `skin-tone-${index + 1}`;
|
||||
});
|
||||
|
||||
emojiShortcuts.forEach(({emoji, shortcuts}) => {
|
||||
shortcuts.forEach((shortcut: string) => {
|
||||
shortcutToName[shortcut] = emoji;
|
||||
});
|
||||
});
|
||||
|
||||
const EMOJI_NAME_RE = /^:([^\s:]+?(?:::skin-tone-\d)?):/;
|
||||
const EMOJI_NAME_AND_DIVERSITY_RE = /^:([^\s:]+?(?:::skin-tone-\d)?):/;
|
||||
const EMOJI_SURROGATE_RE = (() => {
|
||||
const emojiRegex = Object.keys(surrogateToName)
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.map(RegexUtils.escapeRegex)
|
||||
.join('|');
|
||||
return new RegExp(`(${emojiRegex})`, 'g');
|
||||
})();
|
||||
const EMOJI_SHORTCUT_RE = (() => {
|
||||
const emojiRegex = Object.keys(shortcutToName).map(RegexUtils.escapeRegex).join('|');
|
||||
return new RegExp(`^(${emojiRegex})`);
|
||||
})();
|
||||
|
||||
const categoryIcons = {
|
||||
people: SmileyIcon,
|
||||
nature: LeafIcon,
|
||||
food: BowlFoodIcon,
|
||||
activity: GameControllerIcon,
|
||||
travel: BicycleIcon,
|
||||
objects: MagnetIcon,
|
||||
symbols: HeartIcon,
|
||||
flags: FlagIcon,
|
||||
};
|
||||
|
||||
const getCategoryLabel = (category: string, i18n: I18n): string => {
|
||||
switch (category) {
|
||||
case 'people':
|
||||
return i18n._(msg`People`);
|
||||
case 'nature':
|
||||
return i18n._(msg`Nature`);
|
||||
case 'food':
|
||||
return i18n._(msg`Food & Drink`);
|
||||
case 'activity':
|
||||
return i18n._(msg`Activities`);
|
||||
case 'travel':
|
||||
return i18n._(msg`Travel & Places`);
|
||||
case 'objects':
|
||||
return i18n._(msg`Objects`);
|
||||
case 'symbols':
|
||||
return i18n._(msg`Symbols`);
|
||||
case 'flags':
|
||||
return i18n._(msg`Flags`);
|
||||
default:
|
||||
return category;
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
getDefaultSkinTone: (): string => defaultSkinTone,
|
||||
setDefaultSkinTone: (skinTone: string): void => {
|
||||
defaultSkinTone = skinTone || '';
|
||||
},
|
||||
getCategories: (): ReadonlyArray<string> => Object.freeze(Object.keys(emojisByCategory)),
|
||||
getByName: (emojiName: string): UnicodeEmoji | null => nameToEmoji[emojiName] || null,
|
||||
getByCategory: (emojiCategory: string): ReadonlyArray<UnicodeEmoji> | null => emojisByCategory[emojiCategory] || null,
|
||||
translateInlineEmojiToSurrogates: (content: string): string => {
|
||||
return content.replace(EMOJI_NAME_AND_DIVERSITY_RE, (original, emoji) => nameToSurrogate[emoji] || original);
|
||||
},
|
||||
translateSurrogatesToInlineEmoji: (content: string): string => {
|
||||
return content.replace(EMOJI_SURROGATE_RE, (_, surrogate) => {
|
||||
const name = surrogateToName[surrogate];
|
||||
return name ? `:${name}:` : surrogate;
|
||||
});
|
||||
},
|
||||
convertNameToSurrogate: (emojiName: string, defaultSurrogate = ''): string => {
|
||||
return nameToSurrogate[emojiName] || defaultSurrogate;
|
||||
},
|
||||
convertSurrogateToName: (surrogate: string, includeColons = true, defaultName = ''): string => {
|
||||
const name = surrogateToName[surrogate] || defaultName;
|
||||
return includeColons && name ? `:${name}:` : name;
|
||||
},
|
||||
convertShortcutToName: (shortcut: string, includeColons = true, defaultName = ''): string => {
|
||||
const name = shortcutToName[shortcut] || defaultName;
|
||||
return includeColons && name ? `:${name}:` : name;
|
||||
},
|
||||
forEachEmoji: (callback: (emoji: UnicodeEmoji) => void): void => {
|
||||
emojis.forEach(callback);
|
||||
},
|
||||
all: (): ReadonlyArray<UnicodeEmoji> => emojis,
|
||||
getCategoryForEmoji: (emoji: UnicodeEmoji): string | null => {
|
||||
for (const [category, categoryEmojis] of Object.entries(emojisByCategory)) {
|
||||
if (categoryEmojis.some((e) => e.uniqueName === emoji.uniqueName)) {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getCategoryIcon: (category: string): React.ComponentType<{className?: string}> => {
|
||||
return categoryIcons[category as keyof typeof categoryIcons] || SmileyIcon;
|
||||
},
|
||||
getCategoryLabel,
|
||||
getSurrogateName: (surrogate: string): string | null => {
|
||||
return surrogateToName[surrogate] || null;
|
||||
},
|
||||
findEmojiByName: (emojiName: string): UnicodeEmoji | null => {
|
||||
return nameToEmoji[emojiName] || null;
|
||||
},
|
||||
findEmojiWithSkinTone: (baseName: string, skinToneSurrogate: string): UnicodeEmoji | null => {
|
||||
const skinToneName = surrogateToName[skinToneSurrogate];
|
||||
if (!skinToneName) return null;
|
||||
|
||||
const skinToneEmojiName = `${baseName}::${skinToneName}`;
|
||||
return nameToEmoji[skinToneEmojiName] || null;
|
||||
},
|
||||
numDiversitySprites,
|
||||
numNonDiversitySprites,
|
||||
EMOJI_NAME_RE,
|
||||
EMOJI_NAME_AND_DIVERSITY_RE,
|
||||
EMOJI_SHORTCUT_RE,
|
||||
EMOJI_SPRITES,
|
||||
};
|
||||
166
fluxer_app/src/lib/VoiceStatsDB.ts
Normal file
166
fluxer_app/src/lib/VoiceStatsDB.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Logger} from '~/lib/Logger';
|
||||
|
||||
const logger = new Logger('VoiceStatsDB');
|
||||
|
||||
const DB_NAME = 'FluxerVoiceStats';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'stats';
|
||||
|
||||
interface StatEntry {
|
||||
reportId: string;
|
||||
bytes: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const IDB_TIMEOUT = 5000;
|
||||
|
||||
class VoiceStatsDB {
|
||||
private db: IDBDatabase | null = null;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
private initFailed = false;
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (this.initFailed) return;
|
||||
if (this.initPromise) return this.initPromise;
|
||||
|
||||
const openPromise = new Promise<void>((resolve, reject) => {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => {
|
||||
logger.error('Failed to open IndexedDB', request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
|
||||
request.onblocked = () => {
|
||||
logger.warn('VoiceStatsDB: IndexedDB blocked');
|
||||
reject(new Error('IndexedDB blocked'));
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, {keyPath: 'reportId'});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<void>((_, reject) => {
|
||||
setTimeout(() => reject(new Error('VoiceStatsDB init timeout')), IDB_TIMEOUT);
|
||||
});
|
||||
|
||||
this.initPromise = Promise.race([openPromise, timeoutPromise]).catch((err) => {
|
||||
logger.warn('VoiceStatsDB init failed, will use no-op mode', err);
|
||||
this.initFailed = true;
|
||||
});
|
||||
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
async set(reportId: string, bytes: number, timestamp: number): Promise<void> {
|
||||
await this.init();
|
||||
if (!this.db) return;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction([STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const entry: StatEntry = {reportId, bytes, timestamp};
|
||||
const request = store.put(entry);
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async get(reportId: string): Promise<{bytes: number; timestamp: number} | null> {
|
||||
await this.init();
|
||||
if (!this.db) return null;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction([STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.get(reportId);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const entry = request.result as StatEntry | undefined;
|
||||
if (entry) {
|
||||
resolve({bytes: entry.bytes, timestamp: entry.timestamp});
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
await this.init();
|
||||
if (!this.db) return;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction([STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.clear();
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async clearOldEntries(maxAgeMs: number): Promise<void> {
|
||||
await this.init();
|
||||
if (!this.db) return;
|
||||
|
||||
const cutoffTime = Date.now() - maxAgeMs;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction([STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.openCursor();
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
|
||||
if (cursor) {
|
||||
const entry = cursor.value as StatEntry;
|
||||
if (entry.timestamp < cutoffTime) {
|
||||
cursor.delete();
|
||||
}
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const voiceStatsDB = new VoiceStatsDB();
|
||||
136
fluxer_app/src/lib/customStatus.ts
Normal file
136
fluxer_app/src/lib/customStatus.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface CustomStatus {
|
||||
text: string | null;
|
||||
expiresAt: string | null;
|
||||
emojiId: string | null;
|
||||
emojiName: string | null;
|
||||
emojiAnimated?: boolean | null;
|
||||
}
|
||||
|
||||
export interface GatewayCustomStatusPayload {
|
||||
text?: string | null;
|
||||
expires_at?: string | null;
|
||||
emoji_id?: string | null;
|
||||
emoji_name?: string | null;
|
||||
emoji_animated?: boolean | null;
|
||||
}
|
||||
|
||||
export const CUSTOM_STATUS_TEXT_LIMIT = 128;
|
||||
|
||||
export const isCustomStatusExpired = (status: CustomStatus | null, referenceTime = Date.now()): boolean => {
|
||||
if (!status?.expiresAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expiresAt = Date.parse(status.expiresAt);
|
||||
if (Number.isNaN(expiresAt)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return expiresAt <= referenceTime;
|
||||
};
|
||||
|
||||
function normalizeText(text: string | null | undefined): string | null {
|
||||
const trimmed = text?.trim() ?? null;
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return trimmed.slice(0, CUSTOM_STATUS_TEXT_LIMIT);
|
||||
}
|
||||
|
||||
function normalizeEmojiName(name: string | null | undefined): string | null {
|
||||
const trimmed = name?.trim() ?? null;
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
export const normalizeCustomStatus = (status: CustomStatus | null | undefined): CustomStatus | null => {
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = normalizeText(status.text);
|
||||
const emojiId = status.emojiId?.trim() ?? null;
|
||||
const emojiName = normalizeEmojiName(status.emojiName);
|
||||
const expiresAt = status.expiresAt ?? null;
|
||||
|
||||
if (!text && !emojiId && !emojiName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized: CustomStatus = {
|
||||
text,
|
||||
expiresAt,
|
||||
emojiId,
|
||||
emojiName,
|
||||
emojiAnimated: status.emojiAnimated ?? null,
|
||||
};
|
||||
|
||||
if (isCustomStatusExpired(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const toGatewayCustomStatus = (status: CustomStatus | null | undefined): GatewayCustomStatusPayload | null => {
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
text: status.text,
|
||||
expires_at: status.expiresAt,
|
||||
emoji_id: status.emojiId,
|
||||
emoji_name: status.emojiName,
|
||||
emoji_animated: status.emojiAnimated ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const fromGatewayCustomStatus = (
|
||||
payload: GatewayCustomStatusPayload | null | undefined,
|
||||
): CustomStatus | null => {
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const customStatus: CustomStatus = {
|
||||
text: payload.text ?? null,
|
||||
expiresAt: payload.expires_at ?? null,
|
||||
emojiId: payload.emoji_id ?? null,
|
||||
emojiName: payload.emoji_name ?? null,
|
||||
emojiAnimated: payload.emoji_animated ?? null,
|
||||
};
|
||||
|
||||
return normalizeCustomStatus(customStatus);
|
||||
};
|
||||
|
||||
export const customStatusToKey = (status: CustomStatus | null | undefined): string => {
|
||||
if (!status) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${status.text ?? ''}|${status.emojiId ?? ''}|${status.emojiName ?? ''}|${status.emojiAnimated ?? ''}|${status.expiresAt ?? ''}`;
|
||||
};
|
||||
|
||||
export const getCustomStatusText = (status: CustomStatus | null | undefined): string | null => {
|
||||
const normalized = status?.text ? status.text.trim() : null;
|
||||
return normalized || null;
|
||||
};
|
||||
22
fluxer_app/src/lib/env.ts
Normal file
22
fluxer_app/src/lib/env.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export const IS_DEV = import.meta.env.DEV;
|
||||
export const IS_PROD = import.meta.env.PROD;
|
||||
export const MODE = import.meta.env.MODE;
|
||||
47
fluxer_app/src/lib/isTextInputKeyEvent.ts
Normal file
47
fluxer_app/src/lib/isTextInputKeyEvent.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export function isTextInputKeyEvent(event: KeyboardEvent): boolean {
|
||||
const {key, ctrlKey, metaKey} = event;
|
||||
|
||||
if (!key || key === 'Unidentified') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ctrlKey || metaKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (key === 'Dead') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key.length > 1 && NAMED_KEY_PATTERN.test(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const firstCodePoint = key.codePointAt(0)!;
|
||||
if (firstCodePoint <= 0x1f || (firstCodePoint >= 0x7f && firstCodePoint <= 0x9f)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const NAMED_KEY_PATTERN = /^[A-Z][A-Za-z0-9]*$/;
|
||||
62
fluxer_app/src/lib/libfluxcore.ts
Normal file
62
fluxer_app/src/lib/libfluxcore.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import initLibfluxcore, * as wasm from '@pkgs/libfluxcore/libfluxcore';
|
||||
|
||||
let modulePromise: Promise<void> | null = null;
|
||||
|
||||
async function loadModule(): Promise<void> {
|
||||
if (!modulePromise) {
|
||||
modulePromise = (async () => {
|
||||
try {
|
||||
if (typeof initLibfluxcore === 'function') {
|
||||
await initLibfluxcore();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[libfluxcore] Failed to load wasm module', err);
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
}
|
||||
await modulePromise;
|
||||
}
|
||||
|
||||
export async function ensureLibfluxcoreReady(): Promise<void> {
|
||||
await loadModule();
|
||||
}
|
||||
|
||||
export function cropAndRotateGif(
|
||||
gif: Uint8Array,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
rotation: number,
|
||||
resizeWidth: number | null,
|
||||
resizeHeight: number | null,
|
||||
): Uint8Array {
|
||||
const result = wasm.crop_and_rotate_gif(gif, x, y, width, height, rotation, resizeWidth, resizeHeight);
|
||||
return result instanceof Uint8Array ? result : new Uint8Array(result);
|
||||
}
|
||||
|
||||
export async function decompressZstdFrame(input: Uint8Array): Promise<Uint8Array | null> {
|
||||
await loadModule();
|
||||
const result = wasm.decompress_zstd_frame(input);
|
||||
return result instanceof Uint8Array ? result : new Uint8Array(result);
|
||||
}
|
||||
199
fluxer_app/src/lib/libfluxcore.worker.ts
Normal file
199
fluxer_app/src/lib/libfluxcore.worker.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
let cachedWasm: WebAssembly.Instance | null = null;
|
||||
let wasmMemory: WebAssembly.Memory | null = null;
|
||||
let wasmExternrefTable: WebAssembly.Table | null = null;
|
||||
let wasmModule: WebAssembly.Module | null = null;
|
||||
|
||||
declare const self: DedicatedWorkerGlobalScope;
|
||||
|
||||
function getArrayU8FromWasm0(ptr: number, len: number): Uint8Array {
|
||||
ptr = ptr >>> 0;
|
||||
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
|
||||
}
|
||||
|
||||
let cachedUint8ArrayMemory0: Uint8Array | null = null;
|
||||
function getUint8ArrayMemory0(): Uint8Array {
|
||||
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
||||
cachedUint8ArrayMemory0 = new Uint8Array(wasmMemory!.buffer);
|
||||
}
|
||||
return cachedUint8ArrayMemory0;
|
||||
}
|
||||
|
||||
function isLikeNone(x: unknown): x is null | undefined {
|
||||
return x === null || x === undefined;
|
||||
}
|
||||
|
||||
let WASM_VECTOR_LEN = 0;
|
||||
|
||||
function passArray8ToWasm0(arg: Uint8Array, malloc: (n: number, align: number) => number): number {
|
||||
const ptr = malloc(arg.length * 1, 1) >>> 0;
|
||||
getUint8ArrayMemory0().set(arg, ptr / 1);
|
||||
WASM_VECTOR_LEN = arg.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
let cachedTextDecoder = new TextDecoder('utf-8', {ignoreBOM: true, fatal: true});
|
||||
cachedTextDecoder.decode();
|
||||
|
||||
const MAX_SAFARI_DECODE_BYTES = 2146435072;
|
||||
let numBytesDecoded = 0;
|
||||
|
||||
function decodeText(ptr: number, len: number): string {
|
||||
numBytesDecoded += len;
|
||||
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
|
||||
cachedTextDecoder = new TextDecoder('utf-8', {ignoreBOM: true, fatal: true});
|
||||
cachedTextDecoder.decode();
|
||||
numBytesDecoded = len;
|
||||
}
|
||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||
}
|
||||
|
||||
function takeObject(idx: number): any {
|
||||
const ret = wasmExternrefTable!.get(idx);
|
||||
wasmExternrefTable!.set(idx, undefined);
|
||||
return ret;
|
||||
}
|
||||
|
||||
async function loadWasmModule(wasmUrl: string): Promise<void> {
|
||||
if (cachedWasm) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(wasmUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch WASM: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
wasmModule = await WebAssembly.compile(buffer);
|
||||
|
||||
const imports = getImports();
|
||||
const instance = await WebAssembly.instantiate(wasmModule, imports);
|
||||
cachedWasm = instance;
|
||||
|
||||
wasmMemory = instance.exports.memory as WebAssembly.Memory;
|
||||
wasmExternrefTable = instance.exports.__wbindgen_externref_table as WebAssembly.Table;
|
||||
|
||||
cachedUint8ArrayMemory0 = null;
|
||||
|
||||
const exports = instance.exports as any;
|
||||
exports.__wbindgen_start();
|
||||
}
|
||||
|
||||
function getImports(): Record<string, any> {
|
||||
const imports: Record<string, any> = {
|
||||
wbg: {},
|
||||
};
|
||||
|
||||
imports.wbg.__wbindgen_string_get = (arg0: number, arg1: number) => {
|
||||
const ret = decodeText(arg0, arg1);
|
||||
return ret;
|
||||
};
|
||||
|
||||
imports.wbg.__wbindgen_is_null = (arg0: number) => (arg0 === 0 ? 1 : 0);
|
||||
|
||||
imports.wbg.__wbindgen_is_undefined = (arg0: number) => (arg0 === 0 ? 1 : 0);
|
||||
|
||||
imports.wbg.__wbindgen_number_get = (_arg0: number, arg1: number) => {
|
||||
const obj = takeObject(arg1);
|
||||
const ret = typeof obj === 'number' ? obj : undefined;
|
||||
return ret;
|
||||
};
|
||||
|
||||
imports.wbg.__wbindgen_object_clone_ref = (arg0: number) => {
|
||||
const obj = takeObject(arg0);
|
||||
const ret = obj;
|
||||
return ret;
|
||||
};
|
||||
|
||||
imports.wbg.__wbindgen_object_drop_ref = (_arg0: number) => {};
|
||||
|
||||
imports.wbg.__wbindgen_cb_drop = (arg0: number) => {
|
||||
const obj = takeObject(arg0).original;
|
||||
if (obj.cnt-- === 1) {
|
||||
obj.a = 0;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
imports.wbg.__wbindgen_init_externref_table = () => {
|
||||
const table = wasmExternrefTable!;
|
||||
const offset = table.grow(4);
|
||||
table.set(0, undefined);
|
||||
table.set(offset + 0, undefined);
|
||||
table.set(offset + 1, null);
|
||||
table.set(offset + 2, true);
|
||||
table.set(offset + 3, false);
|
||||
};
|
||||
|
||||
imports.wbg.__wbindgen_throw = (arg0: number, arg1: number) => {
|
||||
throw new Error(decodeText(arg0, arg1));
|
||||
};
|
||||
|
||||
return imports;
|
||||
}
|
||||
|
||||
export async function ensureLibfluxcoreReady(): Promise<void> {
|
||||
const wasmUrl = new URL('@pkgs/libfluxcore/libfluxcore_bg.wasm', import.meta.url);
|
||||
await loadWasmModule(wasmUrl.href);
|
||||
}
|
||||
|
||||
export function cropAndRotateGif(
|
||||
gif: Uint8Array,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
rotation: number,
|
||||
resizeWidth: number | null,
|
||||
resizeHeight: number | null,
|
||||
): Uint8Array {
|
||||
if (!cachedWasm) {
|
||||
throw new Error('WASM module not loaded. Call ensureLibfluxcoreReady() first.');
|
||||
}
|
||||
|
||||
const exports = cachedWasm.exports as any;
|
||||
|
||||
const ptr0 = passArray8ToWasm0(gif, exports.__wbindgen_malloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
|
||||
const ret = exports.crop_and_rotate_gif(
|
||||
ptr0,
|
||||
len0,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
rotation,
|
||||
isLikeNone(resizeWidth) ? 0x100000001 : (resizeWidth ?? 0) >>> 0,
|
||||
isLikeNone(resizeHeight) ? 0x100000001 : (resizeHeight ?? 0) >>> 0,
|
||||
);
|
||||
|
||||
if (ret[3]) {
|
||||
throw takeObject(ret[2]);
|
||||
}
|
||||
|
||||
const v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
||||
exports.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
||||
|
||||
return v2;
|
||||
}
|
||||
21
fluxer_app/src/lib/markdown/config.ts
Normal file
21
fluxer_app/src/lib/markdown/config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export const TABLE_PARSING_ENABLED = false;
|
||||
export const TABLE_PARSING_FLAG = 0;
|
||||
90
fluxer_app/src/lib/markdown/index.tsx
Normal file
90
fluxer_app/src/lib/markdown/index.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import markupStyles from '~/styles/Markup.module.css';
|
||||
import {Parser} from './parser/parser/parser';
|
||||
import {
|
||||
getParserFlagsForContext,
|
||||
MarkdownContext,
|
||||
type MarkdownParseOptions,
|
||||
render,
|
||||
wrapRenderedContent,
|
||||
} from './renderers';
|
||||
|
||||
const MarkdownErrorBoundary = class MarkdownErrorBoundary extends React.Component<
|
||||
{children: React.ReactNode},
|
||||
{hasError: boolean; error: Error | null}
|
||||
> {
|
||||
constructor(props: {children: React.ReactNode}) {
|
||||
super(props);
|
||||
this.state = {hasError: false, error: null};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return {hasError: true, error};
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
console.error('Error rendering markdown:', error, info);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<span className={markupStyles.error}>
|
||||
<Trans>Error rendering content</Trans>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
};
|
||||
|
||||
function parseMarkdown(
|
||||
content: string,
|
||||
options: MarkdownParseOptions = {context: MarkdownContext.STANDARD_WITHOUT_JUMBO},
|
||||
): React.ReactNode {
|
||||
try {
|
||||
const flags = getParserFlagsForContext(options.context);
|
||||
|
||||
const parser = new Parser(content, flags);
|
||||
const {nodes} = parser.parse();
|
||||
|
||||
const renderedContent = render(nodes, options);
|
||||
|
||||
return wrapRenderedContent(renderedContent, options.context);
|
||||
} catch (error) {
|
||||
console.error(`Error parsing markdown (${options.context}):`, error);
|
||||
return <span>{content}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
export const SafeMarkdown = observer(function SafeMarkdown({
|
||||
content,
|
||||
options = {context: MarkdownContext.STANDARD_WITHOUT_JUMBO},
|
||||
}: {
|
||||
content: string;
|
||||
options?: MarkdownParseOptions;
|
||||
}): React.ReactElement {
|
||||
return <MarkdownErrorBoundary>{parseMarkdown(content, options)}</MarkdownErrorBoundary>;
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {beforeEach, describe, expect, test} from 'vitest';
|
||||
import {FormattingContext} from './formatting-context';
|
||||
|
||||
describe('FormattingContext', () => {
|
||||
let context: FormattingContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = new FormattingContext();
|
||||
});
|
||||
|
||||
describe('pushFormatting and popFormatting', () => {
|
||||
test('should push and pop formatting markers correctly', () => {
|
||||
context.pushFormatting('*', true);
|
||||
context.pushFormatting('_', false);
|
||||
|
||||
expect(context.popFormatting()).toEqual(['_', false]);
|
||||
expect(context.popFormatting()).toEqual(['*', true]);
|
||||
});
|
||||
|
||||
test('should handle empty stack when popping', () => {
|
||||
expect(context.popFormatting()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should update active formatting types when pushing', () => {
|
||||
context.pushFormatting('*', true);
|
||||
expect(context.isFormattingActive('*', true)).toBe(true);
|
||||
expect(context.isFormattingActive('*', false)).toBe(false);
|
||||
});
|
||||
|
||||
test('should remove active formatting types when popping', () => {
|
||||
context.pushFormatting('*', true);
|
||||
expect(context.isFormattingActive('*', true)).toBe(true);
|
||||
|
||||
context.popFormatting();
|
||||
expect(context.isFormattingActive('*', true)).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle multiple formatting markers of same type', () => {
|
||||
context.pushFormatting('*', true);
|
||||
context.pushFormatting('*', false);
|
||||
|
||||
expect(context.isFormattingActive('*', true)).toBe(true);
|
||||
expect(context.isFormattingActive('*', false)).toBe(true);
|
||||
|
||||
context.popFormatting();
|
||||
expect(context.isFormattingActive('*', false)).toBe(false);
|
||||
expect(context.isFormattingActive('*', true)).toBe(true);
|
||||
|
||||
context.popFormatting();
|
||||
expect(context.isFormattingActive('*', true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFormattingActive', () => {
|
||||
test('should detect active formatting correctly', () => {
|
||||
context.pushFormatting('*', true);
|
||||
expect(context.isFormattingActive('*', true)).toBe(true);
|
||||
expect(context.isFormattingActive('*', false)).toBe(false);
|
||||
expect(context.isFormattingActive('_', true)).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle different marker and emphasis combinations', () => {
|
||||
context.pushFormatting('_', true);
|
||||
context.pushFormatting('*', false);
|
||||
context.pushFormatting('~', true);
|
||||
|
||||
expect(context.isFormattingActive('_', true)).toBe(true);
|
||||
expect(context.isFormattingActive('*', false)).toBe(true);
|
||||
expect(context.isFormattingActive('~', true)).toBe(true);
|
||||
expect(context.isFormattingActive('_', false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canEnterFormatting', () => {
|
||||
test('should allow formatting when not active', () => {
|
||||
expect(context.canEnterFormatting('*', true)).toBe(true);
|
||||
expect(context.canEnterFormatting('_', false)).toBe(true);
|
||||
});
|
||||
|
||||
test('should prevent entering active formatting', () => {
|
||||
context.pushFormatting('*', true);
|
||||
expect(context.canEnterFormatting('*', true)).toBe(false);
|
||||
expect(context.canEnterFormatting('*', false)).toBe(true);
|
||||
});
|
||||
|
||||
test('should handle underscore special cases', () => {
|
||||
context.setCurrentText('test_with_underscores');
|
||||
context.pushFormatting('_', false);
|
||||
expect(context.canEnterFormatting('_', true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setCurrentText', () => {
|
||||
test('should detect underscores in text', () => {
|
||||
context.setCurrentText('test_with_underscores');
|
||||
context.pushFormatting('_', false);
|
||||
expect(context.canEnterFormatting('_', true)).toBe(true);
|
||||
});
|
||||
|
||||
test('should handle text without underscores', () => {
|
||||
context.setCurrentText('test without underscores');
|
||||
expect(context.canEnterFormatting('_', true)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {MAX_INLINE_DEPTH} from '../types/constants';
|
||||
|
||||
export class FormattingContext {
|
||||
private readonly activeFormattingTypes = new Map<string, boolean>();
|
||||
private readonly formattingStack: Array<[string, boolean]> = [];
|
||||
private currentDepth = 0;
|
||||
|
||||
canEnterFormatting(delimiter: string, isDouble: boolean): boolean {
|
||||
const key = this.getFormattingKey(delimiter, isDouble);
|
||||
if (this.activeFormattingTypes.has(key)) return false;
|
||||
return this.currentDepth < MAX_INLINE_DEPTH;
|
||||
}
|
||||
|
||||
isFormattingActive(delimiter: string, isDouble: boolean): boolean {
|
||||
return this.activeFormattingTypes.has(this.getFormattingKey(delimiter, isDouble));
|
||||
}
|
||||
|
||||
pushFormatting(delimiter: string, isDouble: boolean): void {
|
||||
this.formattingStack.push([delimiter, isDouble]);
|
||||
this.activeFormattingTypes.set(this.getFormattingKey(delimiter, isDouble), true);
|
||||
this.currentDepth++;
|
||||
}
|
||||
|
||||
popFormatting(): [string, boolean] | undefined {
|
||||
const removed = this.formattingStack.pop();
|
||||
if (removed) {
|
||||
this.activeFormattingTypes.delete(this.getFormattingKey(removed[0], removed[1]));
|
||||
this.currentDepth--;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
setCurrentText(_text: string): void {}
|
||||
|
||||
private getFormattingKey(delimiter: string, isDouble: boolean): string {
|
||||
return `${delimiter}${isDouble ? '2' : '1'}`;
|
||||
}
|
||||
}
|
||||
185
fluxer_app/src/lib/markdown/parser/parser/parser.test.ts
Normal file
185
fluxer_app/src/lib/markdown/parser/parser/parser.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {describe, expect, test} from 'vitest';
|
||||
import {MentionKind, NodeType, ParserFlags} from '../types/enums';
|
||||
import {Parser} from './parser';
|
||||
|
||||
describe('Fluxer Markdown Parser', () => {
|
||||
test('empty input', () => {
|
||||
const input = '';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
expect(ast).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('multiple consecutive newlines', () => {
|
||||
const input = 'First paragraph.\n\n\n\nSecond paragraph.';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'First paragraph.\n'},
|
||||
{type: NodeType.Text, content: '\n\n\n'},
|
||||
{type: NodeType.Text, content: 'Second paragraph.'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('multiple newlines between blocks', () => {
|
||||
const input = '# Heading\n\n\n\nParagraph.';
|
||||
const flags = ParserFlags.ALLOW_HEADINGS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Heading,
|
||||
level: 1,
|
||||
children: [{type: NodeType.Text, content: 'Heading'}],
|
||||
},
|
||||
{type: NodeType.Text, content: 'Paragraph.'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('preserve consecutive newlines between paragraphs', () => {
|
||||
const input = 'First paragraph\n\n\n\nSecond paragraph';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'First paragraph\n'},
|
||||
{type: NodeType.Text, content: '\n\n\n'},
|
||||
{type: NodeType.Text, content: 'Second paragraph'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('flags disabling spoilers', () => {
|
||||
const input = 'This is a ||secret|| message';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: 'This is a ||secret|| message'}]);
|
||||
});
|
||||
|
||||
test('flags disabling headings', () => {
|
||||
const input = '# Heading 1';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '# Heading 1'}]);
|
||||
});
|
||||
|
||||
test('flags disabling code blocks', () => {
|
||||
const input = '```rust\nfn main() {}\n```';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '```rust\nfn main() {}\n```'}]);
|
||||
});
|
||||
|
||||
test('flags disabling custom links', () => {
|
||||
const input = '[Rust](https://www.rust-lang.org)';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '[Rust](https://www.rust-lang.org)'}]);
|
||||
});
|
||||
|
||||
test('flags disabling command mentions', () => {
|
||||
const input = 'Use </airhorn:816437322781949972>';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: 'Use </airhorn:816437322781949972>'}]);
|
||||
});
|
||||
|
||||
test('flags disabling guild navigations', () => {
|
||||
const input = 'Go to <id:customize> now!';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: 'Go to <id:customize> now!'}]);
|
||||
});
|
||||
|
||||
test('flags partial', () => {
|
||||
const input = '# Heading\nThis is a ||secret|| message with a [link](https://example.com).';
|
||||
const flags = ParserFlags.ALLOW_HEADINGS | ParserFlags.ALLOW_SPOILERS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Heading,
|
||||
level: 1,
|
||||
children: [{type: NodeType.Text, content: 'Heading'}],
|
||||
},
|
||||
{type: NodeType.Text, content: 'This is a '},
|
||||
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: 'secret'}]},
|
||||
{type: NodeType.Text, content: ' message with a [link](https://example.com).'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('flags all enabled', () => {
|
||||
const input = '# Heading\n||Spoiler|| with a [link](https://example.com) and a </command:12345>.';
|
||||
const flags =
|
||||
ParserFlags.ALLOW_HEADINGS |
|
||||
ParserFlags.ALLOW_SPOILERS |
|
||||
ParserFlags.ALLOW_MASKED_LINKS |
|
||||
ParserFlags.ALLOW_COMMAND_MENTIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Heading,
|
||||
level: 1,
|
||||
children: [{type: NodeType.Text, content: 'Heading'}],
|
||||
},
|
||||
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: 'Spoiler'}]},
|
||||
{type: NodeType.Text, content: ' with a '},
|
||||
{
|
||||
type: NodeType.Link,
|
||||
text: {type: NodeType.Text, content: 'link'},
|
||||
url: 'https://example.com/',
|
||||
escaped: false,
|
||||
},
|
||||
{type: NodeType.Text, content: ' and a '},
|
||||
{
|
||||
type: NodeType.Mention,
|
||||
kind: {
|
||||
kind: MentionKind.Command,
|
||||
name: 'command',
|
||||
subcommandGroup: undefined,
|
||||
subcommand: undefined,
|
||||
id: '12345',
|
||||
},
|
||||
},
|
||||
{type: NodeType.Text, content: '.'},
|
||||
]);
|
||||
});
|
||||
});
|
||||
195
fluxer_app/src/lib/markdown/parser/parser/parser.ts
Normal file
195
fluxer_app/src/lib/markdown/parser/parser/parser.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as BlockParsers from '../parsers/block-parsers';
|
||||
import {applyTextPresentation} from '../parsers/emoji-parsers';
|
||||
import * as InlineParsers from '../parsers/inline-parsers';
|
||||
import * as ListParsers from '../parsers/list-parsers';
|
||||
import {MAX_AST_NODES, MAX_LINE_LENGTH, MAX_LINES} from '../types/constants';
|
||||
import {NodeType, ParserFlags} from '../types/enums';
|
||||
import type {Node} from '../types/nodes';
|
||||
import * as ASTUtils from '../utils/ast-utils';
|
||||
|
||||
export class Parser {
|
||||
private readonly lines: Array<string>;
|
||||
private currentLineIndex: number;
|
||||
private readonly totalLineCount: number;
|
||||
private readonly parserFlags: number;
|
||||
private nodeCount: number;
|
||||
|
||||
constructor(input: string, flags: number) {
|
||||
if (!input || input === '') {
|
||||
this.lines = [];
|
||||
this.currentLineIndex = 0;
|
||||
this.totalLineCount = 0;
|
||||
this.parserFlags = flags;
|
||||
this.nodeCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = input.split('\n');
|
||||
if (lines.length > MAX_LINES) {
|
||||
lines.length = MAX_LINES;
|
||||
}
|
||||
|
||||
if (lines.length === 1 && lines[0] === '') {
|
||||
this.lines = [];
|
||||
} else {
|
||||
this.lines = lines;
|
||||
}
|
||||
|
||||
this.currentLineIndex = 0;
|
||||
this.totalLineCount = this.lines.length;
|
||||
this.parserFlags = flags;
|
||||
this.nodeCount = 0;
|
||||
}
|
||||
|
||||
parse(): {nodes: Array<Node>} {
|
||||
const ast: Array<Node> = [];
|
||||
if (this.totalLineCount === 0) {
|
||||
return {nodes: ast};
|
||||
}
|
||||
|
||||
while (this.currentLineIndex < this.totalLineCount && this.nodeCount <= MAX_AST_NODES) {
|
||||
const line = this.lines[this.currentLineIndex];
|
||||
let lineLength = line.length;
|
||||
if (lineLength > MAX_LINE_LENGTH) {
|
||||
this.lines[this.currentLineIndex] = line.slice(0, MAX_LINE_LENGTH);
|
||||
lineLength = MAX_LINE_LENGTH;
|
||||
}
|
||||
|
||||
const trimmedLine = line.trimStart();
|
||||
if (trimmedLine === '') {
|
||||
const blankLineCount = this.countBlankLines(this.currentLineIndex);
|
||||
if (ast.length > 0 && this.currentLineIndex + blankLineCount < this.totalLineCount) {
|
||||
const nextLine = this.lines[this.currentLineIndex + blankLineCount];
|
||||
const nextTrimmed = nextLine.trimStart();
|
||||
|
||||
const isNextHeading = nextTrimmed
|
||||
? BlockParsers.parseHeading(nextTrimmed, (text) => InlineParsers.parseInline(text, this.parserFlags)) !==
|
||||
null
|
||||
: false;
|
||||
|
||||
const isPreviousHeading = ast[ast.length - 1]?.type === NodeType.Heading;
|
||||
|
||||
if (!isNextHeading && !isPreviousHeading) {
|
||||
const newlines = '\n'.repeat(blankLineCount);
|
||||
ast.push({type: NodeType.Text, content: newlines});
|
||||
this.nodeCount++;
|
||||
}
|
||||
}
|
||||
this.currentLineIndex += blankLineCount;
|
||||
continue;
|
||||
}
|
||||
|
||||
const blockResult = BlockParsers.parseBlock(
|
||||
this,
|
||||
this.lines,
|
||||
this.currentLineIndex,
|
||||
this.parserFlags,
|
||||
this.nodeCount,
|
||||
);
|
||||
|
||||
if (blockResult.node) {
|
||||
ast.push(blockResult.node);
|
||||
if (blockResult.extraNodes) {
|
||||
for (const extraNode of blockResult.extraNodes) {
|
||||
ast.push(extraNode);
|
||||
}
|
||||
}
|
||||
this.currentLineIndex = blockResult.newLineIndex;
|
||||
this.nodeCount = blockResult.newNodeCount;
|
||||
continue;
|
||||
}
|
||||
|
||||
this.parseInlineLine(ast);
|
||||
this.currentLineIndex++;
|
||||
}
|
||||
|
||||
ASTUtils.flattenAST(ast);
|
||||
|
||||
for (const node of ast) {
|
||||
applyTextPresentation(node);
|
||||
}
|
||||
|
||||
return {nodes: ast};
|
||||
}
|
||||
|
||||
private countBlankLines(startLine: number): number {
|
||||
let count = 0;
|
||||
let current = startLine;
|
||||
while (current < this.totalLineCount && this.lines[current].trim() === '') {
|
||||
count++;
|
||||
current++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private parseInlineLine(ast: Array<Node>): void {
|
||||
let text = this.lines[this.currentLineIndex];
|
||||
let linesConsumed = 1;
|
||||
|
||||
while (this.currentLineIndex + linesConsumed < this.totalLineCount) {
|
||||
const nextLine = this.lines[this.currentLineIndex + linesConsumed];
|
||||
const trimmedNext = nextLine.trimStart();
|
||||
|
||||
if (this.isBlockStart(trimmedNext)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (trimmedNext === '') {
|
||||
break;
|
||||
}
|
||||
|
||||
text += `\n${nextLine}`;
|
||||
linesConsumed++;
|
||||
}
|
||||
|
||||
if (this.currentLineIndex + linesConsumed < this.totalLineCount) {
|
||||
const nextLine = this.lines[this.currentLineIndex + linesConsumed];
|
||||
const trimmedNext = nextLine.trimStart();
|
||||
const isNextLineHeading = trimmedNext.startsWith('#') && !trimmedNext.startsWith('-#');
|
||||
const isNextLineBlockquote = trimmedNext.startsWith('>');
|
||||
if (trimmedNext === '' || (!isNextLineHeading && !isNextLineBlockquote)) {
|
||||
text += '\n';
|
||||
}
|
||||
}
|
||||
|
||||
const inlineNodes = InlineParsers.parseInline(text, this.parserFlags);
|
||||
|
||||
for (const node of inlineNodes) {
|
||||
ast.push(node);
|
||||
this.nodeCount++;
|
||||
if (this.nodeCount > MAX_AST_NODES) break;
|
||||
}
|
||||
|
||||
this.currentLineIndex += linesConsumed - 1;
|
||||
}
|
||||
|
||||
private isBlockStart(line: string): boolean {
|
||||
return !!(
|
||||
line.startsWith('#') ||
|
||||
(this.parserFlags & ParserFlags.ALLOW_SUBTEXT && line.startsWith('-#')) ||
|
||||
(this.parserFlags & ParserFlags.ALLOW_CODE_BLOCKS && line.startsWith('```')) ||
|
||||
(this.parserFlags & ParserFlags.ALLOW_LISTS && ListParsers.matchListItem(line) != null) ||
|
||||
(this.parserFlags & (ParserFlags.ALLOW_BLOCKQUOTES | ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES) &&
|
||||
(line.startsWith('>') || line.startsWith('>>> ')))
|
||||
);
|
||||
}
|
||||
}
|
||||
1279
fluxer_app/src/lib/markdown/parser/parsers/block-parsers.test.ts
Normal file
1279
fluxer_app/src/lib/markdown/parser/parsers/block-parsers.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
806
fluxer_app/src/lib/markdown/parser/parsers/block-parsers.ts
Normal file
806
fluxer_app/src/lib/markdown/parser/parsers/block-parsers.ts
Normal file
@@ -0,0 +1,806 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Parser} from '../parser/parser';
|
||||
import {MAX_AST_NODES, MAX_LINE_LENGTH} from '../types/constants';
|
||||
import {AlertType, NodeType, ParserFlags} from '../types/enums';
|
||||
import type {AlertNode, CodeBlockNode, HeadingNode, Node, SpoilerNode, SubtextNode, TextNode} from '../types/nodes';
|
||||
import {flattenChildren} from '../utils/ast-utils';
|
||||
import * as InlineParsers from './inline-parsers';
|
||||
import * as ListParsers from './list-parsers';
|
||||
import * as TableParsers from './table-parsers';
|
||||
|
||||
const ALERT_PATTERN = /^\[!([A-Z]+)\]\s*\n?/;
|
||||
|
||||
interface BlockParseResult {
|
||||
node: Node | null;
|
||||
newLineIndex: number;
|
||||
newNodeCount: number;
|
||||
extraNodes?: Array<Node>;
|
||||
}
|
||||
|
||||
const stringCache = new Map<string, boolean>();
|
||||
|
||||
function hasOpenInlineCode(text: string): boolean {
|
||||
if (!text.includes('`')) return false;
|
||||
|
||||
let openLength: number | null = null;
|
||||
let index = 0;
|
||||
|
||||
while (index < text.length) {
|
||||
if (text[index] !== '`') {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let runLength = 0;
|
||||
while (index + runLength < text.length && text[index + runLength] === '`') {
|
||||
runLength++;
|
||||
}
|
||||
|
||||
if (openLength === null) {
|
||||
openLength = runLength;
|
||||
} else if (runLength === openLength) {
|
||||
openLength = null;
|
||||
}
|
||||
|
||||
index += runLength;
|
||||
}
|
||||
|
||||
return openLength !== null;
|
||||
}
|
||||
|
||||
function cachedStartsWith(str: string, search: string): boolean {
|
||||
const key = `${str}:${search}:startsWith`;
|
||||
if (!stringCache.has(key)) {
|
||||
stringCache.set(key, str.startsWith(search));
|
||||
}
|
||||
return stringCache.get(key)!;
|
||||
}
|
||||
|
||||
export function parseBlock(
|
||||
_parser: Parser,
|
||||
lines: Array<string>,
|
||||
currentLineIndex: number,
|
||||
parserFlags: number,
|
||||
nodeCount: number,
|
||||
): BlockParseResult {
|
||||
if (currentLineIndex >= lines.length) {
|
||||
return {node: null, newLineIndex: currentLineIndex, newNodeCount: nodeCount};
|
||||
}
|
||||
|
||||
const line = lines[currentLineIndex];
|
||||
const trimmed = line.trimStart();
|
||||
|
||||
if (cachedStartsWith(trimmed, '>>> ')) {
|
||||
if (!(parserFlags & ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES)) {
|
||||
const result = {
|
||||
node: parseBlockAsText(lines, currentLineIndex, '>>> '),
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
return result;
|
||||
}
|
||||
const result = parseMultilineBlockquote(lines, currentLineIndex, parserFlags, nodeCount);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (cachedStartsWith(trimmed, '>')) {
|
||||
if (!(parserFlags & ParserFlags.ALLOW_BLOCKQUOTES)) {
|
||||
return {node: null, newLineIndex: currentLineIndex, newNodeCount: nodeCount};
|
||||
}
|
||||
const result = parseBlockquote(lines, currentLineIndex, parserFlags, nodeCount);
|
||||
return result;
|
||||
}
|
||||
|
||||
const listMatch = ListParsers.matchListItem(line);
|
||||
|
||||
if (listMatch) {
|
||||
const [isOrdered, indentLevel, _content] = listMatch;
|
||||
if (parserFlags & ParserFlags.ALLOW_LISTS) {
|
||||
const result = ListParsers.parseList(
|
||||
lines,
|
||||
currentLineIndex,
|
||||
isOrdered,
|
||||
indentLevel,
|
||||
1,
|
||||
parserFlags,
|
||||
nodeCount,
|
||||
(text) => InlineParsers.parseInline(text, parserFlags),
|
||||
);
|
||||
|
||||
const finalResult = {
|
||||
node: result.node,
|
||||
newLineIndex: result.newLineIndex,
|
||||
newNodeCount: result.newNodeCount,
|
||||
};
|
||||
|
||||
return finalResult;
|
||||
}
|
||||
|
||||
const result = {
|
||||
node: {type: NodeType.Text, content: line} as TextNode,
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('||') && !trimmed.slice(2).includes('||')) {
|
||||
if (parserFlags & ParserFlags.ALLOW_SPOILERS) {
|
||||
const result = parseSpoiler(lines, currentLineIndex, parserFlags);
|
||||
|
||||
const finalResult = {
|
||||
node: result.node,
|
||||
newLineIndex: result.newLineIndex,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
|
||||
return finalResult;
|
||||
}
|
||||
|
||||
const result = {
|
||||
node: {type: NodeType.Text, content: line} as TextNode,
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (parserFlags & ParserFlags.ALLOW_CODE_BLOCKS) {
|
||||
const fencePosition = line.indexOf('```');
|
||||
|
||||
if (fencePosition !== -1) {
|
||||
const startsWithFence = cachedStartsWith(trimmed, '```') && fencePosition === line.length - trimmed.length;
|
||||
if (startsWithFence) {
|
||||
const result = parseCodeBlock(lines, currentLineIndex);
|
||||
|
||||
const finalResult: BlockParseResult = {
|
||||
node: result.node,
|
||||
newLineIndex: result.newLineIndex,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
|
||||
if (result.extraContent) {
|
||||
finalResult.extraNodes = [{type: NodeType.Text, content: result.extraContent}];
|
||||
finalResult.newNodeCount = nodeCount + 2;
|
||||
}
|
||||
|
||||
return finalResult;
|
||||
}
|
||||
|
||||
const prefixText = line.slice(0, fencePosition);
|
||||
if (hasOpenInlineCode(prefixText)) {
|
||||
return {node: null, newLineIndex: currentLineIndex, newNodeCount: nodeCount};
|
||||
}
|
||||
const inlineNodes = InlineParsers.parseInline(prefixText, parserFlags);
|
||||
|
||||
const codeLines = [line.slice(fencePosition), ...lines.slice(currentLineIndex + 1)];
|
||||
const codeResult = parseCodeBlock(codeLines, 0);
|
||||
const newLineIndex = currentLineIndex + codeResult.newLineIndex;
|
||||
|
||||
const extraNodes: Array<Node> = [];
|
||||
if (inlineNodes.length > 1) {
|
||||
extraNodes.push(...inlineNodes.slice(1));
|
||||
}
|
||||
extraNodes.push(codeResult.node);
|
||||
if (codeResult.extraContent) {
|
||||
extraNodes.push({type: NodeType.Text, content: codeResult.extraContent});
|
||||
}
|
||||
|
||||
const firstNode = inlineNodes[0] ?? codeResult.node;
|
||||
const newNodeCount = nodeCount + inlineNodes.length + 1 + (codeResult.extraContent ? 1 : 0);
|
||||
|
||||
return {
|
||||
node: firstNode,
|
||||
extraNodes: extraNodes.length > 0 ? extraNodes : undefined,
|
||||
newLineIndex,
|
||||
newNodeCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!(parserFlags & ParserFlags.ALLOW_CODE_BLOCKS) && cachedStartsWith(trimmed, '```')) {
|
||||
let codeBlockText = lines[currentLineIndex];
|
||||
let endLineIndex = currentLineIndex + 1;
|
||||
|
||||
while (endLineIndex < lines.length) {
|
||||
const nextLine = lines[endLineIndex];
|
||||
|
||||
if (nextLine.trim() === '```') {
|
||||
codeBlockText += `\n${nextLine}`;
|
||||
endLineIndex++;
|
||||
break;
|
||||
}
|
||||
|
||||
codeBlockText += `\n${nextLine}`;
|
||||
endLineIndex++;
|
||||
}
|
||||
|
||||
return {
|
||||
node: {type: NodeType.Text, content: codeBlockText} as TextNode,
|
||||
newLineIndex: endLineIndex,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (cachedStartsWith(trimmed, '-#')) {
|
||||
if (parserFlags & ParserFlags.ALLOW_SUBTEXT) {
|
||||
const subtextNode = parseSubtext(trimmed, (text) => InlineParsers.parseInline(text, parserFlags));
|
||||
|
||||
if (subtextNode) {
|
||||
const result = {
|
||||
node: subtextNode,
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
node: {type: NodeType.Text, content: handleLineAsText(lines, currentLineIndex)} as TextNode,
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (cachedStartsWith(trimmed, '#')) {
|
||||
if (parserFlags & ParserFlags.ALLOW_HEADINGS) {
|
||||
const headingNode = parseHeading(trimmed, (text) => InlineParsers.parseInline(text, parserFlags));
|
||||
|
||||
if (headingNode) {
|
||||
const result = {
|
||||
node: headingNode,
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
node: {type: NodeType.Text, content: handleLineAsText(lines, currentLineIndex)} as TextNode,
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (trimmed.includes('|') && parserFlags & ParserFlags.ALLOW_TABLES) {
|
||||
const startIndex = currentLineIndex;
|
||||
|
||||
const tableResult = TableParsers.parseTable(lines, currentLineIndex, parserFlags, (text) =>
|
||||
InlineParsers.parseInline(text, parserFlags),
|
||||
);
|
||||
|
||||
if (tableResult.node) {
|
||||
const result = {
|
||||
node: tableResult.node,
|
||||
newLineIndex: tableResult.newLineIndex,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
currentLineIndex = startIndex;
|
||||
}
|
||||
|
||||
return {node: null, newLineIndex: currentLineIndex, newNodeCount: nodeCount};
|
||||
}
|
||||
|
||||
function handleLineAsText(lines: Array<string>, currentLineIndex: number): string {
|
||||
const isLastLine = currentLineIndex === lines.length - 1;
|
||||
return isLastLine ? lines[currentLineIndex] : `${lines[currentLineIndex]}\n`;
|
||||
}
|
||||
|
||||
function parseBlockAsText(lines: Array<string>, currentLineIndex: number, marker: string): TextNode {
|
||||
const originalContent = lines[currentLineIndex];
|
||||
|
||||
if (marker === '>' || marker === '>>> ') {
|
||||
return {
|
||||
type: NodeType.Text,
|
||||
content: originalContent + (currentLineIndex < lines.length - 1 ? '\n' : ''),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: NodeType.Text,
|
||||
content: originalContent,
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_HEADING_LEVEL = 4;
|
||||
|
||||
export function parseHeading(trimmed: string, parseInline: (text: string) => Array<Node>): HeadingNode | null {
|
||||
let level = 0;
|
||||
for (let i = 0; i < trimmed.length && i < MAX_HEADING_LEVEL; i++) {
|
||||
if (trimmed[i] === '#') level++;
|
||||
else break;
|
||||
}
|
||||
|
||||
if (level >= 1 && level <= MAX_HEADING_LEVEL && trimmed[level] === ' ') {
|
||||
const content = trimmed.slice(level + 1);
|
||||
const inlineNodes = parseInline(content);
|
||||
|
||||
const result: HeadingNode = {
|
||||
type: NodeType.Heading,
|
||||
level,
|
||||
children: inlineNodes,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseSubtext(trimmed: string, parseInline: (text: string) => Array<Node>): SubtextNode | null {
|
||||
if (trimmed.startsWith('-#')) {
|
||||
if ((trimmed.length > 2 && trimmed[2] !== ' ') || (trimmed.length > 3 && trimmed[3] === ' ')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = trimmed.slice(3);
|
||||
const inlineNodes = parseInline(content);
|
||||
|
||||
const result: SubtextNode = {
|
||||
type: NodeType.Subtext,
|
||||
children: inlineNodes,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseBlockquote(
|
||||
lines: Array<string>,
|
||||
currentLineIndex: number,
|
||||
parserFlags: number,
|
||||
nodeCount: number,
|
||||
): BlockParseResult {
|
||||
let blockquoteContent = '';
|
||||
const startLine = currentLineIndex;
|
||||
let newLineIndex = currentLineIndex;
|
||||
|
||||
while (newLineIndex < lines.length) {
|
||||
if (nodeCount > MAX_AST_NODES) break;
|
||||
const line = lines[newLineIndex];
|
||||
const trimmed = line.trimStart();
|
||||
|
||||
if (trimmed === '> ' || trimmed === '> ') {
|
||||
if (blockquoteContent.length > 0) blockquoteContent += '\n';
|
||||
newLineIndex++;
|
||||
} else if (trimmed.startsWith('> ')) {
|
||||
const content = trimmed.slice(2);
|
||||
if (blockquoteContent.length > 0) blockquoteContent += '\n';
|
||||
blockquoteContent += content;
|
||||
newLineIndex++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
if (blockquoteContent.length > MAX_LINE_LENGTH * 100) break;
|
||||
}
|
||||
|
||||
if (blockquoteContent === '' && newLineIndex === startLine) {
|
||||
return {node: null, newLineIndex, newNodeCount: nodeCount};
|
||||
}
|
||||
|
||||
if (parserFlags & ParserFlags.ALLOW_ALERTS) {
|
||||
const alertNode = parseAlert(blockquoteContent, parserFlags);
|
||||
|
||||
if (alertNode) {
|
||||
return {
|
||||
node: alertNode,
|
||||
newLineIndex,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const childFlags = parserFlags & ~ParserFlags.ALLOW_BLOCKQUOTES;
|
||||
const childParser = new Parser(blockquoteContent, childFlags);
|
||||
const {nodes: childNodes} = childParser.parse();
|
||||
|
||||
flattenChildren(childNodes, true);
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Blockquote,
|
||||
children: childNodes,
|
||||
},
|
||||
newLineIndex,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function parseMultilineBlockquote(
|
||||
lines: Array<string>,
|
||||
currentLineIndex: number,
|
||||
parserFlags: number,
|
||||
nodeCount: number,
|
||||
): BlockParseResult {
|
||||
const line = lines[currentLineIndex];
|
||||
const trimmed = line.trimStart();
|
||||
|
||||
if (!trimmed.startsWith('>>> ')) {
|
||||
return {
|
||||
node: {type: NodeType.Text, content: ''},
|
||||
newLineIndex: currentLineIndex,
|
||||
newNodeCount: nodeCount,
|
||||
};
|
||||
}
|
||||
|
||||
let content = trimmed.slice(4);
|
||||
let newLineIndex = currentLineIndex + 1;
|
||||
|
||||
while (newLineIndex < lines.length) {
|
||||
const current = lines[newLineIndex];
|
||||
content += `\n${current}`;
|
||||
newLineIndex++;
|
||||
if (content.length > MAX_LINE_LENGTH * 100) break;
|
||||
}
|
||||
|
||||
const childFlags = (parserFlags & ~ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES) | ParserFlags.ALLOW_BLOCKQUOTES;
|
||||
const childParser = new Parser(content, childFlags);
|
||||
const {nodes: childNodes} = childParser.parse();
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Blockquote,
|
||||
children: childNodes,
|
||||
},
|
||||
newLineIndex,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCodeBlock(
|
||||
lines: Array<string>,
|
||||
currentLineIndex: number,
|
||||
): {node: CodeBlockNode; newLineIndex: number; extraContent?: string} {
|
||||
const line = lines[currentLineIndex];
|
||||
const trimmed = line.trimStart();
|
||||
|
||||
const indentSpaces = line.length - trimmed.length;
|
||||
const listIndent = indentSpaces > 0 ? ' '.repeat(indentSpaces) : '';
|
||||
|
||||
let fenceLength = 0;
|
||||
for (let i = 0; i < trimmed.length && trimmed[i] === '`'; i++) {
|
||||
fenceLength++;
|
||||
}
|
||||
|
||||
const languagePart = trimmed.slice(fenceLength);
|
||||
|
||||
const closingFence = '`'.repeat(fenceLength);
|
||||
const closingFenceIndex = languagePart.indexOf(closingFence);
|
||||
|
||||
let language: string | undefined;
|
||||
if (closingFenceIndex !== -1) {
|
||||
const inlineContent = languagePart.slice(0, closingFenceIndex);
|
||||
const trailingInline = languagePart.slice(closingFenceIndex + fenceLength);
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.CodeBlock,
|
||||
language: undefined,
|
||||
content: inlineContent,
|
||||
},
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
extraContent: trailingInline || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
language = languagePart.trim() || undefined;
|
||||
let newLineIndex = currentLineIndex + 1;
|
||||
|
||||
let tempIndex = newLineIndex;
|
||||
let lineCount = 0;
|
||||
|
||||
while (tempIndex < lines.length) {
|
||||
const trimmedLine = lines[tempIndex].trimStart();
|
||||
if (trimmedLine.startsWith(closingFence)) {
|
||||
let backtickCount = 0;
|
||||
for (let i = 0; i < trimmedLine.length && trimmedLine[i] === '`'; i++) {
|
||||
backtickCount++;
|
||||
}
|
||||
|
||||
const charAfterBackticks = trimmedLine[backtickCount];
|
||||
if (
|
||||
backtickCount >= fenceLength &&
|
||||
(!charAfterBackticks || charAfterBackticks === ' ' || charAfterBackticks === '\t' || charAfterBackticks === '`')
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
tempIndex++;
|
||||
lineCount++;
|
||||
if (lineCount > 1000) break;
|
||||
}
|
||||
|
||||
const contentParts: Array<string> = [];
|
||||
let contentLength = 0;
|
||||
|
||||
while (newLineIndex < lines.length) {
|
||||
const current = lines[newLineIndex];
|
||||
const trimmedLine = current.trimStart();
|
||||
|
||||
const fenceIndex = trimmedLine.indexOf(closingFence);
|
||||
if (fenceIndex !== -1) {
|
||||
let backtickCount = 0;
|
||||
let idx = fenceIndex;
|
||||
while (idx < trimmedLine.length && trimmedLine[idx] === '`') {
|
||||
backtickCount++;
|
||||
idx++;
|
||||
}
|
||||
|
||||
const charAfterBackticks = trimmedLine[idx];
|
||||
const onlyWhitespaceAfter =
|
||||
!charAfterBackticks || charAfterBackticks === ' ' || charAfterBackticks === '\t' || charAfterBackticks === '`';
|
||||
|
||||
if (backtickCount >= fenceLength && onlyWhitespaceAfter) {
|
||||
const contentPrefix = current.slice(0, current.indexOf(closingFence));
|
||||
let contentLine = contentPrefix;
|
||||
if (indentSpaces > 0 && contentPrefix.startsWith(listIndent)) {
|
||||
contentLine = contentPrefix.slice(indentSpaces);
|
||||
}
|
||||
|
||||
if (contentLine.length > 0) {
|
||||
contentParts.push(contentLine);
|
||||
contentParts.push('\n');
|
||||
}
|
||||
|
||||
let extraContent: string | undefined;
|
||||
const trailingText = trimmedLine.slice(idx);
|
||||
if (trailingText) {
|
||||
extraContent = trailingText;
|
||||
} else if (backtickCount > fenceLength) {
|
||||
extraContent = trimmedLine.slice(fenceLength);
|
||||
}
|
||||
|
||||
newLineIndex++;
|
||||
|
||||
if (extraContent) {
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.CodeBlock,
|
||||
language,
|
||||
content: contentParts.join(''),
|
||||
},
|
||||
newLineIndex,
|
||||
extraContent,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let contentLine = current;
|
||||
if (indentSpaces > 0 && current.startsWith(listIndent)) {
|
||||
contentLine = current.slice(indentSpaces);
|
||||
}
|
||||
|
||||
contentParts.push(contentLine);
|
||||
contentParts.push('\n');
|
||||
contentLength += contentLine.length + 1;
|
||||
|
||||
if (contentLength > MAX_LINE_LENGTH * 100) break;
|
||||
newLineIndex++;
|
||||
}
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.CodeBlock,
|
||||
language,
|
||||
content: contentParts.join(''),
|
||||
},
|
||||
newLineIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSpoiler(
|
||||
lines: Array<string>,
|
||||
currentLineIndex: number,
|
||||
parserFlags: number,
|
||||
): {node: SpoilerNode | TextNode; newLineIndex: number} {
|
||||
const startLine = currentLineIndex;
|
||||
let foundEnd = false;
|
||||
let blockContent = '';
|
||||
let newLineIndex = currentLineIndex;
|
||||
|
||||
while (newLineIndex < lines.length) {
|
||||
const line = lines[newLineIndex];
|
||||
|
||||
if (newLineIndex === startLine) {
|
||||
const startIdx = line.indexOf('||');
|
||||
if (startIdx !== -1) {
|
||||
blockContent += line.slice(startIdx + 2);
|
||||
}
|
||||
} else {
|
||||
const endIdx = line.indexOf('||');
|
||||
if (endIdx !== -1) {
|
||||
blockContent += line.slice(0, endIdx);
|
||||
foundEnd = true;
|
||||
newLineIndex++;
|
||||
break;
|
||||
}
|
||||
blockContent += line;
|
||||
}
|
||||
|
||||
blockContent += '\n';
|
||||
newLineIndex++;
|
||||
|
||||
if (blockContent.length > MAX_LINE_LENGTH * 10) break;
|
||||
}
|
||||
|
||||
if (!foundEnd) {
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Text,
|
||||
content: `||${blockContent.trimEnd()}`,
|
||||
},
|
||||
newLineIndex,
|
||||
};
|
||||
}
|
||||
|
||||
const childParser = new Parser(blockContent.trim(), parserFlags);
|
||||
const {nodes: innerNodes} = childParser.parse();
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Spoiler,
|
||||
children: innerNodes,
|
||||
isBlock: true,
|
||||
},
|
||||
newLineIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function parseAlert(blockquoteText: string, parserFlags: number): AlertNode | null {
|
||||
const alertMatch = blockquoteText.match(ALERT_PATTERN);
|
||||
if (!alertMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const alertTypeStr = alertMatch[1].toUpperCase();
|
||||
let alertType: AlertType;
|
||||
|
||||
switch (alertTypeStr) {
|
||||
case 'NOTE':
|
||||
alertType = AlertType.Note;
|
||||
break;
|
||||
case 'TIP':
|
||||
alertType = AlertType.Tip;
|
||||
break;
|
||||
case 'IMPORTANT':
|
||||
alertType = AlertType.Important;
|
||||
break;
|
||||
case 'WARNING':
|
||||
alertType = AlertType.Warning;
|
||||
break;
|
||||
case 'CAUTION':
|
||||
alertType = AlertType.Caution;
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = blockquoteText.slice(alertMatch[0].length);
|
||||
|
||||
const childFlags =
|
||||
(parserFlags & ~ParserFlags.ALLOW_BLOCKQUOTES) | ParserFlags.ALLOW_LISTS | ParserFlags.ALLOW_HEADINGS;
|
||||
|
||||
const lines = content.split('\n');
|
||||
const processedLines = lines.map((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('-') || /^\d+\./.test(trimmed)) {
|
||||
return line;
|
||||
}
|
||||
return trimmed;
|
||||
});
|
||||
|
||||
const processedContent = processedLines
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
const childParser = new Parser(processedContent, childFlags);
|
||||
const {nodes: childNodes} = childParser.parse();
|
||||
|
||||
const mergedNodes: Array<Node> = [];
|
||||
let currentText = '';
|
||||
|
||||
for (const node of childNodes) {
|
||||
if (node.type === NodeType.Text) {
|
||||
if (currentText) {
|
||||
currentText += node.content;
|
||||
} else {
|
||||
currentText = node.content;
|
||||
}
|
||||
} else {
|
||||
if (currentText) {
|
||||
mergedNodes.push({type: NodeType.Text, content: currentText});
|
||||
currentText = '';
|
||||
}
|
||||
mergedNodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentText) {
|
||||
mergedNodes.push({type: NodeType.Text, content: currentText});
|
||||
}
|
||||
|
||||
const finalNodes = postProcessAlertNodes(mergedNodes);
|
||||
|
||||
return {
|
||||
type: NodeType.Alert,
|
||||
alertType,
|
||||
children: finalNodes,
|
||||
};
|
||||
}
|
||||
|
||||
function postProcessAlertNodes(nodes: Array<Node>): Array<Node> {
|
||||
if (nodes.length <= 1) return nodes;
|
||||
|
||||
const result: Array<Node> = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < nodes.length) {
|
||||
const node = nodes[i];
|
||||
|
||||
if (node.type === NodeType.Text && i + 1 < nodes.length) {
|
||||
if (nodes[i + 1].type === NodeType.List) {
|
||||
const trimmedContent = node.content.replace(/\s+$/, '\n');
|
||||
if (trimmedContent) {
|
||||
result.push({type: NodeType.Text, content: trimmedContent});
|
||||
}
|
||||
} else {
|
||||
result.push(node);
|
||||
}
|
||||
} else if (node.type === NodeType.List && i + 1 < nodes.length) {
|
||||
result.push(node);
|
||||
|
||||
const nextNode = nodes[i + 1];
|
||||
if (nextNode.type === NodeType.Text) {
|
||||
const content = nextNode.content.trim();
|
||||
if (content) {
|
||||
result.push({type: NodeType.Text, content: `\n${content}`});
|
||||
i++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.push(node);
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
666
fluxer_app/src/lib/markdown/parser/parsers/emoji-parsers.test.ts
Normal file
666
fluxer_app/src/lib/markdown/parser/parsers/emoji-parsers.test.ts
Normal file
@@ -0,0 +1,666 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {describe, expect, test} from 'vitest';
|
||||
import {Parser} from '../parser/parser';
|
||||
import {EmojiKind, NodeType, ParserFlags} from '../types/enums';
|
||||
|
||||
describe('Fluxer Markdown Parser', () => {
|
||||
test('standard emoji', () => {
|
||||
const input = 'Hello 🦶 World!';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Hello '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '🦶',
|
||||
codepoints: '1f9b6',
|
||||
name: expect.any(String),
|
||||
},
|
||||
},
|
||||
{type: NodeType.Text, content: ' World!'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('custom emoji static', () => {
|
||||
const input = 'Check this <:mmLol:216154654256398347> emoji!';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Check this '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Custom,
|
||||
name: 'mmLol',
|
||||
id: '216154654256398347',
|
||||
animated: false,
|
||||
},
|
||||
},
|
||||
{type: NodeType.Text, content: ' emoji!'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('custom emoji animated', () => {
|
||||
const input = 'Animated: <a:b1nzy:392938283556143104>';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Animated: '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Custom,
|
||||
name: 'b1nzy',
|
||||
id: '392938283556143104',
|
||||
animated: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('generate codepoints with vs16 and zwj', () => {
|
||||
const input = '👨👩👧👦❤️😊';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '👨👩👧👦',
|
||||
codepoints: '1f468-200d-1f469-200d-1f467-200d-1f466',
|
||||
name: 'family_mwgb',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '❤️',
|
||||
codepoints: '2764',
|
||||
name: 'heart',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '😊',
|
||||
codepoints: '1f60a',
|
||||
name: 'blush',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('multiple consecutive emojis', () => {
|
||||
const input = '😀😃😄😁';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '😀',
|
||||
codepoints: '1f600',
|
||||
name: expect.any(String),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '😃',
|
||||
codepoints: '1f603',
|
||||
name: expect.any(String),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '😄',
|
||||
codepoints: '1f604',
|
||||
name: expect.any(String),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '😁',
|
||||
codepoints: '1f601',
|
||||
name: expect.any(String),
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('special plaintext symbols should be rendered as text', () => {
|
||||
const input = '™ ™️ © ©️ ® ®️';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '™ ™ © © ® ®'}]);
|
||||
});
|
||||
|
||||
test('copyright shortcode converts to text symbol', () => {
|
||||
const input = ':copyright: normal text';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '© normal text'}]);
|
||||
});
|
||||
|
||||
test('trademark shortcode converts to text symbol', () => {
|
||||
const input = ':tm: normal text';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '™ normal text'}]);
|
||||
});
|
||||
|
||||
test('registered shortcode converts to text symbol', () => {
|
||||
const input = ':registered: normal text';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '® normal text'}]);
|
||||
});
|
||||
|
||||
test('mixed shortcodes with regular emojis', () => {
|
||||
const input = ':copyright: and :smile: with :registered:';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: '© and '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '😄',
|
||||
codepoints: '1f604',
|
||||
name: 'smile',
|
||||
},
|
||||
},
|
||||
{type: NodeType.Text, content: ' with ®'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('shortcodes in formatted text', () => {
|
||||
const input = '**Bold :tm:** *Italic :copyright:* __:registered:__';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [{type: NodeType.Text, content: 'Bold ™'}],
|
||||
},
|
||||
{type: NodeType.Text, content: ' '},
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [{type: NodeType.Text, content: 'Italic ©'}],
|
||||
},
|
||||
{type: NodeType.Text, content: ' '},
|
||||
{
|
||||
type: NodeType.Underline,
|
||||
children: [{type: NodeType.Text, content: '®'}],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('emoji data loaded', () => {
|
||||
const input = ':smile: :wave: :heart:';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBeGreaterThan(0);
|
||||
expect(ast.some((node) => node.type === NodeType.Emoji)).toBe(true);
|
||||
});
|
||||
|
||||
test('emoji cache initialization', () => {
|
||||
const input = ':smile: :face_holding_back_tears: :face-holding-back-tears:';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '😄',
|
||||
codepoints: '1f604',
|
||||
name: 'smile',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: NodeType.Text,
|
||||
content: ' ',
|
||||
},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '🥹',
|
||||
codepoints: '1f979',
|
||||
name: 'face_holding_back_tears',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: NodeType.Text,
|
||||
content: ' :face-holding-back-tears:',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('case sensitive emoji lookup', () => {
|
||||
const validVariants = [':smile:', ':face_holding_back_tears:'];
|
||||
|
||||
for (const emoji of validVariants) {
|
||||
const parser = new Parser(emoji, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: expect.any(String),
|
||||
codepoints: expect.any(String),
|
||||
name: expect.any(String),
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid case emoji lookup', () => {
|
||||
const invalidVariants = [
|
||||
':SMILE:',
|
||||
':Smile:',
|
||||
':FACE_HOLDING_BACK_TEARS:',
|
||||
':Face_Holding_Back_Tears:',
|
||||
':face-holding-back-tears:',
|
||||
];
|
||||
|
||||
for (const emoji of invalidVariants) {
|
||||
const parser = new Parser(emoji, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: emoji}]);
|
||||
}
|
||||
});
|
||||
|
||||
test('separator variants', () => {
|
||||
const input = ':face_holding_back_tears: :face-holding-back-tears:';
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '🥹',
|
||||
codepoints: '1f979',
|
||||
name: 'face_holding_back_tears',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: NodeType.Text,
|
||||
content: ' :face-holding-back-tears:',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('basic emoji shortcode', () => {
|
||||
const input = 'Hello :face_holding_back_tears: world!';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Hello '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: expect.any(String),
|
||||
codepoints: expect.any(String),
|
||||
name: 'face_holding_back_tears',
|
||||
},
|
||||
},
|
||||
{type: NodeType.Text, content: ' world!'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('emoji shortcode in code', () => {
|
||||
const input = "`print(':face_holding_back_tears:')`";
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.InlineCode, content: "print(':face_holding_back_tears:')"}]);
|
||||
});
|
||||
|
||||
test('emoji shortcode in code block', () => {
|
||||
const input = '```\n:face_holding_back_tears:\n```';
|
||||
const flags = ParserFlags.ALLOW_CODE_BLOCKS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.CodeBlock,
|
||||
language: undefined,
|
||||
content: ':face_holding_back_tears:\n',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('distinguishes between plaintext and emoji versions with variation selectors', () => {
|
||||
const inputs = [
|
||||
{text: '↩', shouldBeEmoji: false},
|
||||
{text: '↩️', shouldBeEmoji: true},
|
||||
{text: '↪', shouldBeEmoji: false},
|
||||
{text: '↪️', shouldBeEmoji: true},
|
||||
{text: '⤴', shouldBeEmoji: false},
|
||||
{text: '⤴️', shouldBeEmoji: true},
|
||||
];
|
||||
|
||||
for (const {text, shouldBeEmoji} of inputs) {
|
||||
const parser = new Parser(text, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
if (shouldBeEmoji) {
|
||||
expect(ast[0].type).toBe(NodeType.Emoji);
|
||||
expect((ast[0] as any).kind.kind).toBe(EmojiKind.Standard);
|
||||
expect((ast[0] as any).kind.name).not.toBe('');
|
||||
} else {
|
||||
expect(ast[0].type).toBe(NodeType.Text);
|
||||
expect((ast[0] as any).content).toBe(text);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('renders mixed text with both plaintext and emoji versions', () => {
|
||||
const input = '↩ is plaintext, ↩️ is emoji';
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: '↩ is plaintext, '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '↩️',
|
||||
codepoints: '21a9',
|
||||
name: 'leftwards_arrow_with_hook',
|
||||
},
|
||||
},
|
||||
{type: NodeType.Text, content: ' is emoji'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('correctly parses dingbat emojis', () => {
|
||||
const inputs = [
|
||||
{emoji: '✅', name: 'white_check_mark', codepoint: '2705'},
|
||||
{emoji: '❌', name: 'x', codepoint: '274c'},
|
||||
];
|
||||
|
||||
for (const {emoji, name, codepoint} of inputs) {
|
||||
const parser = new Parser(emoji, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: emoji,
|
||||
codepoints: codepoint,
|
||||
name,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('dingbat emojis in text context', () => {
|
||||
const input = 'Task complete ✅ but error ❌';
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Task complete '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '✅',
|
||||
codepoints: '2705',
|
||||
name: 'white_check_mark',
|
||||
},
|
||||
},
|
||||
{type: NodeType.Text, content: ' but error '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '❌',
|
||||
codepoints: '274c',
|
||||
name: 'x',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('malformed custom emoji edge cases', () => {
|
||||
const malformedCases = [
|
||||
'<:ab>',
|
||||
'<:abc>',
|
||||
'<:name:>',
|
||||
'<:name:abc>',
|
||||
'<:name:123abc>',
|
||||
'<:name:12ab34>',
|
||||
'<::123>',
|
||||
];
|
||||
|
||||
for (const input of malformedCases) {
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Text,
|
||||
content: input,
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('empty custom emoji cases', () => {
|
||||
const emptyCases = ['<::>', '<:name:>', '<::123>'];
|
||||
|
||||
for (const input of emptyCases) {
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Text,
|
||||
content: input,
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('custom emoji with invalid ID characters', () => {
|
||||
const invalidIdCases = ['<:test:123a>', '<:name:12b34>', '<:emoji:abc123>', '<:custom:123-456>', '<:sample:12_34>'];
|
||||
|
||||
for (const input of invalidIdCases) {
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Text,
|
||||
content: input,
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('custom emoji with edge case names and IDs', () => {
|
||||
const edgeCases = ['<::123>', '<: :123>'];
|
||||
|
||||
for (const input of edgeCases) {
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Text,
|
||||
content: input,
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Special Symbols Plaintext Rendering', () => {
|
||||
test('trademark symbol should render as text without variation selector', () => {
|
||||
const inputs = ['™', '™️'];
|
||||
|
||||
for (const input of inputs) {
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '™'}]);
|
||||
}
|
||||
});
|
||||
|
||||
test('copyright symbol should render as text without variation selector', () => {
|
||||
const inputs = ['©', '©️'];
|
||||
|
||||
for (const input of inputs) {
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '©'}]);
|
||||
}
|
||||
});
|
||||
|
||||
test('registered symbol should render as text without variation selector', () => {
|
||||
const inputs = ['®', '®️'];
|
||||
|
||||
for (const input of inputs) {
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '®'}]);
|
||||
}
|
||||
});
|
||||
|
||||
test('mixed emoji and special symbols', () => {
|
||||
const input = '™️ ©️ ®️ 👍 ❤️';
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: '™ © ® '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: 'Standard',
|
||||
raw: '👍',
|
||||
codepoints: '1f44d',
|
||||
name: 'thumbsup',
|
||||
},
|
||||
},
|
||||
{type: NodeType.Text, content: ' '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: 'Standard',
|
||||
raw: '❤️',
|
||||
codepoints: '2764',
|
||||
name: 'heart',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('special symbols in formatted text', () => {
|
||||
const input = '**™️** *©️* __®️__';
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [{type: NodeType.Text, content: '™'}],
|
||||
},
|
||||
{type: NodeType.Text, content: ' '},
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [{type: NodeType.Text, content: '©'}],
|
||||
},
|
||||
{type: NodeType.Text, content: ' '},
|
||||
{
|
||||
type: NodeType.Underline,
|
||||
children: [{type: NodeType.Text, content: '®'}],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('special symbols interspersed with text', () => {
|
||||
const input = 'This product™️ is copyright©️ and registered®️';
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: 'This product™ is copyright© and registered®'}]);
|
||||
});
|
||||
});
|
||||
336
fluxer_app/src/lib/markdown/parser/parsers/emoji-parsers.ts
Normal file
336
fluxer_app/src/lib/markdown/parser/parsers/emoji-parsers.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import emojiRegex from 'emoji-regex';
|
||||
import {SKIN_TONE_SURROGATES} from '~/Constants';
|
||||
import UnicodeEmojis from '~/lib/UnicodeEmojis';
|
||||
import * as EmojiUtils from '~/utils/EmojiUtils';
|
||||
import {EmojiKind, NodeType} from '../types/enums';
|
||||
import type {ParserResult} from '../types/nodes';
|
||||
|
||||
const VALID_EMOJI_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
|
||||
const CUSTOM_EMOJI_REGEX = /^<(a)?:([a-zA-Z0-9_-]+):(\d+)>/;
|
||||
|
||||
const PLAINTEXT_SYMBOLS = new Set(['™', '™️', '©', '©️', '®', '®️']);
|
||||
|
||||
const TEXT_PRESENTATION_MAP: Record<string, string> = {
|
||||
'™️': '™',
|
||||
'©️': '©',
|
||||
'®️': '®',
|
||||
};
|
||||
|
||||
const NEEDS_VARIATION_SELECTOR_CACHE = new Map<number, boolean>();
|
||||
|
||||
const SPECIAL_SHORTCODES: Record<string, string> = {
|
||||
tm: '™',
|
||||
copyright: '©',
|
||||
registered: '®',
|
||||
};
|
||||
|
||||
const EMOJI_NAME_CACHE = new Map<string, string | null>();
|
||||
|
||||
const EMOJI_BY_NAME_CACHE = new Map<string, import('~/lib/UnicodeEmojis').UnicodeEmoji | null>();
|
||||
|
||||
const EMOJI_REGEXP = emojiRegex();
|
||||
|
||||
function needsVariationSelector(codePoint: number): boolean {
|
||||
if (NEEDS_VARIATION_SELECTOR_CACHE.has(codePoint)) {
|
||||
return NEEDS_VARIATION_SELECTOR_CACHE.get(codePoint)!;
|
||||
}
|
||||
|
||||
const result =
|
||||
(codePoint >= 0x2190 && codePoint <= 0x21ff) ||
|
||||
(codePoint >= 0x2300 && codePoint <= 0x23ff) ||
|
||||
(codePoint >= 0x2600 && codePoint <= 0x27bf) ||
|
||||
(codePoint >= 0x2900 && codePoint <= 0x297f);
|
||||
|
||||
NEEDS_VARIATION_SELECTOR_CACHE.set(codePoint, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function removeVariationSelectors(text: string): string {
|
||||
if (text.length < 2 || text.indexOf('\uFE0F') === -1) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let result = '';
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
if (text.charCodeAt(i) === 0x2122 && i + 1 < text.length && text.charCodeAt(i + 1) === 0xfe0f) {
|
||||
result += '™';
|
||||
i += 2;
|
||||
} else if (text.charCodeAt(i) === 0xa9 && i + 1 < text.length && text.charCodeAt(i + 1) === 0xfe0f) {
|
||||
result += '©';
|
||||
i += 2;
|
||||
} else if (text.charCodeAt(i) === 0xae && i + 1 < text.length && text.charCodeAt(i + 1) === 0xfe0f) {
|
||||
result += '®';
|
||||
i += 2;
|
||||
} else {
|
||||
result += text.charAt(i);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseStandardEmoji(text: string, start: number): ParserResult | null {
|
||||
if (!text || start >= text.length || text.length - start < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstChar = text.charAt(start);
|
||||
if (PLAINTEXT_SYMBOLS.has(firstChar)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstCharCode = text.charCodeAt(start);
|
||||
if (
|
||||
firstCharCode < 0x80 &&
|
||||
firstCharCode !== 0x23 &&
|
||||
firstCharCode !== 0x2a &&
|
||||
firstCharCode !== 0x30 &&
|
||||
firstCharCode !== 0x31 &&
|
||||
(firstCharCode < 0x32 || firstCharCode > 0x39)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
EMOJI_REGEXP.lastIndex = 0;
|
||||
const match = EMOJI_REGEXP.exec(text.slice(start));
|
||||
|
||||
if (match && match.index === 0) {
|
||||
const candidate = match[0];
|
||||
|
||||
if (PLAINTEXT_SYMBOLS.has(candidate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (PLAINTEXT_SYMBOLS.has(candidate)) {
|
||||
const textPresentation = TEXT_PRESENTATION_MAP[candidate];
|
||||
if (textPresentation) {
|
||||
return {
|
||||
node: {type: NodeType.Text, content: textPresentation},
|
||||
advance: candidate.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
node: {type: NodeType.Text, content: candidate},
|
||||
advance: candidate.length,
|
||||
};
|
||||
}
|
||||
|
||||
const hasVariationSelector = candidate.indexOf('\uFE0F') !== -1;
|
||||
const codePoint = candidate.codePointAt(0) || 0;
|
||||
|
||||
const isDingbat = codePoint >= 0x2600 && codePoint <= 0x27bf;
|
||||
if (!isDingbat && needsVariationSelector(codePoint) && !hasVariationSelector) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let name = EMOJI_NAME_CACHE.get(candidate);
|
||||
if (name === undefined) {
|
||||
name = UnicodeEmojis.getSurrogateName(candidate);
|
||||
|
||||
EMOJI_NAME_CACHE.set(candidate, name);
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const codepoints = EmojiUtils.convertToCodePoints(candidate);
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: candidate,
|
||||
codepoints,
|
||||
name,
|
||||
},
|
||||
},
|
||||
advance: candidate.length,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseEmojiShortcode(text: string): ParserResult | null {
|
||||
if (!text.startsWith(':') || text.length < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const endPos = text.indexOf(':', 1);
|
||||
if (endPos === -1 || endPos === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fullName = text.substring(1, endPos);
|
||||
|
||||
const specialSymbol = SPECIAL_SHORTCODES[fullName];
|
||||
if (specialSymbol) {
|
||||
return {
|
||||
node: {type: NodeType.Text, content: specialSymbol},
|
||||
advance: endPos + 1,
|
||||
};
|
||||
}
|
||||
|
||||
let baseName: string;
|
||||
let skinTone: number | undefined;
|
||||
|
||||
const skinToneIndex = fullName.indexOf('::skin-tone-');
|
||||
if (skinToneIndex !== -1 && skinToneIndex + 12 < fullName.length) {
|
||||
const toneChar = fullName.charAt(skinToneIndex + 12);
|
||||
const tone = Number.parseInt(toneChar, 10);
|
||||
if (tone >= 1 && tone <= 5) {
|
||||
baseName = fullName.substring(0, skinToneIndex);
|
||||
skinTone = tone;
|
||||
} else {
|
||||
baseName = fullName;
|
||||
}
|
||||
} else {
|
||||
baseName = fullName;
|
||||
}
|
||||
|
||||
if (!baseName || !VALID_EMOJI_NAME_REGEX.test(baseName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let emoji = EMOJI_BY_NAME_CACHE.get(baseName);
|
||||
if (emoji === undefined) {
|
||||
emoji = UnicodeEmojis.findEmojiByName(baseName);
|
||||
|
||||
EMOJI_BY_NAME_CACHE.set(baseName, emoji);
|
||||
}
|
||||
|
||||
if (!emoji) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const emojiSurrogate = emoji.surrogates;
|
||||
if (PLAINTEXT_SYMBOLS.has(emojiSurrogate)) {
|
||||
const textPresentation = TEXT_PRESENTATION_MAP[emojiSurrogate];
|
||||
if (textPresentation) {
|
||||
return {
|
||||
node: {type: NodeType.Text, content: textPresentation},
|
||||
advance: endPos + 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
node: {type: NodeType.Text, content: emojiSurrogate},
|
||||
advance: endPos + 1,
|
||||
};
|
||||
}
|
||||
|
||||
let finalEmoji = emoji;
|
||||
if (skinTone !== undefined) {
|
||||
const skinToneKey = `${baseName}:tone-${skinTone}`;
|
||||
let skinToneEmoji = EMOJI_BY_NAME_CACHE.get(skinToneKey);
|
||||
|
||||
if (skinToneEmoji === undefined) {
|
||||
const skinToneSurrogate = SKIN_TONE_SURROGATES[skinTone - 1];
|
||||
skinToneEmoji = UnicodeEmojis.findEmojiWithSkinTone(baseName, skinToneSurrogate);
|
||||
EMOJI_BY_NAME_CACHE.set(skinToneKey, skinToneEmoji);
|
||||
}
|
||||
|
||||
if (skinToneEmoji) {
|
||||
finalEmoji = skinToneEmoji;
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalEmoji) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const codepoints = EmojiUtils.convertToCodePoints(finalEmoji.surrogates);
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: finalEmoji.surrogates,
|
||||
codepoints,
|
||||
name: baseName,
|
||||
},
|
||||
},
|
||||
advance: endPos + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCustomEmoji(text: string): ParserResult | null {
|
||||
if (!(text.startsWith('<:') || text.startsWith('<a:'))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastIdx = text.indexOf('>');
|
||||
if (lastIdx === -1 || lastIdx < 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = CUSTOM_EMOJI_REGEX.exec(text);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const animated = Boolean(match[1]);
|
||||
const name = match[2];
|
||||
const id = match[3];
|
||||
const advance = match[0].length;
|
||||
|
||||
if (!name || !id || id.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
const charCode = id.charCodeAt(i);
|
||||
if (charCode < 48 || charCode > 57) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Custom,
|
||||
name,
|
||||
id,
|
||||
animated,
|
||||
},
|
||||
},
|
||||
advance,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyTextPresentation(node: any): void {
|
||||
if (node && node.type === NodeType.Text && typeof node.content === 'string') {
|
||||
if (node.content.indexOf('\uFE0F') !== -1) {
|
||||
node.content = removeVariationSelectors(node.content);
|
||||
}
|
||||
} else if (node && Array.isArray(node.children)) {
|
||||
for (const child of node.children) {
|
||||
applyTextPresentation(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {describe, expect, test} from 'vitest';
|
||||
import {Parser} from '../parser/parser';
|
||||
import {NodeType, ParserFlags} from '../types/enums';
|
||||
import type {TextNode} from '../types/nodes';
|
||||
|
||||
describe('Fluxer Markdown Parser', () => {
|
||||
test('inline nodes', () => {
|
||||
const input =
|
||||
'This is **strong**, *emphasis*, __underline__, ~~strikethrough~~, `inline code`, and a [link](https://example.com). Also, visit https://rust-lang.org.';
|
||||
const flags = ParserFlags.ALLOW_MASKED_LINKS | ParserFlags.ALLOW_AUTOLINKS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'This is '},
|
||||
{type: NodeType.Strong, children: [{type: NodeType.Text, content: 'strong'}]},
|
||||
{type: NodeType.Text, content: ', '},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'emphasis'}]},
|
||||
{type: NodeType.Text, content: ', '},
|
||||
{type: NodeType.Underline, children: [{type: NodeType.Text, content: 'underline'}]},
|
||||
{type: NodeType.Text, content: ', '},
|
||||
{type: NodeType.Strikethrough, children: [{type: NodeType.Text, content: 'strikethrough'}]},
|
||||
{type: NodeType.Text, content: ', '},
|
||||
{type: NodeType.InlineCode, content: 'inline code'},
|
||||
{type: NodeType.Text, content: ', and a '},
|
||||
{
|
||||
type: NodeType.Link,
|
||||
text: {type: NodeType.Text, content: 'link'},
|
||||
url: 'https://example.com/',
|
||||
escaped: false,
|
||||
},
|
||||
{type: NodeType.Text, content: '. Also, visit '},
|
||||
{
|
||||
type: NodeType.Link,
|
||||
text: undefined,
|
||||
url: 'https://rust-lang.org/',
|
||||
escaped: false,
|
||||
},
|
||||
{type: NodeType.Text, content: '.'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('incomplete formatting', () => {
|
||||
const input = '**incomplete strong *incomplete emphasis `incomplete code';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: '*'},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'incomplete strong '}]},
|
||||
{type: NodeType.Text, content: 'incomplete emphasis `incomplete code'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('underscore emphasis', () => {
|
||||
const input = 'This is _emphasized_ and *also emphasized*';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'This is '},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'emphasized'}]},
|
||||
{type: NodeType.Text, content: ' and '},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'also emphasized'}]},
|
||||
]);
|
||||
});
|
||||
|
||||
test('alternate delimiters', () => {
|
||||
const input = '__underscore *asterisk* mix__ and _single *with* under_';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Underline,
|
||||
children: [
|
||||
{type: NodeType.Text, content: 'underscore '},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'asterisk'}]},
|
||||
{type: NodeType.Text, content: ' mix'},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: ' and '},
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [{type: NodeType.Text, content: 'single with under'}],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('inline spoiler', () => {
|
||||
const input = 'This is a ||secret|| message';
|
||||
const flags = ParserFlags.ALLOW_SPOILERS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'This is a '},
|
||||
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: 'secret'}]},
|
||||
{type: NodeType.Text, content: ' message'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('formatted spoiler', () => {
|
||||
const input = '||This is *emphasized* and **strong**||';
|
||||
const flags = ParserFlags.ALLOW_MASKED_LINKS | ParserFlags.ALLOW_SPOILERS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Spoiler,
|
||||
isBlock: false,
|
||||
children: [
|
||||
{type: NodeType.Text, content: 'This is '},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'emphasized'}]},
|
||||
{type: NodeType.Text, content: ' and '},
|
||||
{type: NodeType.Strong, children: [{type: NodeType.Text, content: 'strong'}]},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('adjacent spoilers', () => {
|
||||
const input = '||a|| ||b|| ||c||';
|
||||
const flags = ParserFlags.ALLOW_SPOILERS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: 'a'}]},
|
||||
{type: NodeType.Text, content: ' '},
|
||||
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: 'b'}]},
|
||||
{type: NodeType.Text, content: ' '},
|
||||
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: 'c'}]},
|
||||
]);
|
||||
});
|
||||
|
||||
test('unclosed spoiler', () => {
|
||||
const input = '||This spoiler never ends';
|
||||
const flags = ParserFlags.ALLOW_SPOILERS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '||This spoiler never ends'}]);
|
||||
});
|
||||
|
||||
test('consecutive pipes should create spoilers with pipe content', () => {
|
||||
const input = '|||||||||||||||';
|
||||
const flags = ParserFlags.ALLOW_SPOILERS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: '|'}]},
|
||||
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: '|'}]},
|
||||
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: '|'}]},
|
||||
]);
|
||||
|
||||
const fivePipes = '|||||';
|
||||
const fiveParser = new Parser(fivePipes, ParserFlags.ALLOW_SPOILERS);
|
||||
const {nodes: fiveAst} = fiveParser.parse();
|
||||
expect(fiveAst).toEqual([
|
||||
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: '|'}]},
|
||||
]);
|
||||
|
||||
const sixPipes = '||||||';
|
||||
const sixParser = new Parser(sixPipes, ParserFlags.ALLOW_SPOILERS);
|
||||
const {nodes: sixAst} = sixParser.parse();
|
||||
expect(sixAst).toEqual([
|
||||
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: '|'}]},
|
||||
{type: NodeType.Text, content: '|'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('bold italics', () => {
|
||||
const input = '***bolditalics***';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [{type: NodeType.Text, content: 'bolditalics'}],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('complex nested formatting combinations', () => {
|
||||
const input =
|
||||
'***__bold italic underline__***\n**_bold and italic_**\n__***underline, bold, italic***__\n**_nested __underline inside italic__ text_**';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Underline,
|
||||
children: [{type: NodeType.Text, content: 'bold italic underline'}],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [{type: NodeType.Text, content: 'bold and italic'}],
|
||||
},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{
|
||||
type: NodeType.Underline,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [{type: NodeType.Text, content: 'underline, bold, italic'}],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [
|
||||
{type: NodeType.Text, content: 'nested '},
|
||||
{
|
||||
type: NodeType.Underline,
|
||||
children: [{type: NodeType.Text, content: 'underline inside italic'}],
|
||||
},
|
||||
{type: NodeType.Text, content: ' text'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('spoiler with various formatting combinations', () => {
|
||||
const input =
|
||||
'||**spoiler bold**||\n**||bold spoiler||**\n_||italic spoiler||_\n`||spoiler code||`\n||`spoiler code inside spoiler`||';
|
||||
const flags = ParserFlags.ALLOW_SPOILERS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Spoiler,
|
||||
isBlock: false,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [{type: NodeType.Text, content: 'spoiler bold'}],
|
||||
},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Spoiler,
|
||||
isBlock: false,
|
||||
children: [{type: NodeType.Text, content: 'bold spoiler'}],
|
||||
},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Spoiler,
|
||||
isBlock: false,
|
||||
children: [{type: NodeType.Text, content: 'italic spoiler'}],
|
||||
},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{type: NodeType.InlineCode, content: '||spoiler code||'},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{
|
||||
type: NodeType.Spoiler,
|
||||
isBlock: false,
|
||||
children: [{type: NodeType.InlineCode, content: 'spoiler code inside spoiler'}],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('spoiler with broken nesting and mixed formatting', () => {
|
||||
const input =
|
||||
'||**spoiler bold**||\n**||bold spoiler||**\n_||italic spoiler||_\n`||spoiler code||`\n||`spoiler code inside spoiler`||\n||_**mixed || nesting madness**_||';
|
||||
const flags = ParserFlags.ALLOW_SPOILERS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Spoiler,
|
||||
isBlock: false,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [{type: NodeType.Text, content: 'spoiler bold'}],
|
||||
},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Spoiler,
|
||||
isBlock: false,
|
||||
children: [{type: NodeType.Text, content: 'bold spoiler'}],
|
||||
},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Spoiler,
|
||||
isBlock: false,
|
||||
children: [{type: NodeType.Text, content: 'italic spoiler'}],
|
||||
},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{type: NodeType.InlineCode, content: '||spoiler code||'},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{
|
||||
type: NodeType.Spoiler,
|
||||
isBlock: false,
|
||||
children: [{type: NodeType.InlineCode, content: 'spoiler code inside spoiler'}],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{type: NodeType.Spoiler, isBlock: false, children: [{type: NodeType.Text, content: '_**mixed '}]},
|
||||
{type: NodeType.Text, content: ' nesting madness**_||'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('text node splitting', () => {
|
||||
const input = 'This is a link: [Rust](https://www.rust-lang.org).';
|
||||
const flags = ParserFlags.ALLOW_MASKED_LINKS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'This is a link: '},
|
||||
{
|
||||
type: NodeType.Link,
|
||||
text: {type: NodeType.Text, content: 'Rust'},
|
||||
url: 'https://www.rust-lang.org/',
|
||||
escaped: false,
|
||||
},
|
||||
{type: NodeType.Text, content: '.'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('newline text handling', () => {
|
||||
const input = 'First line.\nSecond line with `code`.\nThird line.';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'First line.\nSecond line with '},
|
||||
{type: NodeType.InlineCode, content: 'code'},
|
||||
{type: NodeType.Text, content: '.\nThird line.'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('underscore emphasis with constants', () => {
|
||||
const input = 'THIS_IS_A_CONSTANT THIS _IS_ A_CONSTANT THIS _IS_. A CONSTANT';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'THIS_IS_A_CONSTANT THIS '},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'IS'}]},
|
||||
{type: NodeType.Text, content: ' A_CONSTANT THIS '},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'IS'}]},
|
||||
{type: NodeType.Text, content: '. A CONSTANT'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('incomplete formatting in code', () => {
|
||||
const input = '`function() { /* ** {{ __unclosed__ }} ** */ }`';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.InlineCode, content: 'function() { /* ** {{ __unclosed__ }} ** */ }'}]);
|
||||
});
|
||||
|
||||
test('link with inline code', () => {
|
||||
const input = '[`f38932b`](https://github.com/test/test/commit/f38932ba169e863c6693d0edf3d1d1b10609cf13)';
|
||||
const flags = ParserFlags.ALLOW_MASKED_LINKS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Link,
|
||||
text: {type: NodeType.InlineCode, content: 'f38932b'},
|
||||
url: 'https://github.com/test/test/commit/f38932ba169e863c6693d0edf3d1d1b10609cf13',
|
||||
escaped: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('link with all inline formatting', () => {
|
||||
const input = '[**Bold**, *Italic*, ~~Strikethrough~~, and `Code`](https://example.com)';
|
||||
const flags = ParserFlags.ALLOW_MASKED_LINKS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Link,
|
||||
text: {
|
||||
type: NodeType.Sequence,
|
||||
children: [
|
||||
{type: NodeType.Strong, children: [{type: NodeType.Text, content: 'Bold'}]},
|
||||
{type: NodeType.Text, content: ', '},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'Italic'}]},
|
||||
{type: NodeType.Text, content: ', '},
|
||||
{type: NodeType.Strikethrough, children: [{type: NodeType.Text, content: 'Strikethrough'}]},
|
||||
{type: NodeType.Text, content: ', and '},
|
||||
{type: NodeType.InlineCode, content: 'Code'},
|
||||
],
|
||||
},
|
||||
url: 'https://example.com/',
|
||||
escaped: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('shrug emoticon should preserve backslash before underscore', () => {
|
||||
const input = 'Check out this shrug: ¯\\_(ツ)_/¯';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: 'Check out this shrug: ¯\\_(ツ)_/¯'}]);
|
||||
|
||||
expect((ast[0] as TextNode).content).toContain('¯\\_(');
|
||||
});
|
||||
|
||||
test('regular escaped underscore should be handled correctly', () => {
|
||||
const input = 'This is not \\_emphasized\\_ text';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: 'This is not _emphasized_ text'}]);
|
||||
});
|
||||
|
||||
describe('Edge cases and complex nesting', () => {
|
||||
test('double-space line breaks in formatted text', () => {
|
||||
const input = '**bold \nacross \nmultiple lines**\n__underline \nwith breaks__';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(3);
|
||||
expect(ast[0].type).toBe(NodeType.Strong);
|
||||
expect(ast[1].type).toBe(NodeType.Text);
|
||||
expect(ast[1]).toEqual({type: NodeType.Text, content: '\n'});
|
||||
expect(ast[2].type).toBe(NodeType.Underline);
|
||||
|
||||
// TODO: Check for line break nodes within the formatted sections
|
||||
});
|
||||
|
||||
test('escaped characters in formatting', () => {
|
||||
const input = '**bold \\*with\\* escaped**';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.Strong);
|
||||
});
|
||||
|
||||
test('nested formatting behavior', () => {
|
||||
const input = '**outer **inner** content**';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('empty double markers', () => {
|
||||
const input = '****';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '****'}]);
|
||||
});
|
||||
|
||||
test('incomplete formatting marker', () => {
|
||||
const input = '**';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '**'}]);
|
||||
});
|
||||
|
||||
test('complex nested formatting behavior', () => {
|
||||
const input = '**outer *middle **inner** content* end**';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('escaped backslash handling', () => {
|
||||
const input = '**test \\\\escaped backslash**';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.Strong);
|
||||
});
|
||||
|
||||
test('extremely complex nested formatting with weird edge cases', () => {
|
||||
const input =
|
||||
'***bold _italic __underline bold italic__ italic_ bold***\n**_nested *italic inside bold inside italic*_**\n__**_underline bold italic **still going_**__\n**_this ends weirdly__**\n***bold *italic `code` inside***';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [
|
||||
{type: NodeType.Text, content: 'bold '},
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [
|
||||
{type: NodeType.Text, content: 'italic '},
|
||||
{
|
||||
type: NodeType.Underline,
|
||||
children: [{type: NodeType.Text, content: 'underline bold italic'}],
|
||||
},
|
||||
{type: NodeType.Text, content: ' italic'},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: ' bold'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [
|
||||
{type: NodeType.Text, content: 'nested '},
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [{type: NodeType.Text, content: 'italic inside bold inside italic'}],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{
|
||||
type: NodeType.Underline,
|
||||
children: [
|
||||
{type: NodeType.Strong, children: [{type: NodeType.Text, content: '_underline bold italic '}]},
|
||||
{type: NodeType.Text, content: 'still going_**'},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{type: NodeType.Strong, children: [{type: NodeType.Text, content: '_this ends weirdly__'}]},
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [
|
||||
{type: NodeType.Text, content: 'bold *italic '},
|
||||
{type: NodeType.InlineCode, content: 'code'},
|
||||
{type: NodeType.Text, content: ' inside'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('complex mismatched formatting markers', () => {
|
||||
const input =
|
||||
'**bold *italic*\n__underline **bold__\n~~strike *italic~~ text*\n**__mixed but only one end__\n_italics __underline_';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Strong,
|
||||
children: [
|
||||
{type: NodeType.Text, content: 'bold '},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'italic'}]},
|
||||
{type: NodeType.Text, content: '\n__underline '},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: 'bold'},
|
||||
{
|
||||
type: NodeType.Underline,
|
||||
children: [
|
||||
{type: NodeType.Text, content: '\n'},
|
||||
{type: NodeType.Strikethrough, children: [{type: NodeType.Text, content: 'strike *italic'}]},
|
||||
{type: NodeType.Text, content: ' text'},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: '\n'}]},
|
||||
{type: NodeType.Text, content: '*'},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: 'mixed but only one end'},
|
||||
{type: NodeType.Underline, children: [{type: NodeType.Text, content: '\n_italics '}]},
|
||||
{type: NodeType.Text, content: 'underline_'},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
863
fluxer_app/src/lib/markdown/parser/parsers/inline-parsers.ts
Normal file
863
fluxer_app/src/lib/markdown/parser/parsers/inline-parsers.ts
Normal file
@@ -0,0 +1,863 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {FormattingContext} from '../parser/formatting-context';
|
||||
import {MAX_LINE_LENGTH} from '../types/constants';
|
||||
import {MentionKind, NodeType, ParserFlags} from '../types/enums';
|
||||
import type {Node, ParserResult} from '../types/nodes';
|
||||
import * as ASTUtils from '../utils/ast-utils';
|
||||
import * as StringUtils from '../utils/string-utils';
|
||||
import * as EmojiParsers from './emoji-parsers';
|
||||
import * as LinkParsers from './link-parsers';
|
||||
import * as MentionParsers from './mention-parsers';
|
||||
import * as TimestampParsers from './timestamp-parsers';
|
||||
|
||||
const BACKSLASH = 92;
|
||||
const UNDERSCORE = 95;
|
||||
const ASTERISK = 42;
|
||||
const TILDE = 126;
|
||||
const PIPE = 124;
|
||||
const BACKTICK = 96;
|
||||
const LESS_THAN = 60;
|
||||
const AT_SIGN = 64;
|
||||
const HASH = 35;
|
||||
const AMPERSAND = 38;
|
||||
const SLASH = 47;
|
||||
const OPEN_BRACKET = 91;
|
||||
const COLON = 58;
|
||||
const LETTER_A = 97;
|
||||
const LETTER_I = 105;
|
||||
const LETTER_M = 109;
|
||||
const LETTER_S = 115;
|
||||
const LETTER_T = 116;
|
||||
const PLUS_SIGN = 43;
|
||||
|
||||
const FORMATTING_CHARS = new Set([ASTERISK, UNDERSCORE, TILDE, PIPE, BACKTICK]);
|
||||
|
||||
const parseInlineCache = new Map<string, Array<Node>>();
|
||||
const formattingMarkerCache = new Map<string, ReturnType<typeof getFormattingMarkerInfo>>();
|
||||
const MAX_CACHE_SIZE = 500;
|
||||
const cacheHitCount = new Map<string, number>();
|
||||
|
||||
export function parseInline(text: string, parserFlags: number): Array<Node> {
|
||||
if (!text || text.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cacheKey = `${text}:${parserFlags}`;
|
||||
if (parseInlineCache.has(cacheKey)) {
|
||||
const cachedResult = parseInlineCache.get(cacheKey)!;
|
||||
|
||||
const hitCount = cacheHitCount.get(cacheKey) || 0;
|
||||
cacheHitCount.set(cacheKey, hitCount + 1);
|
||||
|
||||
return [...cachedResult];
|
||||
}
|
||||
|
||||
const context = new FormattingContext();
|
||||
|
||||
const nodes = parseInlineWithContext(text, context, parserFlags);
|
||||
|
||||
ASTUtils.flattenAST(nodes);
|
||||
|
||||
if (text.length < 1000) {
|
||||
parseInlineCache.set(cacheKey, [...nodes]);
|
||||
cacheHitCount.set(cacheKey, 1);
|
||||
|
||||
if (parseInlineCache.size > MAX_CACHE_SIZE) {
|
||||
const entries = Array.from(cacheHitCount.entries())
|
||||
.sort((a, b) => a[1] - b[1])
|
||||
.slice(0, 100);
|
||||
|
||||
for (const [key] of entries) {
|
||||
parseInlineCache.delete(key);
|
||||
cacheHitCount.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function parseInlineWithContext(text: string, context: FormattingContext, parserFlags: number): Array<Node> {
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const nodes: Array<Node> = [];
|
||||
let accumulatedText = '';
|
||||
let position = 0;
|
||||
|
||||
const textLength = text.length;
|
||||
|
||||
let characters: Array<string> | null = null;
|
||||
|
||||
while (position < textLength) {
|
||||
const currentChar = text.charAt(position);
|
||||
const currentCharCode = text.charCodeAt(position);
|
||||
|
||||
if (currentCharCode === BACKSLASH && position + 1 < textLength) {
|
||||
const nextChar = text.charAt(position + 1);
|
||||
|
||||
if (nextChar === '_' && position > 0 && text.charAt(position - 1) === '¯') {
|
||||
accumulatedText += `\\${nextChar}`;
|
||||
position += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (StringUtils.isEscapableCharacter(nextChar)) {
|
||||
accumulatedText += nextChar;
|
||||
position += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const remainingText = text.slice(position);
|
||||
|
||||
const insideQuotedAngleBracket = accumulatedText.endsWith('<"') || accumulatedText.endsWith("<'");
|
||||
|
||||
if (
|
||||
!insideQuotedAngleBracket &&
|
||||
parserFlags & ParserFlags.ALLOW_AUTOLINKS &&
|
||||
StringUtils.startsWithUrl(remainingText)
|
||||
) {
|
||||
const urlResult = LinkParsers.extractUrlSegment(remainingText, parserFlags);
|
||||
|
||||
if (urlResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(urlResult.node);
|
||||
position += urlResult.advance;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentCharCode === UNDERSCORE) {
|
||||
if (characters == null) {
|
||||
characters = [...text];
|
||||
}
|
||||
const isDoubleUnderscore = position + 1 < textLength && text.charCodeAt(position + 1) === UNDERSCORE;
|
||||
if (!isDoubleUnderscore) {
|
||||
const isWordUnderscore = StringUtils.isWordUnderscore(characters, position);
|
||||
if (isWordUnderscore) {
|
||||
accumulatedText += '_';
|
||||
position += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const emojiResult = EmojiParsers.parseStandardEmoji(text, position);
|
||||
if (emojiResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(emojiResult.node);
|
||||
position += emojiResult.advance;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentCharCode === LESS_THAN && position + 2 < textLength) {
|
||||
const nextCharCode = text.charCodeAt(position + 1);
|
||||
const thirdCharCode = position + 2 < textLength ? text.charCodeAt(position + 2) : 0;
|
||||
if (nextCharCode === COLON || (nextCharCode === LETTER_A && thirdCharCode === COLON)) {
|
||||
const customEmojiResult = EmojiParsers.parseCustomEmoji(remainingText);
|
||||
|
||||
if (customEmojiResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(customEmojiResult.node);
|
||||
position += customEmojiResult.advance;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
currentCharCode === LESS_THAN &&
|
||||
position + 3 < textLength &&
|
||||
text.charCodeAt(position + 1) === LETTER_T &&
|
||||
text.charCodeAt(position + 2) === COLON
|
||||
) {
|
||||
const timestampResult = TimestampParsers.parseTimestamp(remainingText);
|
||||
|
||||
if (timestampResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(timestampResult.node);
|
||||
position += timestampResult.advance;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentCharCode === COLON) {
|
||||
const emojiResult = EmojiParsers.parseEmojiShortcode(remainingText);
|
||||
|
||||
if (emojiResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(emojiResult.node);
|
||||
position += emojiResult.advance;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
currentCharCode === LESS_THAN &&
|
||||
position + 1 < textLength &&
|
||||
text.charCodeAt(position + 1) === PLUS_SIGN &&
|
||||
parserFlags & ParserFlags.ALLOW_AUTOLINKS
|
||||
) {
|
||||
const phoneResult = LinkParsers.parsePhoneLink(remainingText, parserFlags);
|
||||
|
||||
if (phoneResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(phoneResult.node);
|
||||
position += phoneResult.advance;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
currentCharCode === LESS_THAN &&
|
||||
position + 4 < textLength &&
|
||||
text.charCodeAt(position + 1) === LETTER_S &&
|
||||
text.charCodeAt(position + 2) === LETTER_M &&
|
||||
text.charCodeAt(position + 3) === LETTER_S &&
|
||||
text.charCodeAt(position + 4) === COLON &&
|
||||
parserFlags & ParserFlags.ALLOW_AUTOLINKS
|
||||
) {
|
||||
const smsResult = LinkParsers.parseSmsLink(remainingText, parserFlags);
|
||||
|
||||
if (smsResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(smsResult.node);
|
||||
position += smsResult.advance;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentCharCode === LESS_THAN && position + 1 < textLength) {
|
||||
const nextCharCode = text.charCodeAt(position + 1);
|
||||
|
||||
if (
|
||||
nextCharCode === AT_SIGN ||
|
||||
nextCharCode === HASH ||
|
||||
nextCharCode === AMPERSAND ||
|
||||
nextCharCode === SLASH ||
|
||||
nextCharCode === LETTER_I
|
||||
) {
|
||||
if (
|
||||
nextCharCode === AT_SIGN &&
|
||||
position + 2 < textLength &&
|
||||
text.charCodeAt(position + 2) === AMPERSAND &&
|
||||
parserFlags & ParserFlags.ALLOW_ROLE_MENTIONS
|
||||
) {
|
||||
const mentionResult = MentionParsers.parseMention(remainingText, parserFlags);
|
||||
if (mentionResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(mentionResult.node);
|
||||
position += mentionResult.advance;
|
||||
continue;
|
||||
}
|
||||
} else if (nextCharCode === AT_SIGN && parserFlags & ParserFlags.ALLOW_USER_MENTIONS) {
|
||||
const mentionResult = MentionParsers.parseMention(remainingText, parserFlags);
|
||||
if (mentionResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(mentionResult.node);
|
||||
position += mentionResult.advance;
|
||||
continue;
|
||||
}
|
||||
} else if (nextCharCode === HASH && parserFlags & ParserFlags.ALLOW_CHANNEL_MENTIONS) {
|
||||
const mentionResult = MentionParsers.parseMention(remainingText, parserFlags);
|
||||
if (mentionResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(mentionResult.node);
|
||||
position += mentionResult.advance;
|
||||
continue;
|
||||
}
|
||||
} else if (nextCharCode === SLASH && parserFlags & ParserFlags.ALLOW_COMMAND_MENTIONS) {
|
||||
const mentionResult = MentionParsers.parseMention(remainingText, parserFlags);
|
||||
if (mentionResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(mentionResult.node);
|
||||
position += mentionResult.advance;
|
||||
continue;
|
||||
}
|
||||
} else if (
|
||||
nextCharCode === LETTER_I &&
|
||||
remainingText.startsWith('<id:') &&
|
||||
parserFlags & ParserFlags.ALLOW_GUILD_NAVIGATIONS
|
||||
) {
|
||||
const mentionResult = MentionParsers.parseMention(remainingText, parserFlags);
|
||||
if (mentionResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(mentionResult.node);
|
||||
position += mentionResult.advance;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parserFlags & ParserFlags.ALLOW_AUTOLINKS) {
|
||||
const autolinkResult = LinkParsers.parseAutolink(remainingText, parserFlags);
|
||||
|
||||
if (autolinkResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(autolinkResult.node);
|
||||
position += autolinkResult.advance;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try email links: <user@example.com>
|
||||
const emailLinkResult = LinkParsers.parseEmailLink(remainingText, parserFlags);
|
||||
|
||||
if (emailLinkResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(emailLinkResult.node);
|
||||
position += emailLinkResult.advance;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentCharCode === AT_SIGN && parserFlags & ParserFlags.ALLOW_EVERYONE_MENTIONS) {
|
||||
const isEscaped = position > 0 && text.charCodeAt(position - 1) === BACKSLASH;
|
||||
|
||||
if (!isEscaped && remainingText.startsWith('@everyone')) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push({
|
||||
type: NodeType.Mention,
|
||||
kind: {kind: MentionKind.Everyone},
|
||||
});
|
||||
position += 9;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isEscaped && remainingText.startsWith('@here')) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push({
|
||||
type: NodeType.Mention,
|
||||
kind: {kind: MentionKind.Here},
|
||||
});
|
||||
position += 5;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const isDoubleUnderscore =
|
||||
currentCharCode === UNDERSCORE && position + 1 < textLength && text.charCodeAt(position + 1) === UNDERSCORE;
|
||||
|
||||
if (
|
||||
(FORMATTING_CHARS.has(currentCharCode) || currentCharCode === OPEN_BRACKET) &&
|
||||
(isDoubleUnderscore ||
|
||||
!(
|
||||
currentCharCode === UNDERSCORE &&
|
||||
accumulatedText.length > 0 &&
|
||||
StringUtils.isAlphaNumeric(accumulatedText.charCodeAt(accumulatedText.length - 1))
|
||||
))
|
||||
) {
|
||||
context.setCurrentText(accumulatedText);
|
||||
|
||||
const specialResult = parseSpecialSequence(remainingText, context, parserFlags);
|
||||
|
||||
if (specialResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(specialResult.node);
|
||||
position += specialResult.advance;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
accumulatedText += currentChar;
|
||||
position += 1;
|
||||
|
||||
if (accumulatedText.length > MAX_LINE_LENGTH) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
}
|
||||
|
||||
const result = ASTUtils.mergeTextNodes(nodes);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseSpecialSequence(text: string, context: FormattingContext, parserFlags: number): ParserResult | null {
|
||||
if (text.length === 0) return null;
|
||||
|
||||
const firstCharCode = text.charCodeAt(0);
|
||||
|
||||
switch (firstCharCode) {
|
||||
case LESS_THAN:
|
||||
if (text.length > 1) {
|
||||
const nextCharCode = text.charCodeAt(1);
|
||||
|
||||
if (nextCharCode === SLASH) {
|
||||
if (parserFlags & ParserFlags.ALLOW_COMMAND_MENTIONS) {
|
||||
const mentionResult = MentionParsers.parseMention(text, parserFlags);
|
||||
|
||||
if (mentionResult) return mentionResult;
|
||||
}
|
||||
} else if (nextCharCode === LETTER_I && text.startsWith('<id:')) {
|
||||
if (parserFlags & ParserFlags.ALLOW_GUILD_NAVIGATIONS) {
|
||||
const mentionResult = MentionParsers.parseMention(text, parserFlags);
|
||||
|
||||
if (mentionResult) return mentionResult;
|
||||
}
|
||||
} else if (nextCharCode === PLUS_SIGN && parserFlags & ParserFlags.ALLOW_AUTOLINKS) {
|
||||
const phoneResult = LinkParsers.parsePhoneLink(text, parserFlags);
|
||||
|
||||
if (phoneResult) return phoneResult;
|
||||
} else if (
|
||||
nextCharCode === LETTER_S &&
|
||||
text.length > 4 &&
|
||||
text.charCodeAt(2) === LETTER_S &&
|
||||
text.charCodeAt(3) === COLON &&
|
||||
parserFlags & ParserFlags.ALLOW_AUTOLINKS
|
||||
) {
|
||||
const smsResult = LinkParsers.parseSmsLink(text, parserFlags);
|
||||
|
||||
if (smsResult) return smsResult;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case ASTERISK:
|
||||
case UNDERSCORE:
|
||||
case TILDE:
|
||||
case PIPE:
|
||||
case BACKTICK: {
|
||||
const formattingResult = parseFormatting(text, context, parserFlags);
|
||||
|
||||
if (formattingResult) return formattingResult;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case AT_SIGN:
|
||||
if (parserFlags & ParserFlags.ALLOW_EVERYONE_MENTIONS) {
|
||||
if (text.startsWith('@everyone')) {
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Mention,
|
||||
kind: {kind: MentionKind.Everyone},
|
||||
},
|
||||
advance: 9,
|
||||
};
|
||||
}
|
||||
|
||||
if (text.startsWith('@here')) {
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Mention,
|
||||
kind: {kind: MentionKind.Here},
|
||||
},
|
||||
advance: 5,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case OPEN_BRACKET: {
|
||||
const timestampResult = TimestampParsers.parseTimestamp(text);
|
||||
|
||||
if (timestampResult) return timestampResult;
|
||||
|
||||
if (parserFlags & ParserFlags.ALLOW_MASKED_LINKS) {
|
||||
const linkResult = LinkParsers.parseLink(text, parserFlags, (t) => parseInline(t, parserFlags));
|
||||
|
||||
if (linkResult) return linkResult;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (firstCharCode !== OPEN_BRACKET) {
|
||||
const timestampResult = TimestampParsers.parseTimestamp(text);
|
||||
|
||||
if (timestampResult) return timestampResult;
|
||||
}
|
||||
|
||||
if (firstCharCode !== LESS_THAN && firstCharCode !== OPEN_BRACKET && parserFlags & ParserFlags.ALLOW_MASKED_LINKS) {
|
||||
const linkResult = LinkParsers.parseLink(text, parserFlags, (t) => parseInline(t, parserFlags));
|
||||
|
||||
if (linkResult) return linkResult;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseFormatting(text: string, context: FormattingContext, parserFlags: number): ParserResult | null {
|
||||
if (text.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let markerInfo: FormattingMarkerInfo | null | undefined;
|
||||
const prefix = text.slice(0, Math.min(3, text.length));
|
||||
|
||||
if (formattingMarkerCache.has(prefix)) {
|
||||
markerInfo = formattingMarkerCache.get(prefix);
|
||||
const hitCount = cacheHitCount.get(prefix) || 0;
|
||||
cacheHitCount.set(prefix, hitCount + 1);
|
||||
} else {
|
||||
markerInfo = getFormattingMarkerInfo(text);
|
||||
formattingMarkerCache.set(prefix, markerInfo);
|
||||
cacheHitCount.set(prefix, 1);
|
||||
|
||||
if (formattingMarkerCache.size > MAX_CACHE_SIZE) {
|
||||
const entries = Array.from(cacheHitCount.entries())
|
||||
.filter(([key]) => formattingMarkerCache.has(key))
|
||||
.sort((a, b) => a[1] - b[1])
|
||||
.slice(0, 50);
|
||||
|
||||
for (const [key] of entries) {
|
||||
formattingMarkerCache.delete(key);
|
||||
cacheHitCount.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!markerInfo) return null;
|
||||
|
||||
const {marker, nodeType, markerLength} = markerInfo;
|
||||
|
||||
if (nodeType === NodeType.Spoiler && !(parserFlags & ParserFlags.ALLOW_SPOILERS)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!context.canEnterFormatting(marker[0], marker.length > 1)) return null;
|
||||
|
||||
const endResult = findFormattingEnd(text, marker, markerLength, nodeType);
|
||||
|
||||
if (!endResult) return null;
|
||||
|
||||
const {endPosition, innerContent} = endResult;
|
||||
const isBlock = context.isFormattingActive(marker[0], marker.length > 1);
|
||||
|
||||
const formattingNode = createFormattingNode(
|
||||
nodeType,
|
||||
innerContent,
|
||||
marker,
|
||||
isBlock,
|
||||
(text: string, ctx: FormattingContext) => parseInlineWithContext(text, ctx, parserFlags),
|
||||
);
|
||||
|
||||
return {node: formattingNode, advance: endPosition + markerLength};
|
||||
}
|
||||
|
||||
interface FormattingMarkerInfo {
|
||||
marker: string;
|
||||
nodeType: NodeType;
|
||||
markerLength: number;
|
||||
}
|
||||
|
||||
function getFormattingMarkerInfo(text: string): FormattingMarkerInfo | null {
|
||||
if (!text || text.length === 0) return null;
|
||||
|
||||
const firstCharCode = text.charCodeAt(0);
|
||||
|
||||
if (!FORMATTING_CHARS.has(firstCharCode)) return null;
|
||||
|
||||
const secondCharCode = text.length > 1 ? text.charCodeAt(1) : 0;
|
||||
const thirdCharCode = text.length > 2 ? text.charCodeAt(2) : 0;
|
||||
|
||||
if (firstCharCode === ASTERISK && secondCharCode === ASTERISK && thirdCharCode === ASTERISK) {
|
||||
return {marker: '***', nodeType: NodeType.Emphasis, markerLength: 3};
|
||||
}
|
||||
if (firstCharCode === UNDERSCORE && secondCharCode === UNDERSCORE && thirdCharCode === UNDERSCORE) {
|
||||
return {marker: '___', nodeType: NodeType.Emphasis, markerLength: 3};
|
||||
}
|
||||
|
||||
if (firstCharCode === PIPE && secondCharCode === PIPE) {
|
||||
return {marker: '||', nodeType: NodeType.Spoiler, markerLength: 2};
|
||||
}
|
||||
if (firstCharCode === TILDE && secondCharCode === TILDE) {
|
||||
return {marker: '~~', nodeType: NodeType.Strikethrough, markerLength: 2};
|
||||
}
|
||||
if (firstCharCode === ASTERISK && secondCharCode === ASTERISK) {
|
||||
return {marker: '**', nodeType: NodeType.Strong, markerLength: 2};
|
||||
}
|
||||
if (firstCharCode === UNDERSCORE && secondCharCode === UNDERSCORE) {
|
||||
return {marker: '__', nodeType: NodeType.Underline, markerLength: 2};
|
||||
}
|
||||
|
||||
if (firstCharCode === BACKTICK) {
|
||||
let backtickCount = 1;
|
||||
while (backtickCount < text.length && text.charCodeAt(backtickCount) === BACKTICK) {
|
||||
backtickCount++;
|
||||
}
|
||||
return {marker: '`'.repeat(backtickCount), nodeType: NodeType.InlineCode, markerLength: backtickCount};
|
||||
}
|
||||
if (firstCharCode === ASTERISK) {
|
||||
return {marker: '*', nodeType: NodeType.Emphasis, markerLength: 1};
|
||||
}
|
||||
if (firstCharCode === UNDERSCORE) {
|
||||
return {marker: '_', nodeType: NodeType.Emphasis, markerLength: 1};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findFormattingEnd(
|
||||
text: string,
|
||||
marker: string,
|
||||
markerLength: number,
|
||||
nodeType: NodeType,
|
||||
): {endPosition: number; innerContent: string} | null {
|
||||
let position = markerLength;
|
||||
let nestedLevel = 0;
|
||||
let endPosition: number | null = null;
|
||||
const textLength = text.length;
|
||||
|
||||
if (textLength < markerLength * 2) return null;
|
||||
|
||||
if (nodeType === NodeType.InlineCode && markerLength > 1) {
|
||||
while (position < textLength) {
|
||||
if (text.charCodeAt(position) === BACKTICK) {
|
||||
let backtickCount = 0;
|
||||
let checkPos = position;
|
||||
while (checkPos < textLength && text.charCodeAt(checkPos) === BACKTICK) {
|
||||
backtickCount++;
|
||||
checkPos++;
|
||||
}
|
||||
|
||||
if (backtickCount === markerLength) {
|
||||
endPosition = position;
|
||||
break;
|
||||
}
|
||||
|
||||
position = checkPos;
|
||||
continue;
|
||||
}
|
||||
position++;
|
||||
if (position > MAX_LINE_LENGTH) break;
|
||||
}
|
||||
|
||||
if (endPosition == null) return null;
|
||||
|
||||
return {
|
||||
endPosition,
|
||||
innerContent: text.slice(markerLength, endPosition),
|
||||
};
|
||||
}
|
||||
|
||||
if (markerLength === 1 && (nodeType === NodeType.Emphasis || nodeType === NodeType.InlineCode)) {
|
||||
const markerChar = marker.charCodeAt(0);
|
||||
while (position < textLength) {
|
||||
const currentChar = text.charCodeAt(position);
|
||||
|
||||
if (currentChar === BACKSLASH && position + 1 < textLength) {
|
||||
position += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentChar === markerChar) {
|
||||
if (markerChar === BACKTICK && position + 1 < textLength && text.charCodeAt(position + 1) === BACKTICK) {
|
||||
let _backtickCount = 0;
|
||||
let checkPos = position;
|
||||
while (checkPos < textLength && text.charCodeAt(checkPos) === BACKTICK) {
|
||||
_backtickCount++;
|
||||
checkPos++;
|
||||
}
|
||||
position = checkPos;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (markerChar === UNDERSCORE && position + 1 < textLength && text.charCodeAt(position + 1) === UNDERSCORE) {
|
||||
position += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
endPosition = position;
|
||||
break;
|
||||
}
|
||||
|
||||
position++;
|
||||
if (position > MAX_LINE_LENGTH) break;
|
||||
}
|
||||
|
||||
if (endPosition == null) return null;
|
||||
|
||||
return {
|
||||
endPosition,
|
||||
innerContent: text.slice(markerLength, endPosition),
|
||||
};
|
||||
}
|
||||
|
||||
if (nodeType === NodeType.InlineCode) {
|
||||
while (position < textLength) {
|
||||
if (text.charCodeAt(position) === BACKTICK) {
|
||||
endPosition = position;
|
||||
break;
|
||||
}
|
||||
position++;
|
||||
if (position > MAX_LINE_LENGTH) break;
|
||||
}
|
||||
} else {
|
||||
const firstMarkerChar = marker.charCodeAt(0);
|
||||
const isDoubleMarker = marker.length > 1;
|
||||
|
||||
while (position < textLength) {
|
||||
if (text.charCodeAt(position) === BACKSLASH && position + 1 < textLength) {
|
||||
position += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
let isClosingMarker = true;
|
||||
if (position + marker.length <= textLength) {
|
||||
for (let i = 0; i < marker.length; i++) {
|
||||
if (text.charCodeAt(position + i) !== marker.charCodeAt(i)) {
|
||||
isClosingMarker = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isClosingMarker = false;
|
||||
}
|
||||
|
||||
if (isClosingMarker) {
|
||||
if (nestedLevel === 0) {
|
||||
if (nodeType === NodeType.Spoiler && position === markerLength && position + marker.length < textLength) {
|
||||
position += 1;
|
||||
continue;
|
||||
}
|
||||
endPosition = position;
|
||||
break;
|
||||
}
|
||||
nestedLevel--;
|
||||
position += marker.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
isDoubleMarker &&
|
||||
position + 1 < textLength &&
|
||||
text.charCodeAt(position) === firstMarkerChar &&
|
||||
text.charCodeAt(position + 1) === firstMarkerChar
|
||||
) {
|
||||
nestedLevel++;
|
||||
}
|
||||
|
||||
position++;
|
||||
if (position > MAX_LINE_LENGTH) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endPosition == null) return null;
|
||||
|
||||
const innerContent = text.slice(markerLength, endPosition);
|
||||
return {endPosition, innerContent};
|
||||
}
|
||||
|
||||
function createFormattingNode(
|
||||
nodeType: NodeType,
|
||||
innerContent: string,
|
||||
marker: string,
|
||||
isBlock: boolean,
|
||||
parseInlineWithContext: (text: string, context: FormattingContext) => Array<Node>,
|
||||
): Node {
|
||||
if (nodeType === NodeType.InlineCode) {
|
||||
return {type: NodeType.InlineCode, content: innerContent};
|
||||
}
|
||||
|
||||
if (innerContent.length === 0) {
|
||||
return {
|
||||
type: nodeType as any,
|
||||
children: [],
|
||||
...(isBlock ? {isBlock} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const newContext = new FormattingContext();
|
||||
newContext.pushFormatting(marker[0], marker.length > 1);
|
||||
|
||||
if (marker === '***' || marker === '___') {
|
||||
const emphasisContext = new FormattingContext();
|
||||
emphasisContext.pushFormatting('*', true);
|
||||
|
||||
const innerNodes = parseInlineWithContext(innerContent, emphasisContext);
|
||||
|
||||
return {
|
||||
type: NodeType.Emphasis,
|
||||
children: [{type: NodeType.Strong, children: innerNodes}],
|
||||
};
|
||||
}
|
||||
|
||||
const innerNodes = parseInlineWithContext(innerContent, newContext);
|
||||
|
||||
return {
|
||||
type: nodeType as any,
|
||||
children: innerNodes,
|
||||
...(isBlock || nodeType === NodeType.Spoiler ? {isBlock} : {}),
|
||||
};
|
||||
}
|
||||
1526
fluxer_app/src/lib/markdown/parser/parsers/link-parsers.test.ts
Normal file
1526
fluxer_app/src/lib/markdown/parser/parsers/link-parsers.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
540
fluxer_app/src/lib/markdown/parser/parsers/link-parsers.ts
Normal file
540
fluxer_app/src/lib/markdown/parser/parsers/link-parsers.ts
Normal file
@@ -0,0 +1,540 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {APP_PROTOCOL_PREFIX} from '~/utils/appProtocol';
|
||||
import {MAX_LINK_URL_LENGTH} from '../types/constants';
|
||||
import {NodeType, ParserFlags} from '../types/enums';
|
||||
import type {Node, ParserResult} from '../types/nodes';
|
||||
import * as StringUtils from '../utils/string-utils';
|
||||
import * as URLUtils from '../utils/url-utils';
|
||||
|
||||
const SPOOFED_LINK_PATTERN = /^\[https?:\/\/[^\s[\]]+\]\(https?:\/\/[^\s[\]]+\)$/;
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const URL_DOMAIN_PATTERN =
|
||||
/^(?:https?:\/\/)?(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(?:\/[^\s[\]]*)?$/;
|
||||
|
||||
const OPEN_BRACKET = 91;
|
||||
const CLOSE_BRACKET = 93;
|
||||
const OPEN_PAREN = 40;
|
||||
const CLOSE_PAREN = 41;
|
||||
const BACKSLASH = 92;
|
||||
const LESS_THAN = 60;
|
||||
const GREATER_THAN = 62;
|
||||
const DOUBLE_QUOTE = 34;
|
||||
const SINGLE_QUOTE = 39;
|
||||
const PLUS_SIGN = 43;
|
||||
|
||||
function containsLinkSyntax(text: string): boolean {
|
||||
const bracketIndex = text.indexOf('[');
|
||||
if (bracketIndex === -1) return false;
|
||||
|
||||
const closeBracketIndex = text.indexOf(']', bracketIndex);
|
||||
if (closeBracketIndex === -1) return false;
|
||||
|
||||
if (closeBracketIndex + 1 < text.length && text[closeBracketIndex + 1] === '(') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return containsLinkSyntax(text.substring(closeBracketIndex + 1));
|
||||
}
|
||||
|
||||
export function parseLink(
|
||||
text: string,
|
||||
_parserFlags: number,
|
||||
parseInline: (text: string) => Array<Node>,
|
||||
): ParserResult | null {
|
||||
if (text.charCodeAt(0) !== OPEN_BRACKET) return null;
|
||||
|
||||
const linkParts = extractLinkParts(text);
|
||||
|
||||
if (!linkParts) {
|
||||
if (SPOOFED_LINK_PATTERN.test(text)) {
|
||||
return {
|
||||
node: {type: NodeType.Text, content: text},
|
||||
advance: text.length,
|
||||
};
|
||||
}
|
||||
|
||||
const bracketResult = findClosingBracket(text);
|
||||
|
||||
if (bracketResult) {
|
||||
const {bracketPosition, linkText} = bracketResult;
|
||||
|
||||
if (containsLinkSyntax(linkText)) {
|
||||
return {
|
||||
node: {type: NodeType.Text, content: text},
|
||||
advance: text.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
node: {type: NodeType.Text, content: text.slice(0, bracketPosition + 1)},
|
||||
advance: bracketPosition + 1,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const normalizedUrl = URLUtils.normalizeUrl(linkParts.url);
|
||||
const isValid = URLUtils.isValidUrl(normalizedUrl);
|
||||
|
||||
if (isValid) {
|
||||
if (linkParts.url.startsWith('/') && !linkParts.url.startsWith('//')) {
|
||||
return {
|
||||
node: {type: NodeType.Text, content: text.slice(0, linkParts.advanceBy)},
|
||||
advance: linkParts.advanceBy,
|
||||
};
|
||||
}
|
||||
|
||||
let finalUrl = normalizedUrl;
|
||||
|
||||
if (finalUrl.startsWith('tel:') || finalUrl.startsWith('sms:')) {
|
||||
const protocol = finalUrl.substring(0, finalUrl.indexOf(':') + 1);
|
||||
const phoneNumber = finalUrl.substring(finalUrl.indexOf(':') + 1);
|
||||
|
||||
if (phoneNumber.startsWith('+')) {
|
||||
const normalizedPhone = URLUtils.normalizePhoneNumber(phoneNumber);
|
||||
finalUrl = protocol + normalizedPhone;
|
||||
}
|
||||
} else {
|
||||
finalUrl = URLUtils.convertToAsciiUrl(finalUrl);
|
||||
}
|
||||
|
||||
const inlineNodes = parseInline(linkParts.linkText);
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Link,
|
||||
text: inlineNodes.length === 1 ? inlineNodes[0] : {type: NodeType.Sequence, children: inlineNodes},
|
||||
url: finalUrl,
|
||||
escaped: linkParts.isEscaped,
|
||||
},
|
||||
advance: linkParts.advanceBy,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
node: {type: NodeType.Text, content: text.slice(0, linkParts.advanceBy)},
|
||||
advance: linkParts.advanceBy,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractLinkParts(text: string): {linkText: string; url: string; isEscaped: boolean; advanceBy: number} | null {
|
||||
const bracketResult = findClosingBracket(text);
|
||||
if (!bracketResult) return null;
|
||||
|
||||
const {bracketPosition, linkText} = bracketResult;
|
||||
|
||||
if (bracketPosition + 1 >= text.length || text.charCodeAt(bracketPosition + 1) !== OPEN_PAREN) return null;
|
||||
|
||||
const trimmedLinkText = linkText.trim();
|
||||
|
||||
if (containsLinkSyntax(trimmedLinkText)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isEmailSpoofing = EMAIL_PATTERN.test(trimmedLinkText);
|
||||
const isSpoofedLink = SPOOFED_LINK_PATTERN.test(text);
|
||||
|
||||
if (isEmailSpoofing || isSpoofedLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const urlInfo = extractUrl(text, bracketPosition + 2);
|
||||
if (!urlInfo) return null;
|
||||
|
||||
if (urlInfo.url.includes('"') || urlInfo.url.includes("'")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isUrlOrDomainLike = URL_DOMAIN_PATTERN.test(trimmedLinkText);
|
||||
const isLinkTextUrlWithProtocol = StringUtils.startsWithUrl(trimmedLinkText);
|
||||
|
||||
if (isLinkTextUrlWithProtocol && isUrlOrDomainLike) {
|
||||
try {
|
||||
const textDomain = extractDomainFromString(trimmedLinkText);
|
||||
const urlDomain = extractDomainFromString(urlInfo.url);
|
||||
|
||||
if (!textDomain || (urlDomain && textDomain !== urlDomain)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (shouldTreatAsMaskedLink(trimmedLinkText, urlInfo.url)) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
linkText,
|
||||
...urlInfo,
|
||||
};
|
||||
}
|
||||
|
||||
function findClosingBracket(text: string): {bracketPosition: number; linkText: string} | null {
|
||||
let position = 1;
|
||||
let nestedBrackets = 0;
|
||||
const textLength = text.length;
|
||||
|
||||
while (position < textLength) {
|
||||
const currentChar = text.charCodeAt(position);
|
||||
|
||||
if (currentChar === OPEN_BRACKET) {
|
||||
nestedBrackets++;
|
||||
position++;
|
||||
} else if (currentChar === CLOSE_BRACKET) {
|
||||
if (nestedBrackets > 0) {
|
||||
nestedBrackets--;
|
||||
position++;
|
||||
} else {
|
||||
return {
|
||||
bracketPosition: position,
|
||||
linkText: text.slice(1, position),
|
||||
};
|
||||
}
|
||||
} else if (currentChar === BACKSLASH && position + 1 < textLength) {
|
||||
position += 2;
|
||||
} else {
|
||||
position++;
|
||||
}
|
||||
|
||||
if (position > MAX_LINK_URL_LENGTH) break;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractUrl(text: string, startPos: number): {url: string; isEscaped: boolean; advanceBy: number} | null {
|
||||
if (startPos >= text.length) return null;
|
||||
|
||||
return text.charCodeAt(startPos) === LESS_THAN
|
||||
? extractEscapedUrl(text, startPos + 1)
|
||||
: extractUnescapedUrl(text, startPos);
|
||||
}
|
||||
|
||||
function extractEscapedUrl(
|
||||
text: string,
|
||||
urlStart: number,
|
||||
): {url: string; isEscaped: boolean; advanceBy: number} | null {
|
||||
const textLength = text.length;
|
||||
let currentPos = urlStart;
|
||||
|
||||
while (currentPos < textLength) {
|
||||
if (text.charCodeAt(currentPos) === GREATER_THAN) {
|
||||
const url = text.slice(urlStart, currentPos);
|
||||
|
||||
currentPos++;
|
||||
while (currentPos < textLength && text.charCodeAt(currentPos) !== CLOSE_PAREN) {
|
||||
currentPos++;
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
isEscaped: true,
|
||||
advanceBy: currentPos + 1,
|
||||
};
|
||||
}
|
||||
currentPos++;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractUnescapedUrl(
|
||||
text: string,
|
||||
urlStart: number,
|
||||
): {url: string; isEscaped: boolean; advanceBy: number} | null {
|
||||
const textLength = text.length;
|
||||
let currentPos = urlStart;
|
||||
let nestedParens = 0;
|
||||
|
||||
while (currentPos < textLength) {
|
||||
const currentChar = text.charCodeAt(currentPos);
|
||||
|
||||
if (currentChar === OPEN_PAREN) {
|
||||
nestedParens++;
|
||||
currentPos++;
|
||||
} else if (currentChar === CLOSE_PAREN) {
|
||||
if (nestedParens > 0) {
|
||||
nestedParens--;
|
||||
currentPos++;
|
||||
} else {
|
||||
const url = text.slice(urlStart, currentPos);
|
||||
|
||||
return {
|
||||
url,
|
||||
isEscaped: false,
|
||||
advanceBy: currentPos + 1,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
currentPos++;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractUrlSegment(text: string, parserFlags: number): ParserResult | null {
|
||||
if (!(parserFlags & ParserFlags.ALLOW_AUTOLINKS)) return null;
|
||||
|
||||
let prefixLength = 0;
|
||||
if (text.startsWith('https://')) {
|
||||
prefixLength = 8;
|
||||
} else if (text.startsWith('http://')) {
|
||||
prefixLength = 7;
|
||||
} else if (text.startsWith(APP_PROTOCOL_PREFIX)) {
|
||||
prefixLength = APP_PROTOCOL_PREFIX.length;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
let end = prefixLength;
|
||||
const textLength = text.length;
|
||||
|
||||
while (end < textLength && !StringUtils.isUrlTerminationChar(text[end])) {
|
||||
end++;
|
||||
if (end - prefixLength > MAX_LINK_URL_LENGTH) break;
|
||||
}
|
||||
|
||||
let urlString = text.slice(0, end);
|
||||
|
||||
const punctuation = '.,;:!?';
|
||||
while (
|
||||
urlString.length > 0 &&
|
||||
punctuation.includes(urlString[urlString.length - 1]) &&
|
||||
!urlString.match(/\.[a-zA-Z]{2,}$/)
|
||||
) {
|
||||
urlString = urlString.slice(0, -1);
|
||||
end--;
|
||||
}
|
||||
|
||||
const isInQuotes =
|
||||
text.charAt(0) === '"' ||
|
||||
text.charAt(0) === "'" ||
|
||||
(end < textLength && (text.charAt(end) === '"' || text.charAt(end) === "'"));
|
||||
|
||||
try {
|
||||
const normalizedUrl = URLUtils.normalizeUrl(urlString);
|
||||
const isValid = URLUtils.isValidUrl(normalizedUrl);
|
||||
|
||||
if (isValid) {
|
||||
if (normalizedUrl.startsWith('mailto:') || normalizedUrl.startsWith('tel:') || normalizedUrl.startsWith('sms:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const finalUrl = URLUtils.convertToAsciiUrl(normalizedUrl);
|
||||
|
||||
return {
|
||||
node: {type: NodeType.Link, text: undefined, url: finalUrl, escaped: isInQuotes},
|
||||
advance: urlString.length,
|
||||
};
|
||||
}
|
||||
} catch (_e) {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseAutolink(text: string, parserFlags: number): ParserResult | null {
|
||||
if (!(parserFlags & ParserFlags.ALLOW_AUTOLINKS)) return null;
|
||||
|
||||
if (text.charCodeAt(0) !== LESS_THAN) return null;
|
||||
|
||||
if (text.length > 1 && (text.charCodeAt(1) === DOUBLE_QUOTE || text.charCodeAt(1) === SINGLE_QUOTE)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!StringUtils.startsWithUrl(text.slice(1))) return null;
|
||||
|
||||
const end = text.indexOf('>', 1);
|
||||
if (end === -1) return null;
|
||||
|
||||
const urlString = text.slice(1, end);
|
||||
if (urlString.length > MAX_LINK_URL_LENGTH) return null;
|
||||
|
||||
try {
|
||||
const normalizedUrl = URLUtils.normalizeUrl(urlString);
|
||||
const isValid = URLUtils.isValidUrl(normalizedUrl);
|
||||
|
||||
if (isValid) {
|
||||
if (normalizedUrl.startsWith('mailto:') || normalizedUrl.startsWith('tel:') || normalizedUrl.startsWith('sms:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const finalUrl = URLUtils.convertToAsciiUrl(normalizedUrl);
|
||||
|
||||
return {
|
||||
node: {type: NodeType.Link, text: undefined, url: finalUrl, escaped: true},
|
||||
advance: end + 1,
|
||||
};
|
||||
}
|
||||
} catch (_e) {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseEmailLink(text: string, parserFlags: number): ParserResult | null {
|
||||
if (!(parserFlags & ParserFlags.ALLOW_AUTOLINKS)) return null;
|
||||
|
||||
if (text.charCodeAt(0) !== LESS_THAN) return null;
|
||||
|
||||
const end = text.indexOf('>', 1);
|
||||
if (end === -1) return null;
|
||||
|
||||
const content = text.slice(1, end);
|
||||
|
||||
if (content.startsWith('http://') || content.startsWith('https://')) return null;
|
||||
if (content.charCodeAt(0) === PLUS_SIGN) return null;
|
||||
if (content.indexOf('@') === -1) return null;
|
||||
|
||||
const isValid = URLUtils.isValidEmail(content);
|
||||
|
||||
if (isValid) {
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Link,
|
||||
text: {type: NodeType.Text, content: content},
|
||||
url: `mailto:${content}`,
|
||||
escaped: true,
|
||||
},
|
||||
advance: end + 1,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parsePhoneLink(text: string, parserFlags: number): ParserResult | null {
|
||||
if (!(parserFlags & ParserFlags.ALLOW_AUTOLINKS)) return null;
|
||||
|
||||
if (text.charCodeAt(0) !== LESS_THAN) return null;
|
||||
|
||||
const end = text.indexOf('>', 1);
|
||||
if (end === -1) return null;
|
||||
|
||||
const content = text.slice(1, end);
|
||||
|
||||
if (content.charCodeAt(0) !== PLUS_SIGN) return null;
|
||||
|
||||
const isValid = URLUtils.isValidPhoneNumber(content);
|
||||
|
||||
if (isValid) {
|
||||
const normalizedPhone = URLUtils.normalizePhoneNumber(content);
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Link,
|
||||
text: {type: NodeType.Text, content: content},
|
||||
url: `tel:${normalizedPhone}`,
|
||||
escaped: true,
|
||||
},
|
||||
advance: end + 1,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseSmsLink(text: string, parserFlags: number): ParserResult | null {
|
||||
if (!(parserFlags & ParserFlags.ALLOW_AUTOLINKS)) return null;
|
||||
|
||||
if (text.charCodeAt(0) !== LESS_THAN) return null;
|
||||
|
||||
if (!text.startsWith('<sms:')) return null;
|
||||
|
||||
const end = text.indexOf('>', 1);
|
||||
if (end === -1) return null;
|
||||
|
||||
const content = text.slice(1, end);
|
||||
const phoneNumber = content.slice(4);
|
||||
|
||||
if (phoneNumber.charCodeAt(0) !== PLUS_SIGN || !URLUtils.isValidPhoneNumber(phoneNumber)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedPhone = URLUtils.normalizePhoneNumber(phoneNumber);
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Link,
|
||||
text: {type: NodeType.Text, content: phoneNumber},
|
||||
url: `sms:${normalizedPhone}`,
|
||||
escaped: true,
|
||||
},
|
||||
advance: end + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function extractDomainFromString(input: string): string {
|
||||
if (!input) return '';
|
||||
|
||||
try {
|
||||
const normalized = input.normalize('NFKC').trim();
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let urlCandidate = normalized;
|
||||
if (!normalized.includes('://')) {
|
||||
urlCandidate = normalized.startsWith('//') ? `https:${normalized}` : `https://${normalized}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(urlCandidate);
|
||||
return url.hostname.toLowerCase();
|
||||
} catch {
|
||||
const match = urlCandidate.match(/^(?:https?:\/\/)([^/?#]+)/i);
|
||||
if (match?.[1]) {
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function shouldTreatAsMaskedLink(trimmedLinkText: string, url: string): boolean {
|
||||
const normalizedText = trimmedLinkText.trim();
|
||||
|
||||
try {
|
||||
const normalizedUrl = URLUtils.normalizeUrl(url);
|
||||
const urlObj = new URL(normalizedUrl);
|
||||
const textUrl = new URL(normalizedText);
|
||||
|
||||
if (
|
||||
urlObj.origin === textUrl.origin &&
|
||||
urlObj.pathname === textUrl.pathname &&
|
||||
urlObj.search === textUrl.search &&
|
||||
urlObj.hash === textUrl.hash
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return true;
|
||||
}
|
||||
658
fluxer_app/src/lib/markdown/parser/parsers/list-parsers.test.ts
Normal file
658
fluxer_app/src/lib/markdown/parser/parsers/list-parsers.test.ts
Normal file
@@ -0,0 +1,658 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {describe, expect, test} from 'vitest';
|
||||
import {Parser} from '../parser/parser';
|
||||
import {NodeType, ParserFlags} from '../types/enums';
|
||||
import type {CodeBlockNode, ListNode, TextNode} from '../types/nodes';
|
||||
|
||||
describe('Fluxer Markdown Parser - Lists', () => {
|
||||
describe('Basic list functionality', () => {
|
||||
test('unordered list', () => {
|
||||
const input = '- Item 1\n- Item 2\n- Item 3';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
expect(ast[0]).toEqual({
|
||||
type: NodeType.List,
|
||||
ordered: false,
|
||||
items: [
|
||||
{children: [{type: NodeType.Text, content: 'Item 1'}]},
|
||||
{children: [{type: NodeType.Text, content: 'Item 2'}]},
|
||||
{children: [{type: NodeType.Text, content: 'Item 3'}]},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('ordered list', () => {
|
||||
const input = '1. Item 1\n2. Item 2\n3. Item 3';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
expect(ast[0]).toEqual({
|
||||
type: NodeType.List,
|
||||
ordered: true,
|
||||
items: [
|
||||
{children: [{type: NodeType.Text, content: 'Item 1'}], ordinal: 1},
|
||||
{children: [{type: NodeType.Text, content: 'Item 2'}], ordinal: 2},
|
||||
{children: [{type: NodeType.Text, content: 'Item 3'}], ordinal: 3},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('mixed list types should create separate lists', () => {
|
||||
const input = '1. First\n- Unordered\n2. Second';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(3);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
expect(ast[0]).toEqual({
|
||||
type: NodeType.List,
|
||||
ordered: true,
|
||||
items: [{children: [{type: NodeType.Text, content: 'First'}], ordinal: 1}],
|
||||
});
|
||||
expect(ast[1].type).toBe(NodeType.List);
|
||||
expect(ast[1]).toEqual({
|
||||
type: NodeType.List,
|
||||
ordered: false,
|
||||
items: [{children: [{type: NodeType.Text, content: 'Unordered'}]}],
|
||||
});
|
||||
expect(ast[2].type).toBe(NodeType.List);
|
||||
expect(ast[2]).toEqual({
|
||||
type: NodeType.List,
|
||||
ordered: true,
|
||||
items: [{children: [{type: NodeType.Text, content: 'Second'}], ordinal: 2}],
|
||||
});
|
||||
});
|
||||
|
||||
test('list with asterisks', () => {
|
||||
const input = '* Item 1\n* Item 2\n* Item 3';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
expect(ast[0]).toEqual({
|
||||
type: NodeType.List,
|
||||
ordered: false,
|
||||
items: [
|
||||
{children: [{type: NodeType.Text, content: 'Item 1'}]},
|
||||
{children: [{type: NodeType.Text, content: 'Item 2'}]},
|
||||
{children: [{type: NodeType.Text, content: 'Item 3'}]},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom ordering in lists', () => {
|
||||
test('custom ordered list numbering', () => {
|
||||
const input = '1. First item\n3. Third item\n5. Fifth item';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0]).toEqual({
|
||||
type: NodeType.List,
|
||||
ordered: true,
|
||||
items: [
|
||||
{children: [{type: NodeType.Text, content: 'First item'}], ordinal: 1},
|
||||
{children: [{type: NodeType.Text, content: 'Third item'}], ordinal: 3},
|
||||
{children: [{type: NodeType.Text, content: 'Fifth item'}], ordinal: 5},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('list with all same number', () => {
|
||||
const input = '1. Item one\n1. Item two\n1. Item three';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0]).toEqual({
|
||||
type: NodeType.List,
|
||||
ordered: true,
|
||||
items: [
|
||||
{children: [{type: NodeType.Text, content: 'Item one'}], ordinal: 1},
|
||||
{children: [{type: NodeType.Text, content: 'Item two'}], ordinal: 1},
|
||||
{children: [{type: NodeType.Text, content: 'Item three'}], ordinal: 1},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('list starting with non-1', () => {
|
||||
const input = '5. First item\n6. Second item\n7. Third item';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0]).toEqual({
|
||||
type: NodeType.List,
|
||||
ordered: true,
|
||||
items: [
|
||||
{children: [{type: NodeType.Text, content: 'First item'}], ordinal: 5},
|
||||
{children: [{type: NodeType.Text, content: 'Second item'}], ordinal: 6},
|
||||
{children: [{type: NodeType.Text, content: 'Third item'}], ordinal: 7},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('mixed pattern ordered list', () => {
|
||||
const input = '1. a\n1. b\n3. c\n4. d';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0]).toEqual({
|
||||
type: NodeType.List,
|
||||
ordered: true,
|
||||
items: [
|
||||
{children: [{type: NodeType.Text, content: 'a'}], ordinal: 1},
|
||||
{children: [{type: NodeType.Text, content: 'b'}], ordinal: 1},
|
||||
{children: [{type: NodeType.Text, content: 'c'}], ordinal: 3},
|
||||
{children: [{type: NodeType.Text, content: 'd'}], ordinal: 4},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Nested lists', () => {
|
||||
test('simple nested list', () => {
|
||||
const input = '- Parent 1\n - Child 1\n - Child 2\n- Parent 2';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
expect(listNode.items.length).toBe(2);
|
||||
|
||||
expect(listNode.items[0].children.length).toBe(2);
|
||||
expect(listNode.items[0].children[0].type).toBe(NodeType.Text);
|
||||
expect(listNode.items[0].children[1].type).toBe(NodeType.List);
|
||||
|
||||
const nestedList = listNode.items[0].children[1] as ListNode;
|
||||
expect(nestedList.items.length).toBe(2);
|
||||
|
||||
expect(listNode.items[1].children.length).toBe(1);
|
||||
expect(listNode.items[1].children[0].type).toBe(NodeType.Text);
|
||||
});
|
||||
|
||||
test('ordered list with nested unordered list', () => {
|
||||
const input = '1. First item\n - Nested unordered 1\n - Nested unordered 2\n2. Second item';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
expect(listNode.ordered).toBe(true);
|
||||
expect(listNode.items.length).toBe(2);
|
||||
|
||||
expect(listNode.items[0].children.length).toBe(2);
|
||||
expect(listNode.items[0].children[0].type).toBe(NodeType.Text);
|
||||
expect(listNode.items[0].children[1].type).toBe(NodeType.List);
|
||||
expect(listNode.items[0].ordinal).toBe(1);
|
||||
|
||||
const nestedList = listNode.items[0].children[1] as ListNode;
|
||||
expect(nestedList.ordered).toBe(false);
|
||||
|
||||
expect(listNode.items[1].children.length).toBe(1);
|
||||
expect(listNode.items[1].ordinal).toBe(2);
|
||||
});
|
||||
|
||||
test('unordered list with nested ordered list', () => {
|
||||
const input = '- First item\n 1. Nested ordered 1\n 2. Nested ordered 2\n- Second item';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
expect(listNode.ordered).toBe(false);
|
||||
|
||||
const nestedList = listNode.items[0].children[1] as ListNode;
|
||||
expect(nestedList.ordered).toBe(true);
|
||||
expect(nestedList.items[0].ordinal).toBe(1);
|
||||
expect(nestedList.items[1].ordinal).toBe(2);
|
||||
});
|
||||
|
||||
test('multi-level nesting', () => {
|
||||
const input =
|
||||
'1. Level 1\n - Level 2\n - Level 3\n 1. Level 4\n - Back to level 2\n2. Back to level 1';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
|
||||
expect(listNode.items[0].ordinal).toBe(1);
|
||||
|
||||
const level2List = listNode.items[0].children[1] as ListNode;
|
||||
expect(level2List.ordered).toBe(false);
|
||||
|
||||
const level3List = level2List.items[0].children[1] as ListNode;
|
||||
expect(level3List.ordered).toBe(false);
|
||||
|
||||
const level4List = level3List.items[0].children[1] as ListNode;
|
||||
expect(level4List.ordered).toBe(true);
|
||||
expect(level4List.items[0].ordinal).toBe(1);
|
||||
|
||||
expect(listNode.items[1].ordinal).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lists with other content', () => {
|
||||
test('list with formatted text', () => {
|
||||
const input = '- **Bold item**\n- *Italic item*\n- `Code item`';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
expect(listNode.items[0].children[0].type).toBe(NodeType.Strong);
|
||||
|
||||
expect(listNode.items[1].children[0].type).toBe(NodeType.Emphasis);
|
||||
|
||||
expect(listNode.items[2].children[0].type).toBe(NodeType.InlineCode);
|
||||
});
|
||||
|
||||
test('list with blank lines between paragraphs', () => {
|
||||
const input = '1. First paragraph\n\n Second paragraph\n2. Another item';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(3);
|
||||
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
const firstList = ast[0] as ListNode;
|
||||
expect(firstList.items.length).toBe(1);
|
||||
expect(firstList.items[0].children[0].type).toBe(NodeType.Text);
|
||||
expect((firstList.items[0].children[0] as TextNode).content).toBe('First paragraph');
|
||||
|
||||
expect(ast[1].type).toBe(NodeType.Text);
|
||||
|
||||
expect(ast[2].type).toBe(NodeType.List);
|
||||
const secondList = ast[2] as ListNode;
|
||||
expect(secondList.items.length).toBe(1);
|
||||
expect(secondList.items[0].children[0].type).toBe(NodeType.Text);
|
||||
expect((secondList.items[0].children[0] as TextNode).content).toBe('Another item');
|
||||
});
|
||||
|
||||
test('list before and after paragraph', () => {
|
||||
const input = '- List item 1\n- List item 2\n\nParagraph text\n\n- List item 3\n- List item 4';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(3);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
expect(ast[1].type).toBe(NodeType.Text);
|
||||
expect(ast[2].type).toBe(NodeType.List);
|
||||
|
||||
expect((ast[1] as TextNode).content).toBe('\nParagraph text\n\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lists with code blocks', () => {
|
||||
test('list with code block', () => {
|
||||
const input =
|
||||
'1. Item with code block:\n' +
|
||||
' ```\n' +
|
||||
' function example() {\n' +
|
||||
' return "test";\n' +
|
||||
' }\n' +
|
||||
' ```\n' +
|
||||
'2. Next item';
|
||||
|
||||
const flags = ParserFlags.ALLOW_LISTS | ParserFlags.ALLOW_CODE_BLOCKS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
expect(listNode.items.length).toBe(2);
|
||||
|
||||
expect(listNode.items[0].children.length).toBe(2);
|
||||
expect(listNode.items[0].children[0].type).toBe(NodeType.Text);
|
||||
expect(listNode.items[0].children[1].type).toBe(NodeType.CodeBlock);
|
||||
|
||||
const codeBlock = listNode.items[0].children[1] as CodeBlockNode;
|
||||
expect(codeBlock.content).toContain('function example()');
|
||||
|
||||
expect(listNode.items[1].children.length).toBe(1);
|
||||
expect(listNode.items[1].children[0].type).toBe(NodeType.Text);
|
||||
});
|
||||
|
||||
test('code block with language specified', () => {
|
||||
const input =
|
||||
'- Item with JavaScript:\n' + ' ```javascript\n' + ' const x = 42;\n' + ' console.log(x);\n' + ' ```';
|
||||
|
||||
const flags = ParserFlags.ALLOW_LISTS | ParserFlags.ALLOW_CODE_BLOCKS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
const codeBlock = listNode.items[0].children[1] as CodeBlockNode;
|
||||
|
||||
expect(codeBlock.language).toBe('javascript');
|
||||
expect(codeBlock.content).toContain('const x = 42;');
|
||||
});
|
||||
|
||||
test('list with multiple code blocks', () => {
|
||||
const input =
|
||||
'1. First code block:\n' +
|
||||
' ```\n' +
|
||||
' Block 1\n' +
|
||||
' ```\n' +
|
||||
'2. Second code block:\n' +
|
||||
' ```\n' +
|
||||
' Block 2\n' +
|
||||
' ```';
|
||||
|
||||
const flags = ParserFlags.ALLOW_LISTS | ParserFlags.ALLOW_CODE_BLOCKS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
|
||||
expect(listNode.items.length).toBe(2);
|
||||
expect(listNode.items[0].children[1].type).toBe(NodeType.CodeBlock);
|
||||
expect(listNode.items[1].children[1].type).toBe(NodeType.CodeBlock);
|
||||
|
||||
const firstCodeBlock = listNode.items[0].children[1] as CodeBlockNode;
|
||||
const secondCodeBlock = listNode.items[1].children[1] as CodeBlockNode;
|
||||
|
||||
expect(firstCodeBlock.content).toContain('Block 1');
|
||||
expect(secondCodeBlock.content).toContain('Block 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases and special scenarios', () => {
|
||||
test('deeply nested list beyond max depth (9 levels)', () => {
|
||||
const input =
|
||||
'* fdf\n * dffsdf\n * dfsdfs\n * fdfsf\n * test\n * test2\n * test3\n * test4\n * test5';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
expect(listNode.ordered).toBe(false);
|
||||
expect(listNode.items.length).toBe(1);
|
||||
|
||||
let currentLevel: ListNode = listNode;
|
||||
const expectedContents = ['fdf', 'dffsdf', 'dfsdfs', 'fdfsf', 'test', 'test2', 'test3', 'test4', 'test5'];
|
||||
|
||||
for (let i = 0; i < expectedContents.length; i++) {
|
||||
expect(currentLevel.items.length).toBeGreaterThan(0);
|
||||
const item = currentLevel.items[0];
|
||||
expect(item.children.length).toBeGreaterThan(0);
|
||||
|
||||
const textNode = item.children[0] as TextNode;
|
||||
expect(textNode.type).toBe(NodeType.Text);
|
||||
expect(textNode.content).toBe(expectedContents[i]);
|
||||
|
||||
if (i < expectedContents.length - 1) {
|
||||
expect(item.children.length).toBe(2);
|
||||
expect(item.children[1].type).toBe(NodeType.List);
|
||||
currentLevel = item.children[1] as ListNode;
|
||||
} else {
|
||||
expect(item.children.length).toBe(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('list with invalid indentation', () => {
|
||||
const input = '1. First\n - Invalid subitem with 1 space\n2. Second';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
expect(listNode.items[0].children.length).toBe(2);
|
||||
expect(listNode.items[0].children[1].type).toBe(NodeType.Text);
|
||||
expect((listNode.items[0].children[1] as TextNode).content).toContain('Invalid subitem');
|
||||
});
|
||||
|
||||
test('empty list items', () => {
|
||||
const input = '- \n- Item 2\n- ';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
expect(listNode.items.length).toBe(3);
|
||||
|
||||
expect(listNode.items[1].children.length).toBeGreaterThan(0);
|
||||
expect(listNode.items[1].children[0].type).toBe(NodeType.Text);
|
||||
expect((listNode.items[1].children[0] as TextNode).content).toBe('Item 2');
|
||||
});
|
||||
|
||||
test('list with very large numbers', () => {
|
||||
const input = '9999999. First item\n10000000. Second item';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
expect(listNode.items[0].ordinal).toBe(9999999);
|
||||
expect(listNode.items[1].ordinal).toBe(10000000);
|
||||
});
|
||||
|
||||
test('lists disabled by parser flags', () => {
|
||||
const input = '- Item 1\n- Item 2';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.Text);
|
||||
|
||||
expect((ast[0] as TextNode).content).toBe('- Item 1- Item 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Interaction with other block elements', () => {
|
||||
test('list adjacent to heading', () => {
|
||||
const input = '# Heading\n- List item\n## Next heading';
|
||||
const flags = ParserFlags.ALLOW_HEADINGS | ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(3);
|
||||
expect(ast[0].type).toBe(NodeType.Heading);
|
||||
expect(ast[1].type).toBe(NodeType.List);
|
||||
expect(ast[2].type).toBe(NodeType.Heading);
|
||||
});
|
||||
|
||||
test('list adjacent to blockquote', () => {
|
||||
const input = '> Blockquote\n- List item\n> Another blockquote';
|
||||
const flags = ParserFlags.ALLOW_BLOCKQUOTES | ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(3);
|
||||
expect(ast[0].type).toBe(NodeType.Blockquote);
|
||||
expect(ast[1].type).toBe(NodeType.List);
|
||||
expect(ast[2].type).toBe(NodeType.Blockquote);
|
||||
});
|
||||
|
||||
test('nested list with blank lines between items', () => {
|
||||
const input = '- Parent item\n\n - Child item 1\n\n - Child item 2';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBeGreaterThan(0);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
});
|
||||
|
||||
test('empty list continuation handling', () => {
|
||||
const input = '- Item 1\n Continuation text\n- Item 2';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
expect(listNode.items).toHaveLength(2);
|
||||
expect(listNode.items[0].children).toHaveLength(2);
|
||||
expect(listNode.items[0].children[1]).toEqual({
|
||||
type: NodeType.Text,
|
||||
content: 'Continuation text',
|
||||
});
|
||||
});
|
||||
|
||||
test('list with multiple continuation lines', () => {
|
||||
const input = '- Item 1\n Line 2\n Line 3\n- Item 2';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
expect(listNode.items[0].children).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('deeply nested list structure', () => {
|
||||
const input = '- Level 1\n - Level 2\n - Level 3\n Text continuation';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
});
|
||||
|
||||
test('list continuation with empty items array', () => {
|
||||
const input = 'Some text\nContinuation line\n- Actual list item';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(2);
|
||||
expect(ast[0].type).toBe(NodeType.Text);
|
||||
expect(ast[1].type).toBe(NodeType.List);
|
||||
});
|
||||
|
||||
test('invalid list marker patterns', () => {
|
||||
const invalidPatterns = [
|
||||
'1 Not a list item',
|
||||
'1.Not a list item',
|
||||
'- ',
|
||||
'1. ',
|
||||
' -Not a list',
|
||||
' 1.Not a list',
|
||||
];
|
||||
|
||||
for (const pattern of invalidPatterns) {
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(pattern, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
if (pattern.trim() === '-' || pattern.trim() === '1.') {
|
||||
continue;
|
||||
}
|
||||
expect(ast[0].type).toBe(NodeType.Text);
|
||||
}
|
||||
});
|
||||
|
||||
test('complex list with nested bullets and formatting', () => {
|
||||
const input =
|
||||
'1. **Bold list**\n2. *Italic list*\n3. - Nested bullet\n * **Bold nested**\n4. Text with\n line breaks \n **and bold**';
|
||||
const flags = ParserFlags.ALLOW_LISTS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toHaveLength(1);
|
||||
expect(ast[0].type).toBe(NodeType.List);
|
||||
|
||||
const listNode = ast[0] as ListNode;
|
||||
expect(listNode.ordered).toBe(true);
|
||||
expect(listNode.items.length).toBe(4);
|
||||
|
||||
expect(listNode.items[0].ordinal).toBe(1);
|
||||
expect(listNode.items[0].children.length).toBe(1);
|
||||
expect(listNode.items[0].children[0].type).toBe(NodeType.Strong);
|
||||
|
||||
expect(listNode.items[1].ordinal).toBe(2);
|
||||
expect(listNode.items[1].children.length).toBe(1);
|
||||
expect(listNode.items[1].children[0].type).toBe(NodeType.Emphasis);
|
||||
|
||||
expect(listNode.items[2].ordinal).toBe(3);
|
||||
expect(listNode.items[2].children.length).toBe(1);
|
||||
expect(listNode.items[2].children[0].type).toBe(NodeType.List);
|
||||
|
||||
const nestedList = listNode.items[2].children[0] as ListNode;
|
||||
expect(nestedList.ordered).toBe(false);
|
||||
expect(nestedList.items.length).toBe(2);
|
||||
|
||||
expect(nestedList.items[0].children.length).toBe(1);
|
||||
expect(nestedList.items[0].children[0].type).toBe(NodeType.Text);
|
||||
expect((nestedList.items[0].children[0] as TextNode).content).toBe('Nested bullet');
|
||||
|
||||
expect(nestedList.items[1].children.length).toBe(1);
|
||||
expect(nestedList.items[1].children[0].type).toBe(NodeType.Strong);
|
||||
|
||||
expect(listNode.items[3].ordinal).toBe(4);
|
||||
expect(listNode.items[3].children.length).toBeGreaterThan(1);
|
||||
|
||||
const item4Children = listNode.items[3].children;
|
||||
expect(item4Children.some((child) => child.type === NodeType.Text)).toBe(true);
|
||||
expect(item4Children.some((child) => child.type === NodeType.Strong)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
439
fluxer_app/src/lib/markdown/parser/parsers/list-parsers.ts
Normal file
439
fluxer_app/src/lib/markdown/parser/parsers/list-parsers.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {MAX_AST_NODES} from '../types/constants';
|
||||
import {NodeType} from '../types/enums';
|
||||
import type {ListItem, ListNode, Node} from '../types/nodes';
|
||||
import {parseCodeBlock} from './block-parsers';
|
||||
|
||||
interface ListParseResult {
|
||||
node: ListNode;
|
||||
newLineIndex: number;
|
||||
newNodeCount: number;
|
||||
}
|
||||
|
||||
export function parseList(
|
||||
lines: Array<string>,
|
||||
currentLineIndex: number,
|
||||
isOrdered: boolean,
|
||||
indentLevel: number,
|
||||
depth: number,
|
||||
parserFlags: number,
|
||||
nodeCount: number,
|
||||
parseInline: (text: string) => Array<Node>,
|
||||
): ListParseResult {
|
||||
const items: Array<ListItem> = [];
|
||||
const startLine = currentLineIndex;
|
||||
const firstLineContent = lines[startLine];
|
||||
let newLineIndex = currentLineIndex;
|
||||
let newNodeCount = nodeCount;
|
||||
|
||||
while (newLineIndex < lines.length) {
|
||||
if (newNodeCount > MAX_AST_NODES) break;
|
||||
const currentLine = lines[newLineIndex];
|
||||
const trimmed = currentLine.trimStart();
|
||||
|
||||
if (isBlockBreak(trimmed)) break;
|
||||
|
||||
const listMatch = matchListItem(currentLine);
|
||||
|
||||
if (listMatch) {
|
||||
const [itemOrdered, itemIndent, content, ordinal] = listMatch;
|
||||
|
||||
if (itemIndent < indentLevel) break;
|
||||
|
||||
if (itemIndent === indentLevel) {
|
||||
if (itemOrdered !== isOrdered) {
|
||||
if (newLineIndex === startLine) {
|
||||
const simpleList = createSimpleList(firstLineContent);
|
||||
|
||||
return {
|
||||
node: simpleList,
|
||||
newLineIndex: newLineIndex + 1,
|
||||
newNodeCount: newNodeCount + 1,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const result = handleSameIndentLevel(
|
||||
items,
|
||||
content,
|
||||
indentLevel,
|
||||
depth,
|
||||
parseInline,
|
||||
(parentIndent, depth) => {
|
||||
const tryResult = tryParseNestedContent(
|
||||
lines,
|
||||
newLineIndex + 1,
|
||||
parentIndent,
|
||||
depth,
|
||||
(isOrdered, indentLevel, depth) =>
|
||||
parseList(
|
||||
lines,
|
||||
newLineIndex + 1,
|
||||
isOrdered,
|
||||
indentLevel,
|
||||
depth,
|
||||
parserFlags,
|
||||
newNodeCount,
|
||||
parseInline,
|
||||
),
|
||||
);
|
||||
|
||||
return tryResult;
|
||||
},
|
||||
newLineIndex,
|
||||
ordinal,
|
||||
);
|
||||
|
||||
newLineIndex = result.newLineIndex;
|
||||
newNodeCount = result.newNodeCount;
|
||||
} else if (itemIndent === indentLevel + 1) {
|
||||
const result = handleNestedIndentLevel(
|
||||
items,
|
||||
currentLine,
|
||||
itemOrdered,
|
||||
itemIndent,
|
||||
depth,
|
||||
(isOrdered, indentLevel, depth) =>
|
||||
parseList(lines, newLineIndex, isOrdered, indentLevel, depth, parserFlags, newNodeCount, parseInline),
|
||||
newLineIndex,
|
||||
newNodeCount,
|
||||
);
|
||||
|
||||
newLineIndex = result.newLineIndex;
|
||||
newNodeCount = result.newNodeCount;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else if (isBulletPointText(currentLine)) {
|
||||
const result = handleBulletPointText(items, currentLine, newLineIndex, newNodeCount);
|
||||
|
||||
newLineIndex = result.newLineIndex;
|
||||
newNodeCount = result.newNodeCount;
|
||||
} else if (isListContinuation(currentLine, indentLevel)) {
|
||||
const result = handleListContinuation(items, currentLine, newLineIndex, newNodeCount, parseInline);
|
||||
|
||||
newLineIndex = result.newLineIndex;
|
||||
newNodeCount = result.newNodeCount;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
if (items.length > MAX_AST_NODES) break;
|
||||
}
|
||||
|
||||
if (items.length === 0 && newLineIndex === startLine) {
|
||||
const simpleList = createSimpleList(firstLineContent);
|
||||
|
||||
return {
|
||||
node: simpleList,
|
||||
newLineIndex: newLineIndex + 1,
|
||||
newNodeCount: newNodeCount + 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.List,
|
||||
ordered: isOrdered,
|
||||
items,
|
||||
},
|
||||
newLineIndex,
|
||||
newNodeCount,
|
||||
};
|
||||
}
|
||||
|
||||
function isBlockBreak(trimmed: string): boolean {
|
||||
return trimmed.startsWith('#') || trimmed.startsWith('>') || trimmed.startsWith('>>> ');
|
||||
}
|
||||
|
||||
function createSimpleList(content: string): ListNode {
|
||||
return {
|
||||
type: NodeType.List,
|
||||
ordered: false,
|
||||
items: [{children: [{type: NodeType.Text, content}]}],
|
||||
};
|
||||
}
|
||||
|
||||
function handleSameIndentLevel(
|
||||
items: Array<ListItem>,
|
||||
content: string,
|
||||
indentLevel: number,
|
||||
depth: number,
|
||||
parseInline: (text: string) => Array<Node>,
|
||||
tryParseNestedContent: (parentIndent: number, depth: number) => {node: Node | null; newLineIndex: number},
|
||||
currentLineIndex: number,
|
||||
ordinal?: number,
|
||||
): {newItems: Array<ListItem>; newLineIndex: number; newNodeCount: number} {
|
||||
const itemNodes: Array<Node> = [];
|
||||
let newNodeCount = 0;
|
||||
let newLineIndex = currentLineIndex + 1;
|
||||
|
||||
const contentListMatch = matchListItem(content);
|
||||
if (contentListMatch) {
|
||||
const nestedContent = tryParseNestedContent(indentLevel, depth);
|
||||
|
||||
const [isInlineOrdered, _, inlineItemContent] = contentListMatch;
|
||||
const inlineItemNodes = parseInline(inlineItemContent);
|
||||
|
||||
const nestedListItems: Array<ListItem> = [{children: inlineItemNodes}];
|
||||
|
||||
if (nestedContent.node && nestedContent.node.type === NodeType.List) {
|
||||
const nestedList = nestedContent.node as ListNode;
|
||||
nestedListItems.push(...nestedList.items);
|
||||
newLineIndex = nestedContent.newLineIndex;
|
||||
}
|
||||
|
||||
const nestedList: ListNode = {
|
||||
type: NodeType.List,
|
||||
ordered: isInlineOrdered,
|
||||
items: nestedListItems,
|
||||
};
|
||||
|
||||
itemNodes.push(nestedList);
|
||||
newNodeCount++;
|
||||
} else {
|
||||
const parsedNodes = parseInline(content);
|
||||
itemNodes.push(...parsedNodes);
|
||||
newNodeCount = itemNodes.length;
|
||||
|
||||
const nestedContent = tryParseNestedContent(indentLevel, depth);
|
||||
if (nestedContent.node) {
|
||||
itemNodes.push(nestedContent.node);
|
||||
newNodeCount++;
|
||||
newLineIndex = nestedContent.newLineIndex;
|
||||
}
|
||||
}
|
||||
|
||||
items.push({
|
||||
children: itemNodes,
|
||||
...(ordinal !== undefined ? {ordinal} : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
newItems: items,
|
||||
newLineIndex,
|
||||
newNodeCount,
|
||||
};
|
||||
}
|
||||
|
||||
function handleNestedIndentLevel(
|
||||
items: Array<ListItem>,
|
||||
currentLine: string,
|
||||
isOrdered: boolean,
|
||||
indentLevel: number,
|
||||
depth: number,
|
||||
parseList: (isOrdered: boolean, indentLevel: number, depth: number) => ListParseResult,
|
||||
currentLineIndex: number,
|
||||
nodeCount: number,
|
||||
): {newItems: Array<ListItem>; newLineIndex: number; newNodeCount: number} {
|
||||
if (depth >= 9) {
|
||||
if (items.length > 0) {
|
||||
items[items.length - 1].children.push({
|
||||
type: NodeType.Text,
|
||||
content: currentLine.trim(),
|
||||
});
|
||||
return {
|
||||
newItems: items,
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
}
|
||||
return {
|
||||
newItems: items,
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount,
|
||||
};
|
||||
}
|
||||
|
||||
const nested = parseList(isOrdered, indentLevel, depth + 1);
|
||||
|
||||
if (items.length > 0) {
|
||||
items[items.length - 1].children.push(nested.node);
|
||||
}
|
||||
|
||||
return {
|
||||
newItems: items,
|
||||
newLineIndex: nested.newLineIndex,
|
||||
newNodeCount: nested.newNodeCount,
|
||||
};
|
||||
}
|
||||
|
||||
function handleBulletPointText(
|
||||
items: Array<ListItem>,
|
||||
currentLine: string,
|
||||
currentLineIndex: number,
|
||||
nodeCount: number,
|
||||
): {newItems: Array<ListItem>; newLineIndex: number; newNodeCount: number} {
|
||||
if (items.length > 0) {
|
||||
items[items.length - 1].children.push({
|
||||
type: NodeType.Text,
|
||||
content: currentLine.trim(),
|
||||
});
|
||||
return {
|
||||
newItems: items,
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
newItems: items,
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount,
|
||||
};
|
||||
}
|
||||
|
||||
function handleListContinuation(
|
||||
items: Array<ListItem>,
|
||||
currentLine: string,
|
||||
currentLineIndex: number,
|
||||
nodeCount: number,
|
||||
parseInline: (text: string) => Array<Node>,
|
||||
): {newItems: Array<ListItem>; newLineIndex: number; newNodeCount: number} {
|
||||
if (items.length > 0) {
|
||||
const content = currentLine.trimStart();
|
||||
const parsedNodes = parseInline(content);
|
||||
items[items.length - 1].children.push(...parsedNodes);
|
||||
return {
|
||||
newItems: items,
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount + parsedNodes.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
newItems: items,
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount,
|
||||
};
|
||||
}
|
||||
|
||||
function tryParseNestedContent(
|
||||
lines: Array<string>,
|
||||
currentLineIndex: number,
|
||||
parentIndent: number,
|
||||
depth: number,
|
||||
parseListFactory: (isOrdered: boolean, indentLevel: number, depth: number) => ListParseResult,
|
||||
): {node: Node | null; newLineIndex: number} {
|
||||
if (currentLineIndex >= lines.length) return {node: null, newLineIndex: currentLineIndex};
|
||||
|
||||
const line = lines[currentLineIndex];
|
||||
const trimmed = line.trimStart();
|
||||
|
||||
if (trimmed.startsWith('```')) {
|
||||
const result = parseCodeBlock(lines, currentLineIndex);
|
||||
|
||||
return {
|
||||
node: result.node,
|
||||
newLineIndex: result.newLineIndex,
|
||||
};
|
||||
}
|
||||
|
||||
const listMatch = matchListItem(line);
|
||||
|
||||
if (listMatch) {
|
||||
const [isOrdered, indent, _] = listMatch;
|
||||
if (indent > parentIndent && depth < 9) {
|
||||
const result = parseListFactory(isOrdered, indent, depth + 1);
|
||||
return {
|
||||
node: result.node,
|
||||
newLineIndex: result.newLineIndex,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {node: null, newLineIndex: currentLineIndex};
|
||||
}
|
||||
|
||||
function isListContinuation(line: string, indentLevel: number): boolean {
|
||||
let spaceCount = 0;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
if (line[i] === ' ') spaceCount++;
|
||||
else break;
|
||||
}
|
||||
return spaceCount > indentLevel * 2;
|
||||
}
|
||||
|
||||
function isBulletPointText(text: string): boolean {
|
||||
const listMatch = matchListItem(text);
|
||||
if (listMatch) return false;
|
||||
|
||||
const trimmed = text.trimStart();
|
||||
return trimmed.startsWith('- ') && !text.startsWith(' ');
|
||||
}
|
||||
|
||||
export function matchListItem(line: string): [boolean, number, string, number?] | null {
|
||||
let indent = 0;
|
||||
let pos = 0;
|
||||
|
||||
while (pos < line.length && line[pos] === ' ') {
|
||||
indent++;
|
||||
pos++;
|
||||
}
|
||||
|
||||
if (indent > 0 && indent < 2) return null;
|
||||
const indentLevel = Math.floor(indent / 2);
|
||||
|
||||
if (pos >= line.length) return null;
|
||||
|
||||
const marker = line[pos];
|
||||
if (marker === '*' || marker === '-') {
|
||||
return handleUnorderedListMarker(line, pos, indentLevel);
|
||||
}
|
||||
if (/[0-9]/.test(marker)) {
|
||||
return handleOrderedListMarker(line, pos, indentLevel);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleUnorderedListMarker(
|
||||
line: string,
|
||||
pos: number,
|
||||
indentLevel: number,
|
||||
): [boolean, number, string, undefined] | null {
|
||||
if (line[pos + 1] === ' ') {
|
||||
return [false, indentLevel, line.slice(pos + 2), undefined];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleOrderedListMarker(
|
||||
line: string,
|
||||
pos: number,
|
||||
indentLevel: number,
|
||||
): [boolean, number, string, number] | null {
|
||||
let currentPos = pos;
|
||||
let ordinalStr = '';
|
||||
|
||||
while (currentPos < line.length && /[0-9]/.test(line[currentPos])) {
|
||||
ordinalStr += line[currentPos];
|
||||
currentPos++;
|
||||
}
|
||||
|
||||
if (line[currentPos] === '.' && line[currentPos + 1] === ' ') {
|
||||
const ordinal = Number.parseInt(ordinalStr, 10);
|
||||
return [true, indentLevel, line.slice(currentPos + 2), ordinal];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {describe, expect, test} from 'vitest';
|
||||
import {Parser} from '../parser/parser';
|
||||
import {GuildNavKind, MentionKind, NodeType, ParserFlags} from '../types/enums';
|
||||
|
||||
describe('Fluxer Markdown Parser', () => {
|
||||
test('user mentions', () => {
|
||||
const input = 'Hello <@1234567890> and <@!9876543210>';
|
||||
const flags = ParserFlags.ALLOW_USER_MENTIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Hello '},
|
||||
{type: NodeType.Mention, kind: {kind: MentionKind.User, id: '1234567890'}},
|
||||
{type: NodeType.Text, content: ' and '},
|
||||
{type: NodeType.Mention, kind: {kind: MentionKind.User, id: '9876543210'}},
|
||||
]);
|
||||
});
|
||||
|
||||
test('channel mention', () => {
|
||||
const input = 'Please check <#103735883630395392>';
|
||||
const flags = ParserFlags.ALLOW_CHANNEL_MENTIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Please check '},
|
||||
{type: NodeType.Mention, kind: {kind: MentionKind.Channel, id: '103735883630395392'}},
|
||||
]);
|
||||
});
|
||||
|
||||
test('role mention', () => {
|
||||
const input = 'This is for <@&165511591545143296>';
|
||||
const flags = ParserFlags.ALLOW_ROLE_MENTIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'This is for '},
|
||||
{type: NodeType.Mention, kind: {kind: MentionKind.Role, id: '165511591545143296'}},
|
||||
]);
|
||||
});
|
||||
|
||||
test('slash command mention', () => {
|
||||
const input = 'Use </airhorn:816437322781949972>';
|
||||
const flags = ParserFlags.ALLOW_COMMAND_MENTIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Use '},
|
||||
{
|
||||
type: NodeType.Mention,
|
||||
kind: {
|
||||
kind: MentionKind.Command,
|
||||
name: 'airhorn',
|
||||
subcommandGroup: undefined,
|
||||
subcommand: undefined,
|
||||
id: '816437322781949972',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('slash command with subcommands', () => {
|
||||
const input = 'Try </app group sub:1234567890>';
|
||||
const flags = ParserFlags.ALLOW_COMMAND_MENTIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Try '},
|
||||
{
|
||||
type: NodeType.Mention,
|
||||
kind: {
|
||||
kind: MentionKind.Command,
|
||||
name: 'app',
|
||||
subcommandGroup: 'group',
|
||||
subcommand: 'sub',
|
||||
id: '1234567890',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('guild nav customize', () => {
|
||||
const input = 'Go to <id:customize> now!';
|
||||
const flags = ParserFlags.ALLOW_GUILD_NAVIGATIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Go to '},
|
||||
{type: NodeType.Mention, kind: {kind: MentionKind.GuildNavigation, navigationType: GuildNavKind.Customize}},
|
||||
{type: NodeType.Text, content: ' now!'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('guild nav linked roles', () => {
|
||||
const input = 'Check <id:linked-roles:123456> settings';
|
||||
const flags = ParserFlags.ALLOW_GUILD_NAVIGATIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Check '},
|
||||
{
|
||||
type: NodeType.Mention,
|
||||
kind: {
|
||||
kind: MentionKind.GuildNavigation,
|
||||
navigationType: GuildNavKind.LinkedRoles,
|
||||
id: '123456',
|
||||
},
|
||||
},
|
||||
{type: NodeType.Text, content: ' settings'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('invalid guild nav', () => {
|
||||
const input = 'Invalid <12345:customize>';
|
||||
const flags = ParserFlags.ALLOW_GUILD_NAVIGATIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: 'Invalid <12345:customize>'}]);
|
||||
});
|
||||
|
||||
test('everyone and here mentions', () => {
|
||||
const input = '@everyone and @here are both important.';
|
||||
const flags = ParserFlags.ALLOW_EVERYONE_MENTIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Mention, kind: {kind: MentionKind.Everyone}},
|
||||
{type: NodeType.Text, content: ' and '},
|
||||
{type: NodeType.Mention, kind: {kind: MentionKind.Here}},
|
||||
{type: NodeType.Text, content: ' are both important.'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('escaped everyone and here mentions', () => {
|
||||
const input = '\\@everyone and \\@here should not be parsed.';
|
||||
const flags = ParserFlags.ALLOW_EVERYONE_MENTIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '@everyone and @here should not be parsed.'}]);
|
||||
});
|
||||
|
||||
test('mentions inside inline code', () => {
|
||||
const input = '`@everyone` and `@here` should remain unchanged.';
|
||||
const flags = ParserFlags.ALLOW_EVERYONE_MENTIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.InlineCode, content: '@everyone'},
|
||||
{type: NodeType.Text, content: ' and '},
|
||||
{type: NodeType.InlineCode, content: '@here'},
|
||||
{type: NodeType.Text, content: ' should remain unchanged.'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('mentions inside code block', () => {
|
||||
const input = '```\n@everyone\n@here\n```';
|
||||
const flags = ParserFlags.ALLOW_EVERYONE_MENTIONS | ParserFlags.ALLOW_CODE_BLOCKS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.CodeBlock,
|
||||
language: undefined,
|
||||
content: '@everyone\n@here\n',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('mentions with flags disabled', () => {
|
||||
const input = '@everyone and @here should not be parsed.';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '@everyone and @here should not be parsed.'}]);
|
||||
});
|
||||
|
||||
test('mentions followed by punctuation', () => {
|
||||
const input = 'Hello @everyone! Are you there, @here?';
|
||||
const flags = ParserFlags.ALLOW_EVERYONE_MENTIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Hello '},
|
||||
{type: NodeType.Mention, kind: {kind: MentionKind.Everyone}},
|
||||
{type: NodeType.Text, content: '! Are you there, '},
|
||||
{type: NodeType.Mention, kind: {kind: MentionKind.Here}},
|
||||
{type: NodeType.Text, content: '?'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('mentions adjacent to other symbols', () => {
|
||||
const input = 'Check this out:@everyone@here!';
|
||||
const flags = ParserFlags.ALLOW_EVERYONE_MENTIONS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Check this out:'},
|
||||
{type: NodeType.Mention, kind: {kind: MentionKind.Everyone}},
|
||||
{type: NodeType.Mention, kind: {kind: MentionKind.Here}},
|
||||
{type: NodeType.Text, content: '!'},
|
||||
]);
|
||||
});
|
||||
});
|
||||
206
fluxer_app/src/lib/markdown/parser/parsers/mention-parsers.ts
Normal file
206
fluxer_app/src/lib/markdown/parser/parsers/mention-parsers.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {GuildNavKind, MentionKind, NodeType, ParserFlags} from '../types/enums';
|
||||
import type {MentionNode, ParserResult} from '../types/nodes';
|
||||
|
||||
const LESS_THAN = 60;
|
||||
const AT_SIGN = 64;
|
||||
const HASH = 35;
|
||||
const AMPERSAND = 38;
|
||||
const SLASH = 47;
|
||||
const LETTER_I = 105;
|
||||
const LETTER_D = 100;
|
||||
const COLON = 58;
|
||||
const DIGIT_ZERO = 48;
|
||||
const DIGIT_NINE = 57;
|
||||
|
||||
export function parseMention(text: string, parserFlags: number): ParserResult | null {
|
||||
if (text.length < 2 || text.charCodeAt(0) !== LESS_THAN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const end = text.indexOf('>');
|
||||
if (end === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const secondCharCode = text.charCodeAt(1);
|
||||
|
||||
let mentionNode: MentionNode | null = null;
|
||||
|
||||
if (secondCharCode === AT_SIGN) {
|
||||
mentionNode = parseUserOrRoleMention(text.slice(1, end), parserFlags);
|
||||
} else if (secondCharCode === HASH) {
|
||||
mentionNode = parseChannelMention(text.slice(1, end), parserFlags);
|
||||
} else if (secondCharCode === SLASH) {
|
||||
mentionNode = parseCommandMention(text.slice(1, end), parserFlags);
|
||||
} else if (
|
||||
secondCharCode === LETTER_I &&
|
||||
text.length > 3 &&
|
||||
text.charCodeAt(2) === LETTER_D &&
|
||||
text.charCodeAt(3) === COLON
|
||||
) {
|
||||
mentionNode = parseGuildNavigation(text.slice(1, end), parserFlags);
|
||||
}
|
||||
|
||||
return mentionNode ? {node: mentionNode, advance: end + 1} : null;
|
||||
}
|
||||
|
||||
function isDigitOnly(text: string): boolean {
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const charCode = text.charCodeAt(i);
|
||||
if (charCode < DIGIT_ZERO || charCode > DIGIT_NINE) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return text.length > 0;
|
||||
}
|
||||
|
||||
function parseUserOrRoleMention(inner: string, parserFlags: number): MentionNode | null {
|
||||
if (inner.length < 2 || inner.charCodeAt(0) !== AT_SIGN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (inner.length > 2 && inner.charCodeAt(1) === AMPERSAND) {
|
||||
const roleId = inner.slice(2);
|
||||
if (isDigitOnly(roleId) && parserFlags & ParserFlags.ALLOW_ROLE_MENTIONS) {
|
||||
return {
|
||||
type: NodeType.Mention,
|
||||
kind: {kind: MentionKind.Role, id: roleId},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const userId = inner.startsWith('@!') ? inner.slice(2) : inner.slice(1);
|
||||
if (isDigitOnly(userId) && parserFlags & ParserFlags.ALLOW_USER_MENTIONS) {
|
||||
return {
|
||||
type: NodeType.Mention,
|
||||
kind: {kind: MentionKind.User, id: userId},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseChannelMention(inner: string, parserFlags: number): MentionNode | null {
|
||||
if (inner.length < 2 || inner.charCodeAt(0) !== HASH || !(parserFlags & ParserFlags.ALLOW_CHANNEL_MENTIONS)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const channelId = inner.slice(1);
|
||||
if (isDigitOnly(channelId)) {
|
||||
return {
|
||||
type: NodeType.Mention,
|
||||
kind: {kind: MentionKind.Channel, id: channelId},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCommandMention(inner: string, parserFlags: number): MentionNode | null {
|
||||
if (!(parserFlags & ParserFlags.ALLOW_COMMAND_MENTIONS) || inner.length < 2 || inner.charCodeAt(0) !== SLASH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const colonIndex = inner.indexOf(':');
|
||||
if (colonIndex === -1) return null;
|
||||
|
||||
const commandPart = inner.slice(0, colonIndex);
|
||||
const idPart = inner.slice(colonIndex + 1);
|
||||
|
||||
if (!idPart || !isDigitOnly(idPart)) return null;
|
||||
|
||||
const segments = commandPart.slice(1).trim().split(' ');
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
return {
|
||||
type: NodeType.Mention,
|
||||
kind: {
|
||||
kind: MentionKind.Command,
|
||||
name: segments[0],
|
||||
subcommandGroup: segments.length === 3 ? segments[1] : undefined,
|
||||
subcommand: segments.length >= 2 ? segments[segments.length - 1] : undefined,
|
||||
id: idPart,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseGuildNavigation(inner: string, parserFlags: number): MentionNode | null {
|
||||
if (!(parserFlags & ParserFlags.ALLOW_GUILD_NAVIGATIONS) || inner.length < 5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (inner.charCodeAt(0) !== LETTER_I || inner.charCodeAt(1) !== LETTER_D || inner.charCodeAt(2) !== COLON) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = inner.split(':');
|
||||
if (parts.length < 2 || parts.length > 3) return null;
|
||||
|
||||
const [idLabel, navType, navId] = parts;
|
||||
if (idLabel !== 'id') return null;
|
||||
|
||||
const navigationType = getNavigationType(navType);
|
||||
if (!navigationType) return null;
|
||||
|
||||
if (navigationType === GuildNavKind.LinkedRoles) {
|
||||
return createLinkedRolesNavigation(parts.length === 3 ? navId : undefined);
|
||||
}
|
||||
|
||||
if (parts.length !== 2) return null;
|
||||
return createBasicNavigation(navigationType);
|
||||
}
|
||||
|
||||
function getNavigationType(navTypeLower: string): GuildNavKind | null {
|
||||
switch (navTypeLower) {
|
||||
case 'customize':
|
||||
return GuildNavKind.Customize;
|
||||
case 'browse':
|
||||
return GuildNavKind.Browse;
|
||||
case 'guide':
|
||||
return GuildNavKind.Guide;
|
||||
case 'linked-roles':
|
||||
return GuildNavKind.LinkedRoles;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createLinkedRolesNavigation(id?: string): MentionNode {
|
||||
return {
|
||||
type: NodeType.Mention,
|
||||
kind: {
|
||||
kind: MentionKind.GuildNavigation,
|
||||
navigationType: GuildNavKind.LinkedRoles,
|
||||
id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createBasicNavigation(navigationType: GuildNavKind): MentionNode {
|
||||
return {
|
||||
type: NodeType.Mention,
|
||||
kind: {
|
||||
kind: MentionKind.GuildNavigation,
|
||||
navigationType,
|
||||
},
|
||||
};
|
||||
}
|
||||
306
fluxer_app/src/lib/markdown/parser/parsers/table-parsers.test.ts
Normal file
306
fluxer_app/src/lib/markdown/parser/parsers/table-parsers.test.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {describe, expect, test} from 'vitest';
|
||||
import {Parser} from '../parser/parser';
|
||||
import {NodeType, ParserFlags, TableAlignment} from '../types/enums';
|
||||
import type {InlineCodeNode, TableNode, TextNode} from '../types/nodes';
|
||||
|
||||
describe('Fluxer Markdown Parser', () => {
|
||||
describe('Table Parser', () => {
|
||||
test('basic table', () => {
|
||||
const input = `| Header 1 | Header 2 |
|
||||
|----------|----------|
|
||||
| Cell 1 | Cell 2 |
|
||||
| Cell 3 | Cell 4 |`;
|
||||
|
||||
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBe(1);
|
||||
expect(ast[0].type).toBe(NodeType.Table);
|
||||
|
||||
const tableNode = ast[0] as TableNode;
|
||||
expect(tableNode.header.cells.length).toBe(2);
|
||||
expect(tableNode.rows.length).toBe(2);
|
||||
|
||||
expect(tableNode.header.cells[0].children[0].type).toBe(NodeType.Text);
|
||||
expect((tableNode.header.cells[0].children[0] as TextNode).content).toBe('Header 1');
|
||||
|
||||
expect(tableNode.rows[0].cells[0].children[0].type).toBe(NodeType.Text);
|
||||
expect((tableNode.rows[0].cells[0].children[0] as TextNode).content).toBe('Cell 1');
|
||||
});
|
||||
|
||||
test('table with alignments', () => {
|
||||
const input = `| Left | Center | Right |
|
||||
|:-----|:------:|------:|
|
||||
| 1 | 2 | 3 |`;
|
||||
|
||||
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBe(1);
|
||||
const tableNode = ast[0] as TableNode;
|
||||
|
||||
expect(tableNode.alignments).toEqual([TableAlignment.Left, TableAlignment.Center, TableAlignment.Right]);
|
||||
});
|
||||
|
||||
test('table with escaped pipes', () => {
|
||||
const input = `| Function | Integral |
|
||||
|----------|----------|
|
||||
| 1/x | ln\\|x\\| + C |
|
||||
| tan x | -ln\\|cos x\\| + C |`;
|
||||
|
||||
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBe(1);
|
||||
expect(ast[0].type).toBe(NodeType.Table);
|
||||
|
||||
const tableNode = ast[0] as TableNode;
|
||||
expect(tableNode.rows.length).toBe(2);
|
||||
|
||||
const cell1 = tableNode.rows[0].cells[1].children[0] as TextNode;
|
||||
const cell2 = tableNode.rows[1].cells[1].children[0] as TextNode;
|
||||
|
||||
expect(cell1.content).toBe('ln|x| + C');
|
||||
expect(cell2.content).toBe('-ln|cos x| + C');
|
||||
});
|
||||
|
||||
test('table with formatted content', () => {
|
||||
const input = `| Formatting | Example |
|
||||
|------------|---------|
|
||||
| **Bold** | *Italic* |
|
||||
| ~~Strike~~ | \`Code\` |`;
|
||||
|
||||
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBe(1);
|
||||
const tableNode = ast[0] as TableNode;
|
||||
|
||||
expect(tableNode.rows[0].cells[0].children[0].type).toBe(NodeType.Strong);
|
||||
expect(tableNode.rows[0].cells[1].children[0].type).toBe(NodeType.Emphasis);
|
||||
expect(tableNode.rows[1].cells[0].children[0].type).toBe(NodeType.Strikethrough);
|
||||
expect(tableNode.rows[1].cells[1].children[0].type).toBe(NodeType.InlineCode);
|
||||
});
|
||||
|
||||
test('multi-row integral table with escaped pipes', () => {
|
||||
const input = `| Funktion | Integral |
|
||||
|----------|----------|
|
||||
| x^n (n ≠ -1) | x^(n+1)/(n+1) + C |
|
||||
| 1/x | ln\\|x\\| + C |
|
||||
| e^x | e^x + C |
|
||||
| a^x | a^x/ln a + C |
|
||||
| sin x | -cos x + C |
|
||||
| cos x | sin x + C |
|
||||
| tan x | -ln\\|cos x\\| + C |
|
||||
| sec²x | tan x + C |
|
||||
| 1/√(1-x²) | arcsin x + C |
|
||||
| 1/(1+x²) | arctan x + C |`;
|
||||
|
||||
const flags = ParserFlags.ALLOW_TABLES;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBe(1);
|
||||
expect(ast[0].type).toBe(NodeType.Table);
|
||||
|
||||
const tableNode = ast[0] as TableNode;
|
||||
expect(tableNode.rows.length).toBe(10);
|
||||
|
||||
const row3Cell = tableNode.rows[1].cells[1].children[0] as TextNode;
|
||||
const row7Cell = tableNode.rows[6].cells[1].children[0] as TextNode;
|
||||
|
||||
expect(row3Cell.content).toBe('ln|x| + C');
|
||||
expect(row7Cell.content).toBe('-ln|cos x| + C');
|
||||
});
|
||||
|
||||
test('absolute value notation with escaped pipes', () => {
|
||||
const input = `| Expression | Value |
|
||||
|------------|-------|
|
||||
| \\|x\\| | Absolute value of x |
|
||||
| \\|\\|x\\|\\| | Double absolute value |
|
||||
| \\|x + y\\| | Absolute value of sum |`;
|
||||
|
||||
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBe(1);
|
||||
const tableNode = ast[0] as TableNode;
|
||||
expect(tableNode.rows.length).toBe(3);
|
||||
|
||||
const cell1 = tableNode.rows[0].cells[0].children[0] as TextNode;
|
||||
const cell2 = tableNode.rows[1].cells[0].children[0] as TextNode;
|
||||
const cell3 = tableNode.rows[2].cells[0].children[0] as TextNode;
|
||||
|
||||
expect(cell1.content).toBe('|x|');
|
||||
expect(cell2.content).toBe('||x||');
|
||||
expect(cell3.content).toBe('|x + y|');
|
||||
});
|
||||
|
||||
test('mixed mathematical notations with escaped pipes', () => {
|
||||
const input = `| Function | Example |
|
||||
|----------|---------|
|
||||
| log | log\\|x\\| |
|
||||
| ln | ln\\|x\\| |
|
||||
| sin | sin(\\|x\\|) |
|
||||
| abs | \\|f(x)\\| |
|
||||
| complex | \\|x\\|² + \\|y\\|² |`;
|
||||
|
||||
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBe(1);
|
||||
const tableNode = ast[0] as TableNode;
|
||||
expect(tableNode.rows.length).toBe(5);
|
||||
|
||||
expect((tableNode.rows[0].cells[1].children[0] as TextNode).content).toBe('log|x|');
|
||||
expect((tableNode.rows[1].cells[1].children[0] as TextNode).content).toBe('ln|x|');
|
||||
expect((tableNode.rows[2].cells[1].children[0] as TextNode).content).toBe('sin(|x|)');
|
||||
expect((tableNode.rows[3].cells[1].children[0] as TextNode).content).toBe('|f(x)|');
|
||||
expect((tableNode.rows[4].cells[1].children[0] as TextNode).content).toBe('|x|² + |y|²');
|
||||
});
|
||||
|
||||
test('set notation with escaped pipes', () => {
|
||||
const input = `| Notation | Meaning |
|
||||
|----------|---------|
|
||||
| {x \\| x > 0} | Set of positive numbers |
|
||||
| \\|{x \\| x > 0}\\| | Cardinality of positive numbers |
|
||||
| A ∩ {x \\| \\|x\\| < 1} | Intersection with unit ball |`;
|
||||
|
||||
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBe(1);
|
||||
const tableNode = ast[0] as TableNode;
|
||||
expect(tableNode.rows.length).toBe(3);
|
||||
|
||||
expect((tableNode.rows[0].cells[0].children[0] as TextNode).content).toBe('{x | x > 0}');
|
||||
expect((tableNode.rows[1].cells[0].children[0] as TextNode).content).toBe('|{x | x > 0}|');
|
||||
expect((tableNode.rows[2].cells[0].children[0] as TextNode).content).toBe('A ∩ {x | |x| < 1}');
|
||||
});
|
||||
|
||||
test('table with links and code', () => {
|
||||
const input = `| Description | Example |
|
||||
|-------------|---------|
|
||||
| [Link](https://example.com) | \`code\` |
|
||||
| **[Bold Link](https://example.org)** | *\`inline code\`* |`;
|
||||
|
||||
const parser = new Parser(input, ParserFlags.ALLOW_TABLES | ParserFlags.ALLOW_MASKED_LINKS);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBe(1);
|
||||
const tableNode = ast[0] as TableNode;
|
||||
|
||||
expect(tableNode.rows[0].cells[0].children[0].type).toBe(NodeType.Link);
|
||||
expect(tableNode.rows[0].cells[1].children[0].type).toBe(NodeType.InlineCode);
|
||||
|
||||
expect(tableNode.rows[1].cells[0].children[0].type).toBe(NodeType.Strong);
|
||||
});
|
||||
|
||||
test('invalid table formats', () => {
|
||||
const inputs = ['| Header |\n| Cell |', '| H1 |\n|====|\n| C1 |', '| |\n|--|\n| |'];
|
||||
|
||||
for (const input of inputs) {
|
||||
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
const isTable = ast.some((node) => node.type === NodeType.Table);
|
||||
expect(isTable).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('table with empty cells', () => {
|
||||
const input = `| H1 | | H3 |
|
||||
|----|----|----|
|
||||
| C1 | | C3 |`;
|
||||
|
||||
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBe(1);
|
||||
expect(ast[0].type).toBe(NodeType.Table);
|
||||
|
||||
const tableNode = ast[0] as TableNode;
|
||||
const emptyCell = tableNode.rows[0].cells[1].children[0] as TextNode;
|
||||
expect(emptyCell.type).toBe(NodeType.Text);
|
||||
expect(emptyCell.content).toBe('');
|
||||
});
|
||||
|
||||
test('table with inconsistent column count', () => {
|
||||
const input = `| A | B | C |
|
||||
|---|---|---|
|
||||
| 1 | 2 |
|
||||
| 3 | 4 | 5 | 6 |`;
|
||||
|
||||
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBe(1);
|
||||
const tableNode = ast[0] as TableNode;
|
||||
|
||||
expect(tableNode.rows[0].cells.length).toBe(3);
|
||||
expect(tableNode.rows[1].cells.length).toBe(3);
|
||||
|
||||
const emptyCell = tableNode.rows[0].cells[2].children[0] as TextNode;
|
||||
expect(emptyCell.content).toBe('');
|
||||
|
||||
const mergedCell = tableNode.rows[1].cells[2].children[0] as TextNode;
|
||||
expect(mergedCell.content.includes('5')).toBe(true);
|
||||
});
|
||||
|
||||
test('table with pipes in code examples', () => {
|
||||
const input = `| Language | Pipe Example |
|
||||
|----------|-------------|
|
||||
| Bash | \`echo "hello" \\| grep "h"\` |
|
||||
| JavaScript | \`const x = condition \\|\\| defaultValue;\` |
|
||||
| C++ | \`if (a \\|\\| b && c) {...}\` |`;
|
||||
|
||||
const parser = new Parser(input, ParserFlags.ALLOW_TABLES);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.length).toBe(1);
|
||||
const tableNode = ast[0] as TableNode;
|
||||
|
||||
const cell1 = tableNode.rows[0].cells[1].children[0] as InlineCodeNode;
|
||||
const cell2 = tableNode.rows[1].cells[1].children[0] as InlineCodeNode;
|
||||
const cell3 = tableNode.rows[2].cells[1].children[0] as InlineCodeNode;
|
||||
|
||||
expect(cell1.type).toBe(NodeType.InlineCode);
|
||||
expect(cell2.type).toBe(NodeType.InlineCode);
|
||||
expect(cell3.type).toBe(NodeType.InlineCode);
|
||||
|
||||
expect(cell1.content).toContain('echo "hello" | grep "h"');
|
||||
expect(cell2.content).toContain('condition || defaultValue');
|
||||
expect(cell3.content).toContain('if (a || b && c)');
|
||||
});
|
||||
|
||||
test('tables disabled', () => {
|
||||
const input = `| Header 1 | Header 2 |
|
||||
|----------|----------|
|
||||
| Cell 1 | Cell 2 |`;
|
||||
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast.every((node) => node.type === NodeType.Text)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
329
fluxer_app/src/lib/markdown/parser/parsers/table-parsers.ts
Normal file
329
fluxer_app/src/lib/markdown/parser/parsers/table-parsers.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {NodeType, TableAlignment} from '../types/enums';
|
||||
import type {Node, TableCellNode, TableNode, TableRowNode} from '../types/nodes';
|
||||
|
||||
interface TableParseResult {
|
||||
node: TableNode | null;
|
||||
newLineIndex: number;
|
||||
}
|
||||
|
||||
const PIPE = 124;
|
||||
const SPACE = 32;
|
||||
const BACKSLASH = 92;
|
||||
const DASH = 45;
|
||||
const COLON = 58;
|
||||
const HASH = 35;
|
||||
const GREATER_THAN = 62;
|
||||
const ASTERISK = 42;
|
||||
const DIGIT_0 = 48;
|
||||
const DIGIT_9 = 57;
|
||||
const PERIOD = 46;
|
||||
|
||||
const MAX_CACHE_SIZE = 1000;
|
||||
|
||||
const inlineContentCache = new Map<string, Array<Node>>();
|
||||
|
||||
export function parseTable(
|
||||
lines: Array<string>,
|
||||
currentLineIndex: number,
|
||||
_parserFlags: number,
|
||||
parseInline: (text: string) => Array<Node>,
|
||||
): TableParseResult {
|
||||
const startIndex = currentLineIndex;
|
||||
|
||||
if (startIndex + 2 >= lines.length) {
|
||||
return {node: null, newLineIndex: currentLineIndex};
|
||||
}
|
||||
|
||||
const headerLine = lines[currentLineIndex];
|
||||
const alignmentLine = lines[currentLineIndex + 1];
|
||||
|
||||
if (!containsPipe(headerLine) || !containsPipe(alignmentLine)) {
|
||||
return {node: null, newLineIndex: currentLineIndex};
|
||||
}
|
||||
|
||||
try {
|
||||
const headerCells = fastSplitTableCells(headerLine.trim());
|
||||
if (headerCells.length === 0 || !hasContent(headerCells)) {
|
||||
return {node: null, newLineIndex: currentLineIndex};
|
||||
}
|
||||
|
||||
const headerRow = createTableRow(headerCells, parseInline);
|
||||
|
||||
const columnCount = headerRow.cells.length;
|
||||
currentLineIndex++;
|
||||
|
||||
const alignmentCells = fastSplitTableCells(alignmentLine.trim());
|
||||
|
||||
if (!validateAlignmentRow(alignmentCells)) {
|
||||
return {node: null, newLineIndex: startIndex};
|
||||
}
|
||||
|
||||
const alignments = parseAlignments(alignmentCells);
|
||||
|
||||
if (!alignments || headerRow.cells.length !== alignments.length) {
|
||||
return {node: null, newLineIndex: startIndex};
|
||||
}
|
||||
|
||||
currentLineIndex++;
|
||||
|
||||
const rows: Array<TableRowNode> = [];
|
||||
|
||||
while (currentLineIndex < lines.length) {
|
||||
const line = lines[currentLineIndex];
|
||||
|
||||
if (!containsPipe(line)) break;
|
||||
|
||||
const trimmed = line.trim();
|
||||
if (isBlockBreakFast(trimmed)) break;
|
||||
|
||||
const cellContents = fastSplitTableCells(trimmed);
|
||||
|
||||
if (cellContents.length !== columnCount) {
|
||||
normalizeColumnCount(cellContents, columnCount);
|
||||
}
|
||||
|
||||
const row = createTableRow(cellContents, parseInline);
|
||||
|
||||
rows.push(row);
|
||||
currentLineIndex++;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return {node: null, newLineIndex: startIndex};
|
||||
}
|
||||
|
||||
let hasAnyContent = hasRowContent(headerRow);
|
||||
|
||||
if (!hasAnyContent) {
|
||||
for (const row of rows) {
|
||||
if (hasRowContent(row)) {
|
||||
hasAnyContent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAnyContent) {
|
||||
return {node: null, newLineIndex: startIndex};
|
||||
}
|
||||
|
||||
if (inlineContentCache.size > MAX_CACHE_SIZE) {
|
||||
inlineContentCache.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Table,
|
||||
header: headerRow,
|
||||
alignments: alignments,
|
||||
rows,
|
||||
},
|
||||
newLineIndex: currentLineIndex,
|
||||
};
|
||||
} catch (_err) {
|
||||
return {node: null, newLineIndex: startIndex};
|
||||
}
|
||||
}
|
||||
|
||||
function containsPipe(text: string): boolean {
|
||||
return text.indexOf('|') !== -1;
|
||||
}
|
||||
|
||||
function hasContent(cells: Array<string>): boolean {
|
||||
for (const cell of cells) {
|
||||
if (cell.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasRowContent(row: TableRowNode): boolean {
|
||||
for (const cell of row.cells) {
|
||||
if (
|
||||
cell.children.length > 0 &&
|
||||
!(cell.children.length === 1 && cell.children[0].type === NodeType.Text && cell.children[0].content.trim() === '')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function validateAlignmentRow(cells: Array<string>): boolean {
|
||||
if (cells.length === 0) return false;
|
||||
|
||||
for (const cell of cells) {
|
||||
const trimmed = cell.trim();
|
||||
|
||||
if (trimmed.length === 0 || trimmed.indexOf('-') === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < trimmed.length; i++) {
|
||||
const charCode = trimmed.charCodeAt(i);
|
||||
if (charCode !== SPACE && charCode !== COLON && charCode !== DASH && charCode !== PIPE) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function fastSplitTableCells(line: string): Array<string> {
|
||||
let start = 0;
|
||||
let end = line.length;
|
||||
|
||||
if (line.length > 0 && line.charCodeAt(0) === PIPE) {
|
||||
start = 1;
|
||||
}
|
||||
|
||||
if (line.length > 0 && end > start && line.charCodeAt(end - 1) === PIPE) {
|
||||
end--;
|
||||
}
|
||||
|
||||
if (start >= end) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const content = line.substring(start, end);
|
||||
const cells: Array<string> = [];
|
||||
let currentCell = '';
|
||||
let i = 0;
|
||||
|
||||
while (i < content.length) {
|
||||
if (content.charCodeAt(i) === BACKSLASH && i + 1 < content.length && content.charCodeAt(i + 1) === PIPE) {
|
||||
currentCell += '|';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.charCodeAt(i) === PIPE) {
|
||||
cells.push(currentCell);
|
||||
currentCell = '';
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
currentCell += content[i];
|
||||
i++;
|
||||
}
|
||||
|
||||
cells.push(currentCell);
|
||||
return cells;
|
||||
}
|
||||
|
||||
function parseAlignments(cells: Array<string>): Array<TableAlignment> | null {
|
||||
if (cells.length === 0) return null;
|
||||
|
||||
const alignments: Array<TableAlignment> = [];
|
||||
|
||||
for (const cell of cells) {
|
||||
const trimmed = cell.trim();
|
||||
if (!trimmed || trimmed.indexOf('-') === -1) return null;
|
||||
|
||||
const left = trimmed.charCodeAt(0) === COLON;
|
||||
const right = trimmed.charCodeAt(trimmed.length - 1) === COLON;
|
||||
|
||||
if (left && right) {
|
||||
alignments.push(TableAlignment.Center);
|
||||
} else if (left) {
|
||||
alignments.push(TableAlignment.Left);
|
||||
} else if (right) {
|
||||
alignments.push(TableAlignment.Right);
|
||||
} else {
|
||||
alignments.push(TableAlignment.None);
|
||||
}
|
||||
}
|
||||
|
||||
return alignments;
|
||||
}
|
||||
|
||||
function createTableRow(cellContents: Array<string>, parseInline: (text: string) => Array<Node>): TableRowNode {
|
||||
const cells: Array<TableCellNode> = [];
|
||||
|
||||
for (const cellContent of cellContents) {
|
||||
const trimmed = cellContent.trim();
|
||||
|
||||
let inlineNodes: Array<Node>;
|
||||
if (inlineContentCache.has(trimmed)) {
|
||||
inlineNodes = inlineContentCache.get(trimmed)!;
|
||||
} else {
|
||||
inlineNodes = parseInline(trimmed);
|
||||
inlineContentCache.set(trimmed, inlineNodes);
|
||||
}
|
||||
|
||||
cells.push({
|
||||
type: NodeType.TableCell,
|
||||
children: inlineNodes.length > 0 ? inlineNodes : [{type: NodeType.Text, content: trimmed}],
|
||||
});
|
||||
}
|
||||
|
||||
return {type: NodeType.TableRow, cells};
|
||||
}
|
||||
|
||||
function normalizeColumnCount(cells: Array<string>, expectedColumns: number): void {
|
||||
if (cells.length > expectedColumns) {
|
||||
const lastCellIndex = expectedColumns - 1;
|
||||
cells[lastCellIndex] = `${cells[lastCellIndex]}|${cells.slice(expectedColumns).join('|')}`;
|
||||
cells.length = expectedColumns;
|
||||
} else {
|
||||
while (cells.length < expectedColumns) {
|
||||
cells.push('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isBlockBreakFast(text: string): boolean {
|
||||
if (!text || text.length === 0) return false;
|
||||
|
||||
const firstChar = text.charCodeAt(0);
|
||||
|
||||
if (firstChar === HASH || firstChar === GREATER_THAN || firstChar === DASH || firstChar === ASTERISK) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
text.length >= 4 &&
|
||||
text.charCodeAt(0) === GREATER_THAN &&
|
||||
text.charCodeAt(1) === GREATER_THAN &&
|
||||
text.charCodeAt(2) === GREATER_THAN &&
|
||||
text.charCodeAt(3) === SPACE
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (text.length >= 2 && text.charCodeAt(0) === DASH && text.charCodeAt(1) === HASH) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (firstChar >= DIGIT_0 && firstChar <= DIGIT_9) {
|
||||
for (let i = 1; i < Math.min(text.length, 4); i++) {
|
||||
if (text.charCodeAt(i) === PERIOD) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {describe, expect, test} from 'vitest';
|
||||
import {Parser} from '../parser/parser';
|
||||
import {EmojiKind, NodeType, ParserFlags, TimestampStyle} from '../types/enums';
|
||||
|
||||
describe('Fluxer Markdown Parser', () => {
|
||||
test('timestamp default', () => {
|
||||
const input = 'Current time: <t:1618953630>';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Current time: '},
|
||||
{
|
||||
type: NodeType.Timestamp,
|
||||
timestamp: 1618953630,
|
||||
style: TimestampStyle.ShortDateTime,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('timestamp with style', () => {
|
||||
const input = '<t:1618953630:d> is the date.';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Timestamp,
|
||||
timestamp: 1618953630,
|
||||
style: TimestampStyle.ShortDate,
|
||||
},
|
||||
{type: NodeType.Text, content: ' is the date.'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('timestamp with short date & short time style', () => {
|
||||
const input = '<t:1618953630:s>';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Timestamp,
|
||||
timestamp: 1618953630,
|
||||
style: TimestampStyle.ShortDateShortTime,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('timestamp with short date & medium time style', () => {
|
||||
const input = '<t:1618953630:S>';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Timestamp,
|
||||
timestamp: 1618953630,
|
||||
style: TimestampStyle.ShortDateMediumTime,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('timestamp invalid style', () => {
|
||||
const input = 'Check this <t:1618953630:z> time.';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: 'Check this <t:1618953630:z> time.'}]);
|
||||
});
|
||||
|
||||
test('timestamp non numeric', () => {
|
||||
const input = 'Check <t:abc123> time.';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: 'Check <t:abc123> time.'}]);
|
||||
});
|
||||
|
||||
test('timestamp with milliseconds should not parse', () => {
|
||||
const input = 'Time: <t:1618953630.123>';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: 'Time: <t:1618953630.123>'}]);
|
||||
});
|
||||
|
||||
test('timestamp with partial milliseconds should not parse', () => {
|
||||
const input = '<t:1618953630.1>';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '<t:1618953630.1>'}]);
|
||||
});
|
||||
|
||||
test('timestamp with excess millisecond precision should not parse', () => {
|
||||
const input = '<t:1618953630.123456>';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '<t:1618953630.123456>'}]);
|
||||
});
|
||||
|
||||
test('timestamp with milliseconds and style should not parse', () => {
|
||||
const input = '<t:1618953630.123:R>';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '<t:1618953630.123:R>'}]);
|
||||
});
|
||||
|
||||
test('timestamp in mixed content', () => {
|
||||
const input = 'Hello <a:wave:12345> 🦶 <t:1618953630:d> <:smile:9876>';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{type: NodeType.Text, content: 'Hello '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Custom,
|
||||
name: 'wave',
|
||||
id: '12345',
|
||||
animated: true,
|
||||
},
|
||||
},
|
||||
{type: NodeType.Text, content: ' '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: '🦶',
|
||||
codepoints: '1f9b6',
|
||||
name: expect.any(String),
|
||||
},
|
||||
},
|
||||
{type: NodeType.Text, content: ' '},
|
||||
{
|
||||
type: NodeType.Timestamp,
|
||||
timestamp: 1618953630,
|
||||
style: TimestampStyle.ShortDate,
|
||||
},
|
||||
{type: NodeType.Text, content: ' '},
|
||||
{
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Custom,
|
||||
name: 'smile',
|
||||
id: '9876',
|
||||
animated: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('timestamp edge cases', () => {
|
||||
const inputs = ['<t:>', '<t:1618953630:>', '<t::d>', '<t:1618953630::d>', '<t:1618953630:d:R>'];
|
||||
|
||||
for (const input of inputs) {
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: input}]);
|
||||
}
|
||||
});
|
||||
|
||||
test('very large valid timestamp', () => {
|
||||
const input = '<t:9999999999>';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Timestamp,
|
||||
timestamp: 9999999999,
|
||||
style: TimestampStyle.ShortDateTime,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('timestamp with leading zeros', () => {
|
||||
const input = '<t:0001618953630>';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.Timestamp,
|
||||
timestamp: 1618953630,
|
||||
style: TimestampStyle.ShortDateTime,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('timestamp in code block should not be parsed', () => {
|
||||
const input = '```\n<t:1618953630>\n```';
|
||||
const flags = ParserFlags.ALLOW_CODE_BLOCKS;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.CodeBlock,
|
||||
language: undefined,
|
||||
content: '<t:1618953630>\n',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('timestamp in inline code should not be parsed', () => {
|
||||
const input = '`<t:1618953630>`';
|
||||
const flags = 0;
|
||||
const parser = new Parser(input, flags);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
type: NodeType.InlineCode,
|
||||
content: '<t:1618953630>',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('timestamp with non-digit characters should not parse', () => {
|
||||
const inputs = [
|
||||
'<t:12a34>',
|
||||
'<t:12.34>',
|
||||
'<t:12,34>',
|
||||
'<t:1234e5>',
|
||||
'<t:+1234>',
|
||||
'<t:-1234>',
|
||||
'<t:1234 >',
|
||||
'<t: 1234>',
|
||||
'<t:1_234>',
|
||||
'<t:0x1234>',
|
||||
'<t:0b1010>',
|
||||
'<t:123.456.789>',
|
||||
];
|
||||
|
||||
for (const input of inputs) {
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: input}]);
|
||||
}
|
||||
});
|
||||
|
||||
test('timestamp with valid integer formats', () => {
|
||||
const inputs = ['<t:1234>', '<t:01234>', '<t:9999999999>'];
|
||||
|
||||
for (const input of inputs) {
|
||||
const parser = new Parser(input, 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast[0].type).toBe(NodeType.Timestamp);
|
||||
}
|
||||
});
|
||||
|
||||
test('timestamp with zero value should not parse', () => {
|
||||
const parser = new Parser('<t:0>', 0);
|
||||
const {nodes: ast} = parser.parse();
|
||||
|
||||
expect(ast).toEqual([{type: NodeType.Text, content: '<t:0>'}]);
|
||||
});
|
||||
});
|
||||
113
fluxer_app/src/lib/markdown/parser/parsers/timestamp-parsers.ts
Normal file
113
fluxer_app/src/lib/markdown/parser/parsers/timestamp-parsers.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {NodeType, TimestampStyle} from '../types/enums';
|
||||
import type {ParserResult} from '../types/nodes';
|
||||
|
||||
const LESS_THAN = 60;
|
||||
const LETTER_T = 116;
|
||||
const COLON = 58;
|
||||
|
||||
export function parseTimestamp(text: string): ParserResult | null {
|
||||
if (
|
||||
text.length < 4 ||
|
||||
text.charCodeAt(0) !== LESS_THAN ||
|
||||
text.charCodeAt(1) !== LETTER_T ||
|
||||
text.charCodeAt(2) !== COLON
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const end = text.indexOf('>');
|
||||
if (end === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inner = text.slice(3, end);
|
||||
|
||||
const allParts = inner.split(':');
|
||||
if (allParts.length > 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [timestampPart, stylePart] = allParts;
|
||||
|
||||
if (!/^\d+$/.test(timestampPart)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = Number(timestampPart);
|
||||
|
||||
if (timestamp === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let style: TimestampStyle;
|
||||
if (stylePart !== undefined) {
|
||||
if (stylePart === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const styleChar = stylePart[0];
|
||||
|
||||
const parsedStyle = getTimestampStyle(styleChar);
|
||||
|
||||
if (!parsedStyle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
style = parsedStyle;
|
||||
} else {
|
||||
style = TimestampStyle.ShortDateTime;
|
||||
}
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Timestamp,
|
||||
timestamp,
|
||||
style,
|
||||
},
|
||||
advance: end + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function getTimestampStyle(char: string): TimestampStyle | null {
|
||||
switch (char) {
|
||||
case 't':
|
||||
return TimestampStyle.ShortTime;
|
||||
case 'T':
|
||||
return TimestampStyle.LongTime;
|
||||
case 'd':
|
||||
return TimestampStyle.ShortDate;
|
||||
case 'D':
|
||||
return TimestampStyle.LongDate;
|
||||
case 'f':
|
||||
return TimestampStyle.ShortDateTime;
|
||||
case 'F':
|
||||
return TimestampStyle.LongDateTime;
|
||||
case 's':
|
||||
return TimestampStyle.ShortDateShortTime;
|
||||
case 'S':
|
||||
return TimestampStyle.ShortDateMediumTime;
|
||||
case 'R':
|
||||
return TimestampStyle.RelativeTime;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
24
fluxer_app/src/lib/markdown/parser/types/constants.ts
Normal file
24
fluxer_app/src/lib/markdown/parser/types/constants.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export const MAX_AST_NODES = 10000;
|
||||
export const MAX_INLINE_DEPTH = 10;
|
||||
export const MAX_LINES = 10000;
|
||||
export const MAX_LINE_LENGTH = 4096;
|
||||
export const MAX_LINK_URL_LENGTH = 2048;
|
||||
119
fluxer_app/src/lib/markdown/parser/types/enums.ts
Normal file
119
fluxer_app/src/lib/markdown/parser/types/enums.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export const ParserFlags = {
|
||||
ALLOW_SPOILERS: 1 << 0,
|
||||
ALLOW_HEADINGS: 1 << 1,
|
||||
ALLOW_LISTS: 1 << 2,
|
||||
ALLOW_CODE_BLOCKS: 1 << 3,
|
||||
ALLOW_MASKED_LINKS: 1 << 4,
|
||||
ALLOW_COMMAND_MENTIONS: 1 << 5,
|
||||
ALLOW_GUILD_NAVIGATIONS: 1 << 6,
|
||||
ALLOW_USER_MENTIONS: 1 << 7,
|
||||
ALLOW_ROLE_MENTIONS: 1 << 8,
|
||||
ALLOW_CHANNEL_MENTIONS: 1 << 9,
|
||||
ALLOW_EVERYONE_MENTIONS: 1 << 10,
|
||||
ALLOW_BLOCKQUOTES: 1 << 11,
|
||||
ALLOW_MULTILINE_BLOCKQUOTES: 1 << 12,
|
||||
ALLOW_SUBTEXT: 1 << 13,
|
||||
ALLOW_TABLES: 1 << 14,
|
||||
ALLOW_ALERTS: 1 << 15,
|
||||
ALLOW_AUTOLINKS: 1 << 16,
|
||||
} as const;
|
||||
export type ParserFlags = (typeof ParserFlags)[keyof typeof ParserFlags];
|
||||
|
||||
export const NodeType = {
|
||||
Text: 'Text',
|
||||
Blockquote: 'Blockquote',
|
||||
Strong: 'Strong',
|
||||
Emphasis: 'Emphasis',
|
||||
Underline: 'Underline',
|
||||
Strikethrough: 'Strikethrough',
|
||||
Spoiler: 'Spoiler',
|
||||
Heading: 'Heading',
|
||||
Subtext: 'Subtext',
|
||||
List: 'List',
|
||||
CodeBlock: 'CodeBlock',
|
||||
InlineCode: 'InlineCode',
|
||||
Sequence: 'Sequence',
|
||||
Link: 'Link',
|
||||
Mention: 'Mention',
|
||||
Timestamp: 'Timestamp',
|
||||
Emoji: 'Emoji',
|
||||
Table: 'Table',
|
||||
TableRow: 'TableRow',
|
||||
TableCell: 'TableCell',
|
||||
Alert: 'Alert',
|
||||
} as const;
|
||||
export type NodeType = (typeof NodeType)[keyof typeof NodeType];
|
||||
|
||||
export const AlertType = {
|
||||
Note: 'Note',
|
||||
Tip: 'Tip',
|
||||
Important: 'Important',
|
||||
Warning: 'Warning',
|
||||
Caution: 'Caution',
|
||||
} as const;
|
||||
export type AlertType = (typeof AlertType)[keyof typeof AlertType];
|
||||
|
||||
export const TableAlignment = {
|
||||
Left: 'Left',
|
||||
Center: 'Center',
|
||||
Right: 'Right',
|
||||
None: 'None',
|
||||
} as const;
|
||||
export type TableAlignment = (typeof TableAlignment)[keyof typeof TableAlignment];
|
||||
|
||||
export const TimestampStyle = {
|
||||
ShortTime: 'ShortTime',
|
||||
LongTime: 'LongTime',
|
||||
ShortDate: 'ShortDate',
|
||||
LongDate: 'LongDate',
|
||||
ShortDateTime: 'ShortDateTime',
|
||||
LongDateTime: 'LongDateTime',
|
||||
ShortDateShortTime: 'ShortDateShortTime',
|
||||
ShortDateMediumTime: 'ShortDateMediumTime',
|
||||
RelativeTime: 'RelativeTime',
|
||||
} as const;
|
||||
export type TimestampStyle = (typeof TimestampStyle)[keyof typeof TimestampStyle];
|
||||
|
||||
export const GuildNavKind = {
|
||||
Customize: 'Customize',
|
||||
Browse: 'Browse',
|
||||
Guide: 'Guide',
|
||||
LinkedRoles: 'LinkedRoles',
|
||||
} as const;
|
||||
export type GuildNavKind = (typeof GuildNavKind)[keyof typeof GuildNavKind];
|
||||
|
||||
export const MentionKind = {
|
||||
User: 'User',
|
||||
Channel: 'Channel',
|
||||
Role: 'Role',
|
||||
Command: 'Command',
|
||||
GuildNavigation: 'GuildNavigation',
|
||||
Everyone: 'Everyone',
|
||||
Here: 'Here',
|
||||
} as const;
|
||||
export type MentionKind = (typeof MentionKind)[keyof typeof MentionKind];
|
||||
|
||||
export const EmojiKind = {
|
||||
Standard: 'Standard',
|
||||
Custom: 'Custom',
|
||||
} as const;
|
||||
export type EmojiKind = (typeof EmojiKind)[keyof typeof EmojiKind];
|
||||
187
fluxer_app/src/lib/markdown/parser/types/nodes.ts
Normal file
187
fluxer_app/src/lib/markdown/parser/types/nodes.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AlertType, EmojiKind, GuildNavKind, MentionKind, NodeType, TableAlignment, TimestampStyle} from './enums';
|
||||
|
||||
interface BaseNode {
|
||||
type: (typeof NodeType)[keyof typeof NodeType];
|
||||
}
|
||||
|
||||
export interface TextNode extends BaseNode {
|
||||
type: typeof NodeType.Text;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface BlockquoteNode extends BaseNode {
|
||||
type: typeof NodeType.Blockquote;
|
||||
children: Array<Node>;
|
||||
}
|
||||
|
||||
export interface FormattingNode extends BaseNode {
|
||||
type:
|
||||
| typeof NodeType.Strong
|
||||
| typeof NodeType.Emphasis
|
||||
| typeof NodeType.Underline
|
||||
| typeof NodeType.Strikethrough
|
||||
| typeof NodeType.Spoiler
|
||||
| typeof NodeType.Sequence;
|
||||
children: Array<Node>;
|
||||
}
|
||||
|
||||
export interface HeadingNode extends BaseNode {
|
||||
type: typeof NodeType.Heading;
|
||||
level: number;
|
||||
children: Array<Node>;
|
||||
}
|
||||
|
||||
export interface SubtextNode extends BaseNode {
|
||||
type: typeof NodeType.Subtext;
|
||||
children: Array<Node>;
|
||||
}
|
||||
|
||||
export interface ListNode extends BaseNode {
|
||||
type: typeof NodeType.List;
|
||||
ordered: boolean;
|
||||
items: Array<ListItem>;
|
||||
}
|
||||
|
||||
export interface ListItem {
|
||||
children: Array<Node>;
|
||||
ordinal?: number;
|
||||
}
|
||||
|
||||
export interface CodeBlockNode extends BaseNode {
|
||||
type: typeof NodeType.CodeBlock;
|
||||
language?: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface InlineCodeNode extends BaseNode {
|
||||
type: typeof NodeType.InlineCode;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface LinkNode extends BaseNode {
|
||||
type: typeof NodeType.Link;
|
||||
text?: Node;
|
||||
url: string;
|
||||
escaped: boolean;
|
||||
}
|
||||
|
||||
export interface MentionNode extends BaseNode {
|
||||
type: typeof NodeType.Mention;
|
||||
kind: MentionType;
|
||||
}
|
||||
|
||||
export interface TimestampNode extends BaseNode {
|
||||
type: typeof NodeType.Timestamp;
|
||||
timestamp: number;
|
||||
style: (typeof TimestampStyle)[keyof typeof TimestampStyle];
|
||||
}
|
||||
|
||||
export interface EmojiNode extends BaseNode {
|
||||
type: typeof NodeType.Emoji;
|
||||
kind: EmojiType;
|
||||
}
|
||||
|
||||
export interface SequenceNode extends BaseNode {
|
||||
type: typeof NodeType.Sequence;
|
||||
children: Array<Node>;
|
||||
}
|
||||
|
||||
export interface TableNode extends BaseNode {
|
||||
type: typeof NodeType.Table;
|
||||
header: TableRowNode;
|
||||
alignments: Array<(typeof TableAlignment)[keyof typeof TableAlignment]>;
|
||||
rows: Array<TableRowNode>;
|
||||
}
|
||||
|
||||
export interface TableRowNode extends BaseNode {
|
||||
type: typeof NodeType.TableRow;
|
||||
cells: Array<TableCellNode>;
|
||||
}
|
||||
|
||||
export interface TableCellNode extends BaseNode {
|
||||
type: typeof NodeType.TableCell;
|
||||
children: Array<Node>;
|
||||
}
|
||||
|
||||
export interface AlertNode extends BaseNode {
|
||||
type: typeof NodeType.Alert;
|
||||
alertType: (typeof AlertType)[keyof typeof AlertType];
|
||||
children: Array<Node>;
|
||||
}
|
||||
|
||||
export interface SpoilerNode extends BaseNode {
|
||||
type: typeof NodeType.Spoiler;
|
||||
children: Array<Node>;
|
||||
isBlock: boolean;
|
||||
}
|
||||
|
||||
export type Node =
|
||||
| TextNode
|
||||
| BlockquoteNode
|
||||
| FormattingNode
|
||||
| HeadingNode
|
||||
| SubtextNode
|
||||
| ListNode
|
||||
| CodeBlockNode
|
||||
| InlineCodeNode
|
||||
| LinkNode
|
||||
| MentionNode
|
||||
| TimestampNode
|
||||
| EmojiNode
|
||||
| SequenceNode
|
||||
| TableNode
|
||||
| TableRowNode
|
||||
| TableCellNode
|
||||
| AlertNode
|
||||
| SpoilerNode;
|
||||
|
||||
type MentionType =
|
||||
| {kind: typeof MentionKind.User; id: string}
|
||||
| {kind: typeof MentionKind.Channel; id: string}
|
||||
| {kind: typeof MentionKind.Role; id: string}
|
||||
| {
|
||||
kind: typeof MentionKind.Command;
|
||||
name: string;
|
||||
subcommandGroup?: string;
|
||||
subcommand?: string;
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
kind: typeof MentionKind.GuildNavigation;
|
||||
navigationType: typeof GuildNavKind.Customize | typeof GuildNavKind.Browse | typeof GuildNavKind.Guide;
|
||||
}
|
||||
| {
|
||||
kind: typeof MentionKind.GuildNavigation;
|
||||
navigationType: typeof GuildNavKind.LinkedRoles;
|
||||
id?: string;
|
||||
}
|
||||
| {kind: typeof MentionKind.Everyone}
|
||||
| {kind: typeof MentionKind.Here};
|
||||
|
||||
type EmojiType =
|
||||
| {kind: typeof EmojiKind.Standard; raw: string; codepoints: string; name: string}
|
||||
| {kind: typeof EmojiKind.Custom; name: string; id: string; animated: boolean};
|
||||
|
||||
export interface ParserResult {
|
||||
node: Node;
|
||||
advance: number;
|
||||
}
|
||||
148
fluxer_app/src/lib/markdown/parser/utils/ast-utils.test.ts
Normal file
148
fluxer_app/src/lib/markdown/parser/utils/ast-utils.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {describe, expect, test} from 'vitest';
|
||||
import {NodeType} from '../types/enums';
|
||||
import type {FormattingNode, Node, TextNode} from '../types/nodes';
|
||||
import {addTextNode, combineAdjacentTextNodes, flattenSameType, isFormattingNode, mergeTextNodes} from './ast-utils';
|
||||
|
||||
describe('AST Utils', () => {
|
||||
describe('isFormattingNode', () => {
|
||||
test('should identify formatting nodes', () => {
|
||||
const emphasisNode: FormattingNode = {
|
||||
type: NodeType.Emphasis,
|
||||
children: [{type: NodeType.Text, content: 'test'}],
|
||||
};
|
||||
const strongNode: FormattingNode = {
|
||||
type: NodeType.Strong,
|
||||
children: [{type: NodeType.Text, content: 'test'}],
|
||||
};
|
||||
const textNode: TextNode = {
|
||||
type: NodeType.Text,
|
||||
content: 'test',
|
||||
};
|
||||
|
||||
expect(isFormattingNode(emphasisNode)).toBe(true);
|
||||
expect(isFormattingNode(strongNode)).toBe(true);
|
||||
expect(isFormattingNode(textNode)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flattenSameType', () => {
|
||||
test('should flatten nodes of the same type', () => {
|
||||
const children: Array<Node> = [
|
||||
{type: NodeType.Text, content: 'first'},
|
||||
{
|
||||
type: NodeType.Emphasis,
|
||||
children: [
|
||||
{type: NodeType.Text, content: 'nested1'},
|
||||
{type: NodeType.Text, content: 'nested2'},
|
||||
],
|
||||
},
|
||||
{type: NodeType.Text, content: 'last'},
|
||||
];
|
||||
|
||||
flattenSameType(children, NodeType.Emphasis);
|
||||
expect(children).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('should handle empty children arrays', () => {
|
||||
const children: Array<Node> = [];
|
||||
flattenSameType(children, NodeType.Text);
|
||||
expect(children).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('combineAdjacentTextNodes', () => {
|
||||
test('should combine adjacent text nodes', () => {
|
||||
const nodes: Array<Node> = [
|
||||
{type: NodeType.Text, content: 'first'},
|
||||
{type: NodeType.Text, content: 'second'},
|
||||
{type: NodeType.Text, content: 'third'},
|
||||
];
|
||||
|
||||
combineAdjacentTextNodes(nodes);
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0]).toEqual({type: NodeType.Text, content: 'firstsecondthird'});
|
||||
});
|
||||
|
||||
test('should not combine non-text nodes', () => {
|
||||
const nodes: Array<Node> = [
|
||||
{type: NodeType.Text, content: 'text'},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'emphasis'}]},
|
||||
{type: NodeType.Text, content: 'more text'},
|
||||
];
|
||||
combineAdjacentTextNodes(nodes);
|
||||
|
||||
expect(nodes).toHaveLength(3);
|
||||
expect(nodes[0]).toEqual({type: NodeType.Text, content: 'text'});
|
||||
expect(nodes[1].type).toBe(NodeType.Emphasis);
|
||||
expect(nodes[2]).toEqual({type: NodeType.Text, content: 'more text'});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeTextNodes', () => {
|
||||
test('should merge text nodes', () => {
|
||||
const nodes: Array<Node> = [
|
||||
{type: NodeType.Text, content: 'hello '},
|
||||
{type: NodeType.Text, content: 'world'},
|
||||
];
|
||||
const result = mergeTextNodes(nodes);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({type: NodeType.Text, content: 'hello world'});
|
||||
});
|
||||
|
||||
test('should handle mixed node types', () => {
|
||||
const nodes: Array<Node> = [
|
||||
{type: NodeType.Text, content: 'start'},
|
||||
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'middle'}]},
|
||||
{type: NodeType.Text, content: 'end'},
|
||||
];
|
||||
const result = mergeTextNodes(nodes);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toEqual({type: NodeType.Text, content: 'start'});
|
||||
expect(result[1].type).toBe(NodeType.Emphasis);
|
||||
expect(result[2]).toEqual({type: NodeType.Text, content: 'end'});
|
||||
});
|
||||
|
||||
test('should handle empty arrays', () => {
|
||||
const result = mergeTextNodes([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addTextNode', () => {
|
||||
test('should add text node to array', () => {
|
||||
const nodes: Array<Node> = [];
|
||||
addTextNode(nodes, 'test content');
|
||||
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0]).toEqual({type: NodeType.Text, content: 'test content'});
|
||||
});
|
||||
|
||||
test('should handle empty text', () => {
|
||||
const nodes: Array<Node> = [];
|
||||
addTextNode(nodes, '');
|
||||
|
||||
expect(nodes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
522
fluxer_app/src/lib/markdown/parser/utils/ast-utils.ts
Normal file
522
fluxer_app/src/lib/markdown/parser/utils/ast-utils.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {NodeType} from '../types/enums';
|
||||
import type {
|
||||
AlertNode,
|
||||
BlockquoteNode,
|
||||
FormattingNode,
|
||||
HeadingNode,
|
||||
LinkNode,
|
||||
ListNode,
|
||||
Node,
|
||||
SequenceNode,
|
||||
SubtextNode,
|
||||
TableCellNode,
|
||||
TableNode,
|
||||
TableRowNode,
|
||||
TextNode,
|
||||
} from '../types/nodes';
|
||||
|
||||
const NT_TEXT = NodeType.Text;
|
||||
const NT_STRONG = NodeType.Strong;
|
||||
const NT_EMPHASIS = NodeType.Emphasis;
|
||||
const NT_UNDERLINE = NodeType.Underline;
|
||||
const NT_STRIKETHROUGH = NodeType.Strikethrough;
|
||||
const NT_SPOILER = NodeType.Spoiler;
|
||||
const NT_SEQUENCE = NodeType.Sequence;
|
||||
const NT_HEADING = NodeType.Heading;
|
||||
const NT_SUBTEXT = NodeType.Subtext;
|
||||
const NT_BLOCKQUOTE = NodeType.Blockquote;
|
||||
const NT_LIST = NodeType.List;
|
||||
const NT_LINK = NodeType.Link;
|
||||
const NT_TABLE = NodeType.Table;
|
||||
const NT_TABLE_ROW = NodeType.TableRow;
|
||||
const NT_TABLE_CELL = NodeType.TableCell;
|
||||
const NT_ALERT = NodeType.Alert;
|
||||
|
||||
const FORMATTING_NODE_TYPES: Set<(typeof NodeType)[keyof typeof NodeType]> = new Set([
|
||||
NT_STRONG,
|
||||
NT_EMPHASIS,
|
||||
NT_UNDERLINE,
|
||||
NT_STRIKETHROUGH,
|
||||
NT_SPOILER,
|
||||
NT_SEQUENCE,
|
||||
]);
|
||||
|
||||
export function flattenAST(nodes: Array<Node>): void {
|
||||
const nodeCount = nodes.length;
|
||||
if (nodeCount <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
flattenNode(nodes[i]);
|
||||
}
|
||||
|
||||
flattenChildren(nodes, false);
|
||||
}
|
||||
|
||||
function flattenNode(node: Node): void {
|
||||
const nodeType = node.type;
|
||||
|
||||
if (nodeType === NT_TEXT) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (FORMATTING_NODE_TYPES.has(nodeType)) {
|
||||
const formattingNode = node as FormattingNode;
|
||||
const children = formattingNode.children;
|
||||
const childCount = children.length;
|
||||
|
||||
if (childCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < childCount; i++) {
|
||||
flattenNode(children[i]);
|
||||
}
|
||||
|
||||
flattenChildren(children, false);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (nodeType) {
|
||||
case NT_HEADING:
|
||||
case NT_SUBTEXT: {
|
||||
const typedNode = node as HeadingNode | SubtextNode;
|
||||
const children = typedNode.children;
|
||||
const childCount = children.length;
|
||||
|
||||
for (let i = 0; i < childCount; i++) {
|
||||
flattenNode(children[i]);
|
||||
}
|
||||
|
||||
flattenChildren(children, false);
|
||||
break;
|
||||
}
|
||||
|
||||
case NT_BLOCKQUOTE: {
|
||||
const blockquoteNode = node as BlockquoteNode;
|
||||
const children = blockquoteNode.children;
|
||||
const childCount = children.length;
|
||||
|
||||
for (let i = 0; i < childCount; i++) {
|
||||
flattenNode(children[i]);
|
||||
}
|
||||
|
||||
flattenChildren(children, true);
|
||||
break;
|
||||
}
|
||||
|
||||
case NT_LIST: {
|
||||
const listNode = node as ListNode;
|
||||
const items = listNode.items;
|
||||
const itemCount = items.length;
|
||||
|
||||
for (let i = 0; i < itemCount; i++) {
|
||||
const item = items[i];
|
||||
const itemChildren = item.children;
|
||||
const itemChildCount = itemChildren.length;
|
||||
|
||||
for (let j = 0; j < itemChildCount; j++) {
|
||||
flattenNode(itemChildren[j]);
|
||||
}
|
||||
|
||||
flattenChildren(itemChildren, false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case NT_LINK: {
|
||||
const linkNode = node as LinkNode;
|
||||
const text = linkNode.text;
|
||||
if (text) {
|
||||
flattenNode(text);
|
||||
if (text.type === NT_SEQUENCE) {
|
||||
const sequenceNode = text as SequenceNode;
|
||||
const seqChildren = sequenceNode.children;
|
||||
const seqChildCount = seqChildren.length;
|
||||
|
||||
for (let i = 0; i < seqChildCount; i++) {
|
||||
flattenNode(seqChildren[i]);
|
||||
}
|
||||
|
||||
flattenChildren(seqChildren, false);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case NT_TABLE: {
|
||||
const tableNode = node as TableNode;
|
||||
flattenTableRow(tableNode.header);
|
||||
|
||||
const rows = tableNode.rows;
|
||||
const rowCount = rows.length;
|
||||
for (let i = 0; i < rowCount; i++) {
|
||||
flattenTableRow(rows[i]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case NT_TABLE_ROW:
|
||||
flattenTableRow(node as TableRowNode);
|
||||
break;
|
||||
|
||||
case NT_TABLE_CELL: {
|
||||
const cellNode = node as TableCellNode;
|
||||
const cellChildren = cellNode.children;
|
||||
const cellChildCount = cellChildren.length;
|
||||
|
||||
for (let i = 0; i < cellChildCount; i++) {
|
||||
flattenNode(cellChildren[i]);
|
||||
}
|
||||
|
||||
flattenChildren(cellChildren, false);
|
||||
break;
|
||||
}
|
||||
|
||||
case NT_ALERT: {
|
||||
const alertNode = node as AlertNode;
|
||||
const alertChildren = alertNode.children;
|
||||
const alertChildCount = alertChildren.length;
|
||||
|
||||
for (let i = 0; i < alertChildCount; i++) {
|
||||
flattenNode(alertChildren[i]);
|
||||
}
|
||||
|
||||
flattenChildren(alertChildren, false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function flattenTableRow(row: TableRowNode): void {
|
||||
const cells = row.cells;
|
||||
const cellCount = cells.length;
|
||||
|
||||
for (let i = 0; i < cellCount; i++) {
|
||||
const cell = cells[i];
|
||||
const cellChildren = cell.children;
|
||||
const childCount = cellChildren.length;
|
||||
|
||||
if (childCount === 0) continue;
|
||||
|
||||
for (let j = 0; j < childCount; j++) {
|
||||
flattenNode(cellChildren[j]);
|
||||
}
|
||||
|
||||
flattenChildren(cellChildren, false);
|
||||
}
|
||||
}
|
||||
|
||||
export function flattenChildren(nodes: Array<Node>, insideBlockquote = false): void {
|
||||
const nodeCount = nodes.length;
|
||||
if (nodeCount <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
flattenFormattingNodes(nodes);
|
||||
|
||||
combineAdjacentTextNodes(nodes, insideBlockquote);
|
||||
|
||||
removeEmptyTextNodesBetweenAlerts(nodes);
|
||||
}
|
||||
|
||||
function flattenFormattingNodes(nodes: Array<Node>): void {
|
||||
if (nodes.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
while (i < nodes.length) {
|
||||
const node = nodes[i];
|
||||
if (FORMATTING_NODE_TYPES.has(node.type)) {
|
||||
const formattingNode = node as FormattingNode;
|
||||
flattenSameType(formattingNode.children, node.type);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
export function isFormattingNode(node: Node): boolean {
|
||||
return FORMATTING_NODE_TYPES.has(node.type);
|
||||
}
|
||||
|
||||
export function flattenSameType(children: Array<Node>, nodeType: NodeType): void {
|
||||
if (children.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let needsFlattening = false;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
if (children[i].type === nodeType) {
|
||||
needsFlattening = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!needsFlattening) {
|
||||
return;
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
const result: Array<Node> = [];
|
||||
|
||||
while (i < children.length) {
|
||||
const child = children[i];
|
||||
if (child.type === nodeType && 'children' in child) {
|
||||
const innerNodes = (child as FormattingNode).children;
|
||||
for (let j = 0; j < innerNodes.length; j++) {
|
||||
result.push(innerNodes[j]);
|
||||
}
|
||||
} else {
|
||||
result.push(child);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
children.length = 0;
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
children.push(result[i]);
|
||||
}
|
||||
}
|
||||
|
||||
export function combineAdjacentTextNodes(nodes: Array<Node>, insideBlockquote = false): void {
|
||||
const nodeCount = nodes.length;
|
||||
if (nodeCount <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let hasAdjacentTextNodes = false;
|
||||
let lastWasText = false;
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const isText = nodes[i].type === NT_TEXT;
|
||||
if (isText && lastWasText) {
|
||||
hasAdjacentTextNodes = true;
|
||||
break;
|
||||
}
|
||||
lastWasText = isText;
|
||||
}
|
||||
|
||||
if (!hasAdjacentTextNodes && !insideBlockquote) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result: Array<Node> = [];
|
||||
let currentText = '';
|
||||
let nonTextNodeSeen = false;
|
||||
|
||||
if (insideBlockquote) {
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const node = nodes[i];
|
||||
const isTextNode = node.type === NT_TEXT;
|
||||
|
||||
if (isTextNode) {
|
||||
if (nonTextNodeSeen) {
|
||||
if (currentText) {
|
||||
result.push({type: NT_TEXT, content: currentText});
|
||||
currentText = '';
|
||||
}
|
||||
nonTextNodeSeen = false;
|
||||
}
|
||||
currentText += (node as TextNode).content;
|
||||
} else {
|
||||
if (currentText) {
|
||||
result.push({type: NT_TEXT, content: currentText});
|
||||
currentText = '';
|
||||
}
|
||||
result.push(node);
|
||||
nonTextNodeSeen = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentText) {
|
||||
result.push({type: NT_TEXT, content: currentText});
|
||||
}
|
||||
} else {
|
||||
let currentTextNode: TextNode | null = null;
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const node = nodes[i];
|
||||
if (node.type === NT_TEXT) {
|
||||
const textNode = node as TextNode;
|
||||
const content = textNode.content;
|
||||
|
||||
let isMalformedContent = false;
|
||||
if (content && (content[0] === '#' || (content[0] === '-' && content.length > 1 && content[1] === '#'))) {
|
||||
const trimmed = content.trim();
|
||||
isMalformedContent = trimmed.startsWith('#') || trimmed.startsWith('-#');
|
||||
}
|
||||
|
||||
if (isMalformedContent) {
|
||||
if (currentTextNode) {
|
||||
result.push(currentTextNode);
|
||||
currentTextNode = null;
|
||||
}
|
||||
result.push({type: NT_TEXT, content});
|
||||
} else if (currentTextNode) {
|
||||
const hasDoubleNewline = content.includes('\n\n');
|
||||
|
||||
if (hasDoubleNewline) {
|
||||
result.push(currentTextNode);
|
||||
result.push({type: NT_TEXT, content});
|
||||
currentTextNode = null;
|
||||
} else {
|
||||
currentTextNode.content += content;
|
||||
}
|
||||
} else {
|
||||
currentTextNode = {type: NT_TEXT, content};
|
||||
}
|
||||
} else {
|
||||
if (currentTextNode) {
|
||||
result.push(currentTextNode);
|
||||
currentTextNode = null;
|
||||
}
|
||||
result.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentTextNode) {
|
||||
result.push(currentTextNode);
|
||||
}
|
||||
}
|
||||
|
||||
nodes.length = 0;
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
nodes.push(result[i]);
|
||||
}
|
||||
}
|
||||
|
||||
function removeEmptyTextNodesBetweenAlerts(nodes: Array<Node>): void {
|
||||
const nodeCount = nodes.length;
|
||||
if (nodeCount < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
let hasAlert = false;
|
||||
let hasTextNode = false;
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const type = nodes[i].type;
|
||||
hasAlert ||= type === NT_ALERT;
|
||||
hasTextNode ||= type === NT_TEXT;
|
||||
|
||||
if (hasAlert && hasTextNode) break;
|
||||
}
|
||||
|
||||
if (!hasAlert || !hasTextNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
let emptyTextBetweenAlerts = false;
|
||||
for (let i = 1; i < nodeCount - 1; i++) {
|
||||
const current = nodes[i];
|
||||
if (
|
||||
current.type === NT_TEXT &&
|
||||
nodes[i - 1].type === NT_ALERT &&
|
||||
nodes[i + 1].type === NT_ALERT &&
|
||||
(current as TextNode).content.trim() === ''
|
||||
) {
|
||||
emptyTextBetweenAlerts = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!emptyTextBetweenAlerts) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result: Array<Node> = [];
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const current = nodes[i];
|
||||
|
||||
if (
|
||||
i > 0 &&
|
||||
i < nodeCount - 1 &&
|
||||
current.type === NT_TEXT &&
|
||||
(current as TextNode).content.trim() === '' &&
|
||||
nodes[i - 1].type === NT_ALERT &&
|
||||
nodes[i + 1].type === NT_ALERT
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(current);
|
||||
}
|
||||
|
||||
nodes.length = 0;
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
nodes.push(result[i]);
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeTextNodes(nodes: Array<Node>): Array<Node> {
|
||||
const nodeCount = nodes.length;
|
||||
if (nodeCount <= 1) {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
let hasConsecutiveTextNodes = false;
|
||||
let prevWasText = false;
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const isText = nodes[i].type === NT_TEXT;
|
||||
if (isText && prevWasText) {
|
||||
hasConsecutiveTextNodes = true;
|
||||
break;
|
||||
}
|
||||
prevWasText = isText;
|
||||
}
|
||||
|
||||
if (!hasConsecutiveTextNodes) {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const mergedNodes: Array<Node> = [];
|
||||
let currentText = '';
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const node = nodes[i];
|
||||
if (node.type === NT_TEXT) {
|
||||
currentText += (node as TextNode).content;
|
||||
} else {
|
||||
if (currentText) {
|
||||
mergedNodes.push({type: NT_TEXT, content: currentText});
|
||||
currentText = '';
|
||||
}
|
||||
mergedNodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentText) {
|
||||
mergedNodes.push({type: NT_TEXT, content: currentText});
|
||||
}
|
||||
|
||||
return mergedNodes;
|
||||
}
|
||||
|
||||
export function addTextNode(nodes: Array<Node>, text: string): void {
|
||||
if (text && text.length > 0) {
|
||||
nodes.push({type: NT_TEXT, content: text});
|
||||
}
|
||||
}
|
||||
120
fluxer_app/src/lib/markdown/parser/utils/string-utils.test.ts
Normal file
120
fluxer_app/src/lib/markdown/parser/utils/string-utils.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {describe, expect, test} from 'vitest';
|
||||
import {isAlphaNumericChar, matchMarker, startsWithUrl} from './string-utils';
|
||||
|
||||
describe('String Utils', () => {
|
||||
describe('isAlphaNumericChar', () => {
|
||||
test('should return true for alphanumeric characters', () => {
|
||||
expect(isAlphaNumericChar('a')).toBe(true);
|
||||
expect(isAlphaNumericChar('Z')).toBe(true);
|
||||
expect(isAlphaNumericChar('5')).toBe(true);
|
||||
expect(isAlphaNumericChar('0')).toBe(true);
|
||||
});
|
||||
|
||||
test('should return false for non-alphanumeric characters', () => {
|
||||
expect(isAlphaNumericChar('!')).toBe(false);
|
||||
expect(isAlphaNumericChar(' ')).toBe(false);
|
||||
expect(isAlphaNumericChar('@')).toBe(false);
|
||||
expect(isAlphaNumericChar('-')).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for multi-character strings', () => {
|
||||
expect(isAlphaNumericChar('ab')).toBe(false);
|
||||
expect(isAlphaNumericChar('12')).toBe(false);
|
||||
expect(isAlphaNumericChar('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startsWithUrl', () => {
|
||||
test('should return true for valid HTTP URLs', () => {
|
||||
expect(startsWithUrl('http://example.com')).toBe(true);
|
||||
expect(startsWithUrl('http://sub.domain.com/path')).toBe(true);
|
||||
});
|
||||
|
||||
test('should return true for valid HTTPS URLs', () => {
|
||||
expect(startsWithUrl('https://example.com')).toBe(true);
|
||||
expect(startsWithUrl('https://secure.site.org/page')).toBe(true);
|
||||
});
|
||||
|
||||
test('should return false for URLs with quotes in protocol', () => {
|
||||
expect(startsWithUrl('ht"tp://example.com')).toBe(false);
|
||||
expect(startsWithUrl("htt'p://example.com")).toBe(false);
|
||||
expect(startsWithUrl('https"://example.com')).toBe(false);
|
||||
expect(startsWithUrl("http's://example.com")).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for too short strings', () => {
|
||||
expect(startsWithUrl('http')).toBe(false);
|
||||
expect(startsWithUrl('https')).toBe(false);
|
||||
expect(startsWithUrl('http:/')).toBe(false);
|
||||
expect(startsWithUrl('')).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for non-URL strings', () => {
|
||||
expect(startsWithUrl('ftp://example.com')).toBe(false);
|
||||
expect(startsWithUrl('mailto:test@example.com')).toBe(false);
|
||||
expect(startsWithUrl('not a url')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchMarker', () => {
|
||||
test('should match single character markers', () => {
|
||||
const chars = ['a', 'b', 'c', 'd'];
|
||||
expect(matchMarker(chars, 0, 'a')).toBe(true);
|
||||
expect(matchMarker(chars, 1, 'b')).toBe(true);
|
||||
expect(matchMarker(chars, 0, 'x')).toBe(false);
|
||||
});
|
||||
|
||||
test('should match two character markers', () => {
|
||||
const chars = ['a', 'b', 'c', 'd'];
|
||||
expect(matchMarker(chars, 0, 'ab')).toBe(true);
|
||||
expect(matchMarker(chars, 1, 'bc')).toBe(true);
|
||||
expect(matchMarker(chars, 0, 'ac')).toBe(false);
|
||||
});
|
||||
|
||||
test('should match longer markers', () => {
|
||||
const chars = ['h', 'e', 'l', 'l', 'o'];
|
||||
expect(matchMarker(chars, 0, 'hello')).toBe(true);
|
||||
expect(matchMarker(chars, 1, 'ello')).toBe(true);
|
||||
expect(matchMarker(chars, 0, 'help')).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false when marker extends beyond array', () => {
|
||||
const chars = ['a', 'b'];
|
||||
expect(matchMarker(chars, 0, 'abc')).toBe(false);
|
||||
expect(matchMarker(chars, 1, 'bc')).toBe(false);
|
||||
expect(matchMarker(chars, 2, 'c')).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle empty marker', () => {
|
||||
const chars = ['a', 'b', 'c'];
|
||||
expect(matchMarker(chars, 0, '')).toBe(true);
|
||||
expect(matchMarker(chars, 1, '')).toBe(true);
|
||||
});
|
||||
|
||||
test('should handle edge positions', () => {
|
||||
const chars = ['x', 'y', 'z'];
|
||||
expect(matchMarker(chars, 0, 'x')).toBe(true);
|
||||
expect(matchMarker(chars, 2, 'z')).toBe(true);
|
||||
expect(matchMarker(chars, 3, 'a')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
99
fluxer_app/src/lib/markdown/parser/utils/string-utils.ts
Normal file
99
fluxer_app/src/lib/markdown/parser/utils/string-utils.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {APP_PROTOCOL_PREFIX} from '~/utils/appProtocol';
|
||||
|
||||
const HTTP_PREFIX = 'http://';
|
||||
const HTTPS_PREFIX = 'https://';
|
||||
|
||||
const WORD_CHARS = new Set('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_');
|
||||
|
||||
const ESCAPABLE_CHARS = new Set('[]()\\*_~`@!#$%^&+={}|:;"\'<>,.?/');
|
||||
|
||||
const URL_TERMINATION_CHARS = new Set(' \t\n\r)\'"');
|
||||
|
||||
function isWordCharacter(char: string): boolean {
|
||||
return char.length === 1 && WORD_CHARS.has(char);
|
||||
}
|
||||
|
||||
export function isEscapableCharacter(char: string): boolean {
|
||||
return char.length === 1 && ESCAPABLE_CHARS.has(char);
|
||||
}
|
||||
|
||||
export function isUrlTerminationChar(char: string): boolean {
|
||||
return char.length === 1 && URL_TERMINATION_CHARS.has(char);
|
||||
}
|
||||
|
||||
export function isWordUnderscore(chars: Array<string>, pos: number): boolean {
|
||||
if (chars[pos] !== '_') return false;
|
||||
|
||||
const prevChar = pos > 0 ? chars[pos - 1] : '';
|
||||
const nextChar = pos + 1 < chars.length ? chars[pos + 1] : '';
|
||||
|
||||
return isWordCharacter(prevChar) && isWordCharacter(nextChar);
|
||||
}
|
||||
|
||||
export function isAlphaNumeric(charCode: number): boolean {
|
||||
return (
|
||||
(charCode >= 48 && charCode <= 57) || (charCode >= 65 && charCode <= 90) || (charCode >= 97 && charCode <= 122)
|
||||
);
|
||||
}
|
||||
|
||||
export function isAlphaNumericChar(char: string): boolean {
|
||||
return char.length === 1 && isAlphaNumeric(char.charCodeAt(0));
|
||||
}
|
||||
|
||||
export function startsWithUrl(text: string): boolean {
|
||||
if (text.length < 8) return false;
|
||||
|
||||
if (text.startsWith(HTTP_PREFIX)) {
|
||||
const prefixEnd = 7;
|
||||
return !text.substring(0, prefixEnd).includes('"') && !text.substring(0, prefixEnd).includes("'");
|
||||
}
|
||||
|
||||
if (text.startsWith(HTTPS_PREFIX)) {
|
||||
const prefixEnd = 8;
|
||||
return !text.substring(0, prefixEnd).includes('"') && !text.substring(0, prefixEnd).includes("'");
|
||||
}
|
||||
|
||||
if (text.startsWith(APP_PROTOCOL_PREFIX)) {
|
||||
const prefixEnd = APP_PROTOCOL_PREFIX.length;
|
||||
return !text.substring(0, prefixEnd).includes('"') && !text.substring(0, prefixEnd).includes("'");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function matchMarker(chars: Array<string>, pos: number, marker: string): boolean {
|
||||
if (pos + marker.length > chars.length) return false;
|
||||
|
||||
if (marker.length === 1) {
|
||||
return chars[pos] === marker;
|
||||
}
|
||||
|
||||
if (marker.length === 2) {
|
||||
return chars[pos] === marker[0] && chars[pos + 1] === marker[1];
|
||||
}
|
||||
|
||||
for (let i = 0; i < marker.length; i++) {
|
||||
if (chars[pos + i] !== marker[i]) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
121
fluxer_app/src/lib/markdown/parser/utils/url-utils.ts
Normal file
121
fluxer_app/src/lib/markdown/parser/utils/url-utils.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as idna from 'idna-uts46-hx';
|
||||
|
||||
const HTTP_PROTOCOL = 'http:';
|
||||
const HTTPS_PROTOCOL = 'https:';
|
||||
const MAILTO_PROTOCOL = 'mailto:';
|
||||
const TEL_PROTOCOL = 'tel:';
|
||||
const SMS_PROTOCOL = 'sms:';
|
||||
const FLUXER_PROTOCOL = 'fluxer:';
|
||||
|
||||
const EMAIL_REGEX =
|
||||
/^[a-zA-Z0-9._%+-]+@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||
|
||||
const PHONE_REGEX = /^\+[1-9][\d\s\-()]+$/;
|
||||
|
||||
const SPECIAL_PROTOCOLS_REGEX = /^(mailto:|tel:|sms:|fluxer:)/;
|
||||
|
||||
const PROTOCOL_REGEX = /:\/\//;
|
||||
|
||||
const TRAILING_SLASH_REGEX = /\/+$/;
|
||||
|
||||
const NORMALIZE_PHONE_REGEX = /[\s\-()]/g;
|
||||
|
||||
function createUrlObject(url: string): URL | null {
|
||||
if (typeof url !== 'string') return null;
|
||||
|
||||
try {
|
||||
if (!PROTOCOL_REGEX.test(url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidEmail(email: string): boolean {
|
||||
return typeof email === 'string' && EMAIL_REGEX.test(email);
|
||||
}
|
||||
|
||||
export function normalizePhoneNumber(phoneNumber: string): string {
|
||||
return phoneNumber.replace(NORMALIZE_PHONE_REGEX, '');
|
||||
}
|
||||
|
||||
export function isValidPhoneNumber(phoneNumber: string): boolean {
|
||||
if (typeof phoneNumber !== 'string' || !PHONE_REGEX.test(phoneNumber)) return false;
|
||||
|
||||
return normalizePhoneNumber(phoneNumber).length >= 7;
|
||||
}
|
||||
|
||||
export function normalizeUrl(url: string): string {
|
||||
if (typeof url !== 'string') return url;
|
||||
|
||||
if (SPECIAL_PROTOCOLS_REGEX.test(url)) {
|
||||
return url.replace(TRAILING_SLASH_REGEX, '');
|
||||
}
|
||||
|
||||
const urlObj = createUrlObject(url);
|
||||
return urlObj ? urlObj.toString() : url;
|
||||
}
|
||||
|
||||
function idnaEncodeURL(url: string): string {
|
||||
const urlObj = createUrlObject(url);
|
||||
if (!urlObj) return url;
|
||||
|
||||
try {
|
||||
urlObj.hostname = idna.toAscii(urlObj.hostname).toLowerCase();
|
||||
|
||||
urlObj.username = '';
|
||||
urlObj.password = '';
|
||||
|
||||
return urlObj.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export function convertToAsciiUrl(url: string): string {
|
||||
if (SPECIAL_PROTOCOLS_REGEX.test(url)) return url;
|
||||
|
||||
const urlObj = createUrlObject(url);
|
||||
return urlObj ? idnaEncodeURL(url) : url;
|
||||
}
|
||||
|
||||
export function isValidUrl(urlStr: string): boolean {
|
||||
if (typeof urlStr !== 'string') return false;
|
||||
|
||||
if (SPECIAL_PROTOCOLS_REGEX.test(urlStr)) return true;
|
||||
|
||||
const urlObj = createUrlObject(urlStr);
|
||||
if (!urlObj) return false;
|
||||
|
||||
const {protocol} = urlObj;
|
||||
return (
|
||||
protocol === HTTP_PROTOCOL ||
|
||||
protocol === HTTPS_PROTOCOL ||
|
||||
protocol === MAILTO_PROTOCOL ||
|
||||
protocol === TEL_PROTOCOL ||
|
||||
protocol === SMS_PROTOCOL ||
|
||||
protocol === FLUXER_PROTOCOL
|
||||
);
|
||||
}
|
||||
317
fluxer_app/src/lib/markdown/plaintext.tsx
Normal file
317
fluxer_app/src/lib/markdown/plaintext.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {TEXT_BASED_CHANNEL_TYPES} from '~/Constants';
|
||||
import ChannelStore from '~/stores/ChannelStore';
|
||||
import GuildStore from '~/stores/GuildStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import * as NicknameUtils from '~/utils/NicknameUtils';
|
||||
import {Parser} from './parser/parser/parser';
|
||||
import {EmojiKind, GuildNavKind, MentionKind, NodeType} from './parser/types/enums';
|
||||
import type {
|
||||
AlertNode,
|
||||
BlockquoteNode,
|
||||
CodeBlockNode,
|
||||
EmojiNode,
|
||||
FormattingNode,
|
||||
HeadingNode,
|
||||
InlineCodeNode,
|
||||
LinkNode,
|
||||
ListNode,
|
||||
MentionNode,
|
||||
Node,
|
||||
SequenceNode,
|
||||
SpoilerNode,
|
||||
SubtextNode,
|
||||
TableNode,
|
||||
TextNode,
|
||||
TimestampNode,
|
||||
} from './parser/types/nodes';
|
||||
import {formatTimestamp} from './utils/date-formatter';
|
||||
|
||||
interface PlaintextRenderOptions {
|
||||
channelId?: string;
|
||||
preserveMarkdown?: boolean;
|
||||
includeEmojiNames?: boolean;
|
||||
i18n?: I18n;
|
||||
}
|
||||
|
||||
function renderNodeToPlaintext(node: Node, options: PlaintextRenderOptions = {}): string {
|
||||
switch (node.type) {
|
||||
case NodeType.Text:
|
||||
return (node as TextNode).content;
|
||||
|
||||
case NodeType.Strong: {
|
||||
const strongNode = node as FormattingNode;
|
||||
const strongContent = renderNodesToPlaintext(strongNode.children, options);
|
||||
return options.preserveMarkdown ? `**${strongContent}**` : strongContent;
|
||||
}
|
||||
|
||||
case NodeType.Emphasis: {
|
||||
const emphasisNode = node as FormattingNode;
|
||||
const emphasisContent = renderNodesToPlaintext(emphasisNode.children, options);
|
||||
return options.preserveMarkdown ? `*${emphasisContent}*` : emphasisContent;
|
||||
}
|
||||
|
||||
case NodeType.Underline: {
|
||||
const underlineNode = node as FormattingNode;
|
||||
const underlineContent = renderNodesToPlaintext(underlineNode.children, options);
|
||||
return options.preserveMarkdown ? `__${underlineContent}__` : underlineContent;
|
||||
}
|
||||
|
||||
case NodeType.Strikethrough: {
|
||||
const strikethroughNode = node as FormattingNode;
|
||||
const strikethroughContent = renderNodesToPlaintext(strikethroughNode.children, options);
|
||||
return options.preserveMarkdown ? `~~${strikethroughContent}~~` : strikethroughContent;
|
||||
}
|
||||
|
||||
case NodeType.Spoiler: {
|
||||
const spoilerNode = node as SpoilerNode;
|
||||
const spoilerContent = renderNodesToPlaintext(spoilerNode.children, options);
|
||||
return options.preserveMarkdown ? `||${spoilerContent}||` : spoilerContent;
|
||||
}
|
||||
|
||||
case NodeType.Heading: {
|
||||
const headingNode = node as HeadingNode;
|
||||
const headingContent = renderNodesToPlaintext(headingNode.children, options);
|
||||
const headingPrefix = options.preserveMarkdown ? `${'#'.repeat(headingNode.level)} ` : '';
|
||||
return `${headingPrefix}${headingContent}`;
|
||||
}
|
||||
|
||||
case NodeType.Subtext: {
|
||||
const subtextNode = node as SubtextNode;
|
||||
return renderNodesToPlaintext(subtextNode.children, options);
|
||||
}
|
||||
|
||||
case NodeType.List: {
|
||||
const listNode = node as ListNode;
|
||||
return listNode.items
|
||||
.map((item, index) => {
|
||||
const content = renderNodesToPlaintext(item.children, options);
|
||||
if (listNode.ordered) {
|
||||
const ordinal = item.ordinal ?? index + 1;
|
||||
return `${ordinal}. ${content}`;
|
||||
}
|
||||
return `• ${content}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
case NodeType.CodeBlock: {
|
||||
const codeBlockNode = node as CodeBlockNode;
|
||||
return options.preserveMarkdown
|
||||
? `\`\`\`${codeBlockNode.language || ''}\n${codeBlockNode.content}\`\`\``
|
||||
: codeBlockNode.content;
|
||||
}
|
||||
|
||||
case NodeType.InlineCode: {
|
||||
const inlineCodeNode = node as InlineCodeNode;
|
||||
return options.preserveMarkdown ? `\`${inlineCodeNode.content}\`` : inlineCodeNode.content;
|
||||
}
|
||||
|
||||
case NodeType.Link: {
|
||||
const linkNode = node as LinkNode;
|
||||
if (linkNode.text) {
|
||||
const linkText = renderNodeToPlaintext(linkNode.text, options);
|
||||
return options.preserveMarkdown ? `[${linkText}](${linkNode.url})` : linkText;
|
||||
}
|
||||
return linkNode.url;
|
||||
}
|
||||
|
||||
case NodeType.Mention:
|
||||
return renderMentionToPlaintext(node as MentionNode, options);
|
||||
|
||||
case NodeType.Timestamp: {
|
||||
const timestampNode = node as TimestampNode;
|
||||
return formatTimestamp(timestampNode.timestamp, timestampNode.style, options.i18n!);
|
||||
}
|
||||
|
||||
case NodeType.Emoji:
|
||||
return renderEmojiToPlaintext(node as EmojiNode, options);
|
||||
|
||||
case NodeType.Blockquote: {
|
||||
const blockquoteNode = node as BlockquoteNode;
|
||||
const blockquoteContent = renderNodesToPlaintext(blockquoteNode.children, options);
|
||||
if (options.preserveMarkdown) {
|
||||
return blockquoteContent
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n');
|
||||
}
|
||||
return blockquoteContent;
|
||||
}
|
||||
|
||||
case NodeType.Sequence: {
|
||||
const sequenceNode = node as SequenceNode;
|
||||
return renderNodesToPlaintext(sequenceNode.children, options);
|
||||
}
|
||||
|
||||
case NodeType.Table: {
|
||||
const tableNode = node as TableNode;
|
||||
const headerContent = tableNode.header.cells
|
||||
.map((cell) => renderNodesToPlaintext(cell.children, options))
|
||||
.join(' | ');
|
||||
const rowsContent = tableNode.rows
|
||||
.map((row) => row.cells.map((cell) => renderNodesToPlaintext(cell.children, options)).join(' | '))
|
||||
.join('\n');
|
||||
return `${headerContent}\n${rowsContent}`;
|
||||
}
|
||||
|
||||
case NodeType.Alert: {
|
||||
const alertNode = node as AlertNode;
|
||||
const alertContent = renderNodesToPlaintext(alertNode.children, options);
|
||||
const alertPrefix = `[${alertNode.alertType.toUpperCase()}] `;
|
||||
return `${alertPrefix}${alertContent}`;
|
||||
}
|
||||
|
||||
case NodeType.TableRow:
|
||||
case NodeType.TableCell:
|
||||
return '';
|
||||
|
||||
default: {
|
||||
const nodeType = typeof (node as {type?: unknown}).type === 'string' ? (node as {type: string}).type : 'unknown';
|
||||
console.warn(`Unknown node type for plaintext rendering: ${nodeType}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderMentionToPlaintext(node: MentionNode, options: PlaintextRenderOptions): string {
|
||||
const {kind} = node;
|
||||
const i18n = options.i18n!;
|
||||
|
||||
switch (kind.kind) {
|
||||
case MentionKind.User: {
|
||||
const user = UserStore.getUser(kind.id);
|
||||
if (!user) {
|
||||
return `@${kind.id}`;
|
||||
}
|
||||
|
||||
let name = user.displayName;
|
||||
if (options.channelId) {
|
||||
const channel = ChannelStore.getChannel(options.channelId);
|
||||
if (channel?.guildId) {
|
||||
name = NicknameUtils.getNickname(user, channel.guildId) || name;
|
||||
}
|
||||
}
|
||||
return `@${name}`;
|
||||
}
|
||||
|
||||
case MentionKind.Role: {
|
||||
const channel = options.channelId ? ChannelStore.getChannel(options.channelId) : null;
|
||||
const guild = GuildStore.getGuild(channel?.guildId ?? '');
|
||||
const role = guild ? guild.roles[kind.id] : null;
|
||||
if (!role) {
|
||||
return `@${i18n._(msg`unknown-role`)}`;
|
||||
}
|
||||
return `@${role.name}`;
|
||||
}
|
||||
|
||||
case MentionKind.Channel: {
|
||||
const channel = ChannelStore.getChannel(kind.id);
|
||||
if (!channel || !TEXT_BASED_CHANNEL_TYPES.has(channel.type)) {
|
||||
return `#${i18n._(msg`unknown-channel`)}`;
|
||||
}
|
||||
return `#${channel.name}`;
|
||||
}
|
||||
|
||||
case MentionKind.Everyone:
|
||||
return '@everyone';
|
||||
|
||||
case MentionKind.Here:
|
||||
return '@here';
|
||||
|
||||
case MentionKind.Command: {
|
||||
const {name, subcommandGroup, subcommand} = kind;
|
||||
let commandName = `/${name}`;
|
||||
if (subcommandGroup) {
|
||||
commandName += ` ${subcommandGroup}`;
|
||||
}
|
||||
if (subcommand) {
|
||||
commandName += ` ${subcommand}`;
|
||||
}
|
||||
return commandName;
|
||||
}
|
||||
|
||||
case MentionKind.GuildNavigation: {
|
||||
const {navigationType} = kind;
|
||||
switch (navigationType) {
|
||||
case GuildNavKind.Customize:
|
||||
return '#customize';
|
||||
case GuildNavKind.Browse:
|
||||
return '#browse';
|
||||
case GuildNavKind.Guide:
|
||||
return '#guide';
|
||||
case GuildNavKind.LinkedRoles: {
|
||||
const linkedRolesId = (kind as {navigationType: typeof GuildNavKind.LinkedRoles; id?: string}).id;
|
||||
return linkedRolesId ? `#linked-roles:${linkedRolesId}` : '#linked-roles';
|
||||
}
|
||||
default:
|
||||
return `#${navigationType}`;
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return `@${i18n._(msg`unknown-mention`)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderEmojiToPlaintext(node: EmojiNode, options: PlaintextRenderOptions): string {
|
||||
const {kind} = node;
|
||||
|
||||
if (kind.kind === EmojiKind.Standard) {
|
||||
return kind.raw;
|
||||
}
|
||||
|
||||
if (options.includeEmojiNames !== false) {
|
||||
return `:${kind.name}:`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderNodesToPlaintext(nodes: Array<Node>, options: PlaintextRenderOptions = {}): string {
|
||||
return nodes.map((node) => renderNodeToPlaintext(node, options)).join('');
|
||||
}
|
||||
|
||||
function renderToPlaintext(nodes: Array<Node>, options: PlaintextRenderOptions = {}): string {
|
||||
const result = renderNodesToPlaintext(nodes, options);
|
||||
|
||||
return result
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function parseAndRenderToPlaintext(
|
||||
content: string,
|
||||
parserFlags: number,
|
||||
options: PlaintextRenderOptions = {},
|
||||
): string {
|
||||
try {
|
||||
const parser = new Parser(content, parserFlags);
|
||||
const {nodes} = parser.parse();
|
||||
return renderToPlaintext(nodes, options);
|
||||
} catch (error) {
|
||||
console.error('Error parsing content for plaintext rendering:', error);
|
||||
return content;
|
||||
}
|
||||
}
|
||||
170
fluxer_app/src/lib/markdown/renderers/MessageJumpLink.module.css
Normal file
170
fluxer_app/src/lib/markdown/renderers/MessageJumpLink.module.css
Normal file
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.jumpLinkButton {
|
||||
border: 1px solid var(--markup-mention-border);
|
||||
padding: 0.1rem 0.35rem;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
background-color: var(--markup-jump-link-fill);
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.jumpLinkButton:hover {
|
||||
background-color: var(--markup-jump-link-hover-fill);
|
||||
}
|
||||
|
||||
.jumpLinkInfo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
min-height: 1rem;
|
||||
padding-bottom: 0.05rem;
|
||||
}
|
||||
|
||||
.jumpLinkGuild {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
transform: translateY(-0.12rem);
|
||||
}
|
||||
|
||||
.jumpLinkGuildIcon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
vertical-align: middle;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.jumpLinkGuildIcon > svg,
|
||||
.jumpLinkGuildIcon > img {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.jumpLinkGuildName {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.jumpLinkCaret {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.jumpLinkLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
transform: translateY(-0.12rem);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.jumpLinkDM {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
line-height: 1;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.jumpLinkDMName {
|
||||
font-weight: 500;
|
||||
color: inherit;
|
||||
line-height: 1;
|
||||
transform: translateY(-0.12rem);
|
||||
}
|
||||
|
||||
.jumpLinkMessage {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.jumpLinkMessageIcon {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
line-height: 0;
|
||||
padding-bottom: 0.05rem;
|
||||
}
|
||||
|
||||
.jumpLinkMessageIcon > svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.jumpLinkChannel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.jumpLinkChannelIcon {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.jumpLinkChannelIcon > svg {
|
||||
display: block;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.jumpLinkChannelName {
|
||||
font-weight: 500;
|
||||
color: inherit;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
transform: translateY(-0.12rem);
|
||||
}
|
||||
235
fluxer_app/src/lib/markdown/renderers/common/block-elements.tsx
Normal file
235
fluxer_app/src/lib/markdown/renderers/common/block-elements.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {
|
||||
CircleWavyWarningIcon,
|
||||
InfoIcon,
|
||||
LightbulbFilamentIcon,
|
||||
WarningCircleIcon,
|
||||
WarningIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React, {useEffect, useRef} from 'react';
|
||||
import markupStyles from '~/styles/Markup.module.css';
|
||||
import {AlertType} from '../../parser/types/enums';
|
||||
import type {
|
||||
AlertNode,
|
||||
BlockquoteNode,
|
||||
HeadingNode,
|
||||
ListItem,
|
||||
ListNode,
|
||||
SequenceNode,
|
||||
SubtextNode,
|
||||
TableNode,
|
||||
} from '../../parser/types/nodes';
|
||||
import {MarkdownContext, type RendererProps} from '..';
|
||||
|
||||
export const BlockquoteRenderer = observer(function BlockquoteRenderer({
|
||||
node,
|
||||
id,
|
||||
renderChildren,
|
||||
}: RendererProps<BlockquoteNode>): React.ReactElement {
|
||||
return (
|
||||
<div key={id} className={markupStyles.blockquoteContainer}>
|
||||
<div className={markupStyles.blockquoteDivider} />
|
||||
<blockquote className={markupStyles.blockquoteContent}>{renderChildren(node.children)}</blockquote>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const ListRenderer = observer(function ListRenderer({
|
||||
node,
|
||||
id,
|
||||
renderChildren,
|
||||
options,
|
||||
}: RendererProps<ListNode>): React.ReactElement {
|
||||
const Tag = node.ordered ? 'ol' : 'ul';
|
||||
const isInlineContext = options.context === MarkdownContext.RESTRICTED_INLINE_REPLY;
|
||||
|
||||
if (!node.ordered) {
|
||||
return (
|
||||
<Tag key={id} className={clsx(isInlineContext && markupStyles.inlineFormat)}>
|
||||
{node.items.map((item, i) => (
|
||||
<li key={`${id}-item-${i}`} className={clsx(isInlineContext && markupStyles.inlineFormat)}>
|
||||
{renderChildren(item.children)}
|
||||
</li>
|
||||
))}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
const segments: Array<{startOrdinal: number; items: Array<ListItem>}> = [];
|
||||
let currentSegment: Array<ListItem> = [];
|
||||
let currentOrdinal = node.items[0]?.ordinal || 1;
|
||||
|
||||
node.items.forEach((item, i) => {
|
||||
const itemOrdinal = item.ordinal !== undefined ? item.ordinal : i === 0 ? 1 : currentOrdinal + 1;
|
||||
|
||||
if (itemOrdinal !== currentOrdinal && i > 0) {
|
||||
segments.push({
|
||||
startOrdinal: currentSegment[0].ordinal || 1,
|
||||
items: [...currentSegment],
|
||||
});
|
||||
currentSegment = [];
|
||||
}
|
||||
|
||||
currentSegment.push({...item, ordinal: itemOrdinal});
|
||||
currentOrdinal = itemOrdinal + 1;
|
||||
});
|
||||
|
||||
if (currentSegment.length > 0) {
|
||||
segments.push({
|
||||
startOrdinal: currentSegment[0].ordinal || 1,
|
||||
items: [...currentSegment],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key={id}>
|
||||
{segments.map((segment, segmentIndex) => {
|
||||
let maxDigits = 1;
|
||||
if (node.items.length > 0) {
|
||||
const largestNumber = Math.max(segment.startOrdinal, segment.startOrdinal + segment.items.length - 1);
|
||||
maxDigits = String(largestNumber).length;
|
||||
}
|
||||
|
||||
const listStyle = {
|
||||
'--totalCharacters': maxDigits,
|
||||
} as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<Tag
|
||||
key={`${id}-segment-${segmentIndex}`}
|
||||
className={isInlineContext ? markupStyles.inlineFormat : undefined}
|
||||
start={segment.startOrdinal}
|
||||
style={listStyle}
|
||||
>
|
||||
{segment.items.map((item, itemIndex) => (
|
||||
<li
|
||||
key={`${id}-segment-${segmentIndex}-item-${itemIndex}`}
|
||||
className={clsx(isInlineContext && markupStyles.inlineFormat)}
|
||||
>
|
||||
{renderChildren(item.children)}
|
||||
</li>
|
||||
))}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
|
||||
export const HeadingRenderer = observer(function HeadingRenderer({
|
||||
node,
|
||||
id,
|
||||
renderChildren,
|
||||
options,
|
||||
}: RendererProps<HeadingNode>): React.ReactElement {
|
||||
const Tag = `h${node.level}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
||||
const isInlineContext = options.context === MarkdownContext.RESTRICTED_INLINE_REPLY;
|
||||
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (headingRef.current && !isInlineContext && node.level <= 3) {
|
||||
const headingId = headingRef.current.textContent
|
||||
?.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
|
||||
if (headingId) {
|
||||
headingRef.current.id = headingId;
|
||||
}
|
||||
}
|
||||
}, [isInlineContext, node.level]);
|
||||
|
||||
return (
|
||||
<Tag ref={headingRef} key={id} className={clsx(isInlineContext && markupStyles.inlineFormat)}>
|
||||
{renderChildren(node.children)}
|
||||
</Tag>
|
||||
);
|
||||
});
|
||||
|
||||
export const SubtextRenderer = observer(function SubtextRenderer({
|
||||
node,
|
||||
id,
|
||||
renderChildren,
|
||||
options,
|
||||
}: RendererProps<SubtextNode>): React.ReactElement {
|
||||
const isInlineContext = options.context === MarkdownContext.RESTRICTED_INLINE_REPLY;
|
||||
|
||||
return (
|
||||
<small key={id} className={clsx(isInlineContext && markupStyles.inlineFormat)}>
|
||||
{renderChildren(node.children)}
|
||||
</small>
|
||||
);
|
||||
});
|
||||
|
||||
export const SequenceRenderer = observer(function SequenceRenderer({
|
||||
node,
|
||||
id,
|
||||
renderChildren,
|
||||
}: RendererProps<SequenceNode>): React.ReactElement {
|
||||
return <React.Fragment key={id}>{renderChildren(node.children)}</React.Fragment>;
|
||||
});
|
||||
|
||||
export const TableRenderer = observer(function TableRenderer(_props: RendererProps<TableNode>): React.ReactElement {
|
||||
throw new Error('unsupported');
|
||||
});
|
||||
|
||||
export const AlertRenderer = observer(function AlertRenderer({
|
||||
node,
|
||||
id,
|
||||
renderChildren,
|
||||
options,
|
||||
}: RendererProps<AlertNode>): React.ReactElement {
|
||||
const i18n = options.i18n!;
|
||||
const alertConfig: Record<
|
||||
AlertType,
|
||||
{
|
||||
Icon: React.ComponentType<{className?: string}>;
|
||||
className: string;
|
||||
title: string;
|
||||
}
|
||||
> = {
|
||||
[AlertType.Note]: {Icon: InfoIcon, className: markupStyles.alertNote, title: i18n._(msg`Note`)},
|
||||
[AlertType.Tip]: {Icon: LightbulbFilamentIcon, className: markupStyles.alertTip, title: i18n._(msg`Tip`)},
|
||||
[AlertType.Important]: {Icon: WarningIcon, className: markupStyles.alertImportant, title: i18n._(msg`Important`)},
|
||||
[AlertType.Warning]: {
|
||||
Icon: CircleWavyWarningIcon,
|
||||
className: markupStyles.alertWarning,
|
||||
title: i18n._(msg`Warning`),
|
||||
},
|
||||
[AlertType.Caution]: {Icon: WarningCircleIcon, className: markupStyles.alertCaution, title: i18n._(msg`Caution`)},
|
||||
};
|
||||
|
||||
const {Icon, className, title} = alertConfig[node.alertType] || alertConfig[AlertType.Note];
|
||||
|
||||
return (
|
||||
<div key={id} className={clsx(markupStyles.alert, className)}>
|
||||
<div className={markupStyles.alertTitle}>
|
||||
<Icon className={markupStyles.alertIcon} />
|
||||
{title}
|
||||
</div>
|
||||
<div className={markupStyles.alertContent}>{renderChildren(node.children)}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
144
fluxer_app/src/lib/markdown/renderers/common/code-elements.tsx
Normal file
144
fluxer_app/src/lib/markdown/renderers/common/code-elements.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {CheckCircleIcon, ClipboardIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import highlight from 'highlight.js';
|
||||
import katex from 'katex';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useState} from 'react';
|
||||
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
|
||||
import codeElementsStyles from '~/styles/CodeElements.module.css';
|
||||
import markupStyles from '~/styles/Markup.module.css';
|
||||
import type {CodeBlockNode, InlineCodeNode} from '../../parser/types/nodes';
|
||||
import type {RendererProps} from '..';
|
||||
|
||||
export const CodeBlockRenderer = observer(function CodeBlockRenderer({
|
||||
node,
|
||||
id,
|
||||
options,
|
||||
}: RendererProps<CodeBlockNode>): React.ReactElement {
|
||||
const i18n = options.i18n!;
|
||||
const {content, language} = node;
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
TextCopyActionCreators.copy(i18n, content);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
};
|
||||
|
||||
const copyButton = (
|
||||
<div className={markupStyles.codeActions}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label={isCopied ? i18n._(msg`Copied!`) : i18n._(msg`Copy code`)}
|
||||
className={clsx(isCopied && markupStyles.codeActionsVisible)}
|
||||
>
|
||||
{isCopied ? (
|
||||
<CheckCircleIcon className={codeElementsStyles.icon} />
|
||||
) : (
|
||||
<ClipboardIcon className={codeElementsStyles.icon} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (language?.toLowerCase() === 'latex' || language?.toLowerCase() === 'tex') {
|
||||
try {
|
||||
const html = katex.renderToString(content, {
|
||||
displayMode: true,
|
||||
throwOnError: false,
|
||||
errorColor: 'var(--accent-danger)',
|
||||
trust: false,
|
||||
strict: false,
|
||||
output: 'html',
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={id} className={markupStyles.latexCodeBlock}>
|
||||
<div className={markupStyles.codeContainer}>
|
||||
{copyButton}
|
||||
<div
|
||||
className={markupStyles.latexContent}
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: KaTeX output is sanitized
|
||||
dangerouslySetInnerHTML={{__html: html}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('KaTeX rendering error:', error);
|
||||
return (
|
||||
<div key={id} className={markupStyles.codeContainer}>
|
||||
{copyButton}
|
||||
<pre>
|
||||
<code className={markupStyles.hljs}>
|
||||
{i18n._(msg`Error rendering LaTeX: ${(error as Error).message || i18n._(msg`Unknown error`)}`)}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let highlightedContent: React.ReactElement;
|
||||
|
||||
if (language && highlight.getLanguage(language)) {
|
||||
try {
|
||||
const highlighted = highlight.highlight(content, {
|
||||
language: language,
|
||||
ignoreIllegals: true,
|
||||
});
|
||||
|
||||
highlightedContent = (
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: highlight.js output is sanitized
|
||||
<code className={clsx(markupStyles.hljs, language)} dangerouslySetInnerHTML={{__html: highlighted.value}} />
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Syntax highlighting error:', error);
|
||||
highlightedContent = <code className={markupStyles.hljs}>{content}</code>;
|
||||
}
|
||||
} else {
|
||||
highlightedContent = <code className={markupStyles.hljs}>{content}</code>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={id} className={markupStyles.codeContainer}>
|
||||
{copyButton}
|
||||
<pre>{highlightedContent}</pre>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const InlineCodeRenderer = observer(function InlineCodeRenderer({
|
||||
node,
|
||||
id,
|
||||
}: RendererProps<InlineCodeNode>): React.ReactElement {
|
||||
const normalizedContent = node.content.replace(/\s+/g, ' ');
|
||||
|
||||
return (
|
||||
<code key={id} className={markupStyles.inline}>
|
||||
{normalizedContent}
|
||||
</code>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useMemo} from 'react';
|
||||
import markupStyles from '~/styles/Markup.module.css';
|
||||
import {normalizeUrl, useSpoilerState} from '~/utils/SpoilerUtils';
|
||||
import {NodeType} from '../../parser/types/enums';
|
||||
import type {FormattingNode, Node} from '../../parser/types/nodes';
|
||||
import type {RendererProps} from '..';
|
||||
|
||||
export const StrongRenderer = observer(function StrongRenderer({
|
||||
node,
|
||||
id,
|
||||
renderChildren,
|
||||
}: RendererProps<FormattingNode>): React.ReactElement {
|
||||
return <strong key={id}>{renderChildren(node.children)}</strong>;
|
||||
});
|
||||
|
||||
export const EmphasisRenderer = observer(function EmphasisRenderer({
|
||||
node,
|
||||
id,
|
||||
renderChildren,
|
||||
}: RendererProps<FormattingNode>): React.ReactElement {
|
||||
return <em key={id}>{renderChildren(node.children)}</em>;
|
||||
});
|
||||
|
||||
export const UnderlineRenderer = observer(function UnderlineRenderer({
|
||||
node,
|
||||
id,
|
||||
renderChildren,
|
||||
}: RendererProps<FormattingNode>): React.ReactElement {
|
||||
return <u key={id}>{renderChildren(node.children)}</u>;
|
||||
});
|
||||
|
||||
export const StrikethroughRenderer = observer(function StrikethroughRenderer({
|
||||
node,
|
||||
id,
|
||||
renderChildren,
|
||||
}: RendererProps<FormattingNode>): React.ReactElement {
|
||||
return <s key={id}>{renderChildren(node.children)}</s>;
|
||||
});
|
||||
|
||||
interface SpoilerNode extends FormattingNode {
|
||||
type: typeof NodeType.Spoiler;
|
||||
isBlock: boolean;
|
||||
}
|
||||
|
||||
export const SpoilerRenderer = observer(function SpoilerRenderer({
|
||||
node,
|
||||
id,
|
||||
renderChildren,
|
||||
options,
|
||||
}: RendererProps<SpoilerNode>): React.ReactElement {
|
||||
const i18n = options.i18n!;
|
||||
const collectUrls = useCallback((nodes: Array<Node>): Array<string> => {
|
||||
const urls: Array<string> = [];
|
||||
for (const child of nodes) {
|
||||
if (child.type === NodeType.Link) {
|
||||
const normalized = normalizeUrl(child.url);
|
||||
if (normalized) urls.push(normalized);
|
||||
}
|
||||
|
||||
if ('children' in child && Array.isArray((child as {children?: Array<Node>}).children)) {
|
||||
urls.push(...collectUrls((child as {children: Array<Node>}).children));
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
}, []);
|
||||
|
||||
const spoilerUrls = useMemo(() => Array.from(new Set(collectUrls(node.children))), [collectUrls, node.children]);
|
||||
const {hidden, reveal, autoRevealed} = useSpoilerState(true, options.channelId, spoilerUrls);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (hidden) {
|
||||
reveal();
|
||||
}
|
||||
}, [hidden, reveal]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
},
|
||||
[handleClick],
|
||||
);
|
||||
|
||||
const isBlock = node.isBlock;
|
||||
const wrapperClass = isBlock ? markupStyles.blockSpoilerWrapper : markupStyles.spoilerWrapper;
|
||||
const spoilerClass = isBlock ? markupStyles.blockSpoiler : markupStyles.spoiler;
|
||||
|
||||
const shouldReveal = !hidden || autoRevealed;
|
||||
|
||||
return (
|
||||
<span key={id} className={wrapperClass}>
|
||||
{shouldReveal ? (
|
||||
<span className={spoilerClass} data-revealed={shouldReveal}>
|
||||
<span className={markupStyles.spoilerContent} aria-hidden={!shouldReveal}>
|
||||
{renderChildren(node.children)}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={spoilerClass}
|
||||
data-revealed={shouldReveal}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={i18n._(msg`Click to reveal spoiler`)}
|
||||
>
|
||||
<span className={markupStyles.spoilerContent} aria-hidden>
|
||||
{renderChildren(node.children)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
280
fluxer_app/src/lib/markdown/renderers/emoji-renderer.tsx
Normal file
280
fluxer_app/src/lib/markdown/renderers/emoji-renderer.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {FloatingPortal} from '@floating-ui/react';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {clsx} from 'clsx';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import {EmojiInfoBottomSheet} from '~/components/bottomsheets/EmojiInfoBottomSheet';
|
||||
import {EmojiInfoContent} from '~/components/emojis/EmojiInfoContent';
|
||||
import {EmojiTooltipContent} from '~/components/uikit/EmojiTooltipContent/EmojiTooltipContent';
|
||||
import {useTooltipPortalRoot} from '~/components/uikit/Tooltip';
|
||||
import {useMergeRefs} from '~/hooks/useMergeRefs';
|
||||
import {useReactionTooltip} from '~/hooks/useReactionTooltip';
|
||||
import EmojiStore, {type Emoji} from '~/stores/EmojiStore';
|
||||
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
||||
import {shouldUseNativeEmoji} from '~/utils/EmojiUtils';
|
||||
import {EmojiKind} from '../parser/types/enums';
|
||||
import type {EmojiNode} from '../parser/types/nodes';
|
||||
import {getEmojiRenderData} from '../utils/emoji-detector';
|
||||
import type {RendererProps} from '.';
|
||||
|
||||
interface EmojiBottomSheetState {
|
||||
isOpen: boolean;
|
||||
emoji: {id?: string; name: string; animated?: boolean} | null;
|
||||
}
|
||||
|
||||
interface EmojiWithTooltipProps {
|
||||
children: React.ReactElement<Record<string, unknown> & {ref?: React.Ref<HTMLElement>}>;
|
||||
emojiUrl: string | null;
|
||||
nativeEmoji?: React.ReactNode;
|
||||
emojiName: string;
|
||||
emojiForSubtext: Emoji;
|
||||
}
|
||||
|
||||
const EmojiWithTooltip = observer(
|
||||
({children, emojiUrl, nativeEmoji, emojiName, emojiForSubtext}: EmojiWithTooltipProps) => {
|
||||
const tooltipPortalRoot = useTooltipPortalRoot();
|
||||
const {targetRef, tooltipRef, state, updatePosition, handlers, tooltipHandlers} = useReactionTooltip(500);
|
||||
|
||||
const childRef = children.props.ref ?? null;
|
||||
const mergedRef = useMergeRefs([targetRef, childRef]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{React.cloneElement(children, {
|
||||
ref: mergedRef,
|
||||
...handlers,
|
||||
} as Record<string, unknown>)}
|
||||
{state.isOpen && (
|
||||
<FloatingPortal root={tooltipPortalRoot}>
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
ref={(node) => {
|
||||
(tooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
if (node && targetRef.current) {
|
||||
updatePosition();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: state.x,
|
||||
top: state.y,
|
||||
zIndex: 'var(--z-index-tooltip)',
|
||||
visibility: state.isReady ? 'visible' : 'hidden',
|
||||
}}
|
||||
initial={{opacity: 0, scale: 0.98}}
|
||||
animate={{opacity: 1, scale: 1}}
|
||||
exit={{opacity: 0, scale: 0.98}}
|
||||
transition={{
|
||||
opacity: {duration: 0.1},
|
||||
scale: {type: 'spring', damping: 25, stiffness: 500},
|
||||
}}
|
||||
{...tooltipHandlers}
|
||||
>
|
||||
<EmojiTooltipContent
|
||||
emoji={nativeEmoji}
|
||||
emojiUrl={emojiUrl}
|
||||
emojiAlt={emojiName}
|
||||
primaryContent={emojiName}
|
||||
subtext={<EmojiInfoContent emoji={emojiForSubtext} />}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const EmojiRendererInner = observer(function EmojiRendererInner({
|
||||
node,
|
||||
id,
|
||||
options,
|
||||
}: RendererProps<EmojiNode>): React.ReactElement {
|
||||
const {shouldJumboEmojis, guildId, messageId, disableAnimatedEmoji} = options;
|
||||
const i18n = options.i18n!;
|
||||
const emojiData = getEmojiRenderData(node, guildId, disableAnimatedEmoji);
|
||||
const isMobile = MobileLayoutStore.enabled;
|
||||
|
||||
const [bottomSheetState, setBottomSheetState] = React.useState<EmojiBottomSheetState>({
|
||||
isOpen: false,
|
||||
emoji: null,
|
||||
});
|
||||
|
||||
const className = clsx('emoji', shouldJumboEmojis && 'jumboable');
|
||||
|
||||
const size = shouldJumboEmojis ? 240 : 96;
|
||||
const qualitySuffix = `?size=${size}&quality=lossless`;
|
||||
const tooltipEmojiSize = 240;
|
||||
const tooltipQualitySuffix = `?size=${tooltipEmojiSize}&quality=lossless`;
|
||||
|
||||
const isCustomEmoji = node.kind.kind === EmojiKind.Custom;
|
||||
const emojiRecord = isCustomEmoji && emojiData.id ? EmojiStore.getEmojiById(emojiData.id) : null;
|
||||
|
||||
const handleOpenBottomSheet = React.useCallback(() => {
|
||||
if (!isMobile) return;
|
||||
|
||||
const emojiInfo = {
|
||||
name: node.kind.name,
|
||||
id: isCustomEmoji ? (node.kind as {id: string}).id : undefined,
|
||||
animated: isCustomEmoji ? (node.kind as {animated: boolean}).animated : false,
|
||||
};
|
||||
|
||||
setBottomSheetState({isOpen: true, emoji: emojiInfo});
|
||||
}, [isMobile, node.kind, isCustomEmoji]);
|
||||
|
||||
const handleCloseBottomSheet = React.useCallback(() => {
|
||||
setBottomSheetState({isOpen: false, emoji: null});
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleOpenBottomSheet();
|
||||
}
|
||||
},
|
||||
[handleOpenBottomSheet],
|
||||
);
|
||||
|
||||
const buildEmojiForSubtext = React.useCallback((): Emoji => {
|
||||
if (emojiRecord) {
|
||||
return emojiRecord;
|
||||
}
|
||||
return {
|
||||
name: node.kind.name,
|
||||
allNamesString: node.kind.name,
|
||||
uniqueName: node.kind.name,
|
||||
};
|
||||
}, [emojiRecord, node.kind.name]);
|
||||
|
||||
const getTooltipData = React.useCallback(() => {
|
||||
const emojiUrl =
|
||||
shouldUseNativeEmoji && node.kind.kind === EmojiKind.Standard
|
||||
? null
|
||||
: `${emojiData.url}${emojiData.id ? tooltipQualitySuffix : ''}`;
|
||||
|
||||
const nativeEmoji =
|
||||
shouldUseNativeEmoji && node.kind.kind === EmojiKind.Standard ? (
|
||||
<span className={clsx('emoji', 'jumboable')}>{node.kind.raw}</span>
|
||||
) : undefined;
|
||||
|
||||
const emojiForSubtext = buildEmojiForSubtext();
|
||||
|
||||
return {emojiUrl, nativeEmoji, emojiForSubtext};
|
||||
}, [emojiData, node.kind, buildEmojiForSubtext, tooltipQualitySuffix]);
|
||||
|
||||
const handleImageError = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.opacity = '0.5';
|
||||
target.alt = `${emojiData.name} ${i18n._(msg`(failed to load)`)}`;
|
||||
};
|
||||
|
||||
if (shouldUseNativeEmoji && node.kind.kind === EmojiKind.Standard) {
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className={className}
|
||||
data-message-id={messageId}
|
||||
onClick={handleOpenBottomSheet}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{node.kind.raw}
|
||||
</span>
|
||||
<EmojiInfoBottomSheet
|
||||
isOpen={bottomSheetState.isOpen}
|
||||
onClose={handleCloseBottomSheet}
|
||||
emoji={bottomSheetState.emoji}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const tooltipData = getTooltipData();
|
||||
return (
|
||||
<EmojiWithTooltip
|
||||
key={id}
|
||||
emojiUrl={tooltipData.emojiUrl}
|
||||
nativeEmoji={tooltipData.nativeEmoji}
|
||||
emojiName={emojiData.name}
|
||||
emojiForSubtext={tooltipData.emojiForSubtext}
|
||||
>
|
||||
<span className={className} data-message-id={messageId}>
|
||||
{node.kind.raw}
|
||||
</span>
|
||||
</EmojiWithTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
<span onClick={handleOpenBottomSheet} onKeyDown={handleKeyDown} role="button" tabIndex={0}>
|
||||
<img
|
||||
draggable={false}
|
||||
className={className}
|
||||
alt={emojiData.name}
|
||||
src={`${emojiData.url}${emojiData.id ? qualitySuffix : ''}`}
|
||||
data-message-id={messageId}
|
||||
data-emoji-id={emojiData.id}
|
||||
data-animated={emojiData.isAnimated}
|
||||
onError={handleImageError}
|
||||
loading="lazy"
|
||||
/>
|
||||
</span>
|
||||
<EmojiInfoBottomSheet
|
||||
isOpen={bottomSheetState.isOpen}
|
||||
onClose={handleCloseBottomSheet}
|
||||
emoji={bottomSheetState.emoji}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const tooltipData = getTooltipData();
|
||||
return (
|
||||
<EmojiWithTooltip
|
||||
key={id}
|
||||
emojiUrl={tooltipData.emojiUrl}
|
||||
nativeEmoji={tooltipData.nativeEmoji}
|
||||
emojiName={emojiData.name}
|
||||
emojiForSubtext={tooltipData.emojiForSubtext}
|
||||
>
|
||||
<img
|
||||
draggable={false}
|
||||
className={className}
|
||||
alt={emojiData.name}
|
||||
src={`${emojiData.url}${emojiData.id ? qualitySuffix : ''}`}
|
||||
data-message-id={messageId}
|
||||
data-emoji-id={emojiData.id}
|
||||
data-animated={emojiData.isAnimated}
|
||||
onError={handleImageError}
|
||||
loading="lazy"
|
||||
/>
|
||||
</EmojiWithTooltip>
|
||||
);
|
||||
});
|
||||
|
||||
export const EmojiRenderer = EmojiRendererInner;
|
||||
209
fluxer_app/src/lib/markdown/renderers/index.tsx
Normal file
209
fluxer_app/src/lib/markdown/renderers/index.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {i18n} from '@lingui/core';
|
||||
import React from 'react';
|
||||
import markupStyles from '~/styles/Markup.module.css';
|
||||
import {TABLE_PARSING_FLAG} from '../config';
|
||||
import {Parser} from '../parser/parser/parser';
|
||||
import {NodeType, ParserFlags} from '../parser/types/enums';
|
||||
import type {Node} from '../parser/types/nodes';
|
||||
import {shouldRenderJumboEmojis} from '../utils/jumbo-detector';
|
||||
import {
|
||||
AlertRenderer,
|
||||
BlockquoteRenderer,
|
||||
HeadingRenderer,
|
||||
ListRenderer,
|
||||
SequenceRenderer,
|
||||
SubtextRenderer,
|
||||
TableRenderer,
|
||||
} from './common/block-elements';
|
||||
import {CodeBlockRenderer, InlineCodeRenderer} from './common/code-elements';
|
||||
import {
|
||||
EmphasisRenderer,
|
||||
SpoilerRenderer,
|
||||
StrikethroughRenderer,
|
||||
StrongRenderer,
|
||||
UnderlineRenderer,
|
||||
} from './common/formatting-elements';
|
||||
import {EmojiRenderer} from './emoji-renderer';
|
||||
import {LinkRenderer} from './link-renderer';
|
||||
import {MentionRenderer} from './mention-renderer';
|
||||
import {TextRenderer} from './text-renderer';
|
||||
import {TimestampRenderer} from './timestamp-renderer';
|
||||
|
||||
export const MarkdownContext = {
|
||||
STANDARD_WITH_JUMBO: 0,
|
||||
RESTRICTED_INLINE_REPLY: 1,
|
||||
RESTRICTED_USER_BIO: 2,
|
||||
RESTRICTED_EMBED_DESCRIPTION: 3,
|
||||
STANDARD_WITHOUT_JUMBO: 4,
|
||||
} as const;
|
||||
export type MarkdownContext = (typeof MarkdownContext)[keyof typeof MarkdownContext];
|
||||
|
||||
export interface MarkdownParseOptions {
|
||||
context: MarkdownContext;
|
||||
disableAnimatedEmoji?: boolean;
|
||||
channelId?: string;
|
||||
messageId?: string;
|
||||
guildId?: string;
|
||||
}
|
||||
|
||||
interface MarkdownRenderOptions extends MarkdownParseOptions {
|
||||
shouldJumboEmojis: boolean;
|
||||
i18n: I18n;
|
||||
}
|
||||
|
||||
export interface RendererProps<T extends Node = Node> {
|
||||
node: T;
|
||||
id: string;
|
||||
renderChildren: (nodes: Array<Node>) => React.ReactNode;
|
||||
options: MarkdownRenderOptions;
|
||||
}
|
||||
|
||||
const STANDARD_FLAGS =
|
||||
ParserFlags.ALLOW_SPOILERS |
|
||||
ParserFlags.ALLOW_HEADINGS |
|
||||
ParserFlags.ALLOW_LISTS |
|
||||
ParserFlags.ALLOW_CODE_BLOCKS |
|
||||
ParserFlags.ALLOW_MASKED_LINKS |
|
||||
ParserFlags.ALLOW_COMMAND_MENTIONS |
|
||||
ParserFlags.ALLOW_GUILD_NAVIGATIONS |
|
||||
ParserFlags.ALLOW_USER_MENTIONS |
|
||||
ParserFlags.ALLOW_ROLE_MENTIONS |
|
||||
ParserFlags.ALLOW_CHANNEL_MENTIONS |
|
||||
ParserFlags.ALLOW_EVERYONE_MENTIONS |
|
||||
ParserFlags.ALLOW_BLOCKQUOTES |
|
||||
ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES |
|
||||
ParserFlags.ALLOW_SUBTEXT |
|
||||
TABLE_PARSING_FLAG |
|
||||
ParserFlags.ALLOW_ALERTS |
|
||||
ParserFlags.ALLOW_AUTOLINKS;
|
||||
|
||||
const RESTRICTED_INLINE_REPLY_FLAGS =
|
||||
STANDARD_FLAGS &
|
||||
~(
|
||||
ParserFlags.ALLOW_BLOCKQUOTES |
|
||||
ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES |
|
||||
ParserFlags.ALLOW_SUBTEXT |
|
||||
ParserFlags.ALLOW_TABLES |
|
||||
ParserFlags.ALLOW_ALERTS |
|
||||
ParserFlags.ALLOW_CODE_BLOCKS |
|
||||
ParserFlags.ALLOW_HEADINGS |
|
||||
ParserFlags.ALLOW_LISTS
|
||||
);
|
||||
|
||||
const RESTRICTED_USER_BIO_FLAGS =
|
||||
STANDARD_FLAGS &
|
||||
~(
|
||||
ParserFlags.ALLOW_HEADINGS |
|
||||
ParserFlags.ALLOW_CODE_BLOCKS |
|
||||
ParserFlags.ALLOW_ROLE_MENTIONS |
|
||||
ParserFlags.ALLOW_EVERYONE_MENTIONS |
|
||||
ParserFlags.ALLOW_SUBTEXT |
|
||||
ParserFlags.ALLOW_TABLES |
|
||||
ParserFlags.ALLOW_ALERTS
|
||||
);
|
||||
|
||||
const RESTRICTED_EMBED_DESCRIPTION_FLAGS =
|
||||
STANDARD_FLAGS &
|
||||
~(ParserFlags.ALLOW_HEADINGS | ParserFlags.ALLOW_TABLES | ParserFlags.ALLOW_ALERTS | ParserFlags.ALLOW_AUTOLINKS);
|
||||
|
||||
export function getParserFlagsForContext(context: MarkdownContext): number {
|
||||
switch (context) {
|
||||
case MarkdownContext.RESTRICTED_INLINE_REPLY:
|
||||
return RESTRICTED_INLINE_REPLY_FLAGS;
|
||||
case MarkdownContext.RESTRICTED_USER_BIO:
|
||||
return RESTRICTED_USER_BIO_FLAGS;
|
||||
case MarkdownContext.RESTRICTED_EMBED_DESCRIPTION:
|
||||
return RESTRICTED_EMBED_DESCRIPTION_FLAGS;
|
||||
default:
|
||||
return STANDARD_FLAGS;
|
||||
}
|
||||
}
|
||||
|
||||
export function parse({content, context}: {content: string; context: MarkdownContext}) {
|
||||
const flags = getParserFlagsForContext(context);
|
||||
const parser = new Parser(content, flags);
|
||||
return parser.parse();
|
||||
}
|
||||
|
||||
const renderers: Record<NodeType, React.ComponentType<RendererProps<any>>> = {
|
||||
[NodeType.Sequence]: SequenceRenderer,
|
||||
[NodeType.Text]: TextRenderer,
|
||||
[NodeType.Strong]: StrongRenderer,
|
||||
[NodeType.Emphasis]: EmphasisRenderer,
|
||||
[NodeType.Underline]: UnderlineRenderer,
|
||||
[NodeType.Strikethrough]: StrikethroughRenderer,
|
||||
[NodeType.Spoiler]: SpoilerRenderer,
|
||||
[NodeType.Timestamp]: TimestampRenderer,
|
||||
[NodeType.Blockquote]: BlockquoteRenderer,
|
||||
[NodeType.CodeBlock]: CodeBlockRenderer,
|
||||
[NodeType.InlineCode]: InlineCodeRenderer,
|
||||
[NodeType.Link]: LinkRenderer,
|
||||
[NodeType.Mention]: MentionRenderer,
|
||||
[NodeType.Emoji]: EmojiRenderer,
|
||||
[NodeType.List]: ListRenderer,
|
||||
[NodeType.Heading]: HeadingRenderer,
|
||||
[NodeType.Subtext]: SubtextRenderer,
|
||||
[NodeType.Table]: TableRenderer,
|
||||
[NodeType.TableRow]: () => null,
|
||||
[NodeType.TableCell]: () => null,
|
||||
[NodeType.Alert]: AlertRenderer,
|
||||
};
|
||||
|
||||
function renderNode(node: Node, id: string, options: MarkdownRenderOptions): React.ReactNode {
|
||||
const renderer = renderers[node.type];
|
||||
if (!renderer) {
|
||||
console.warn(`No renderer found for node type: ${node.type}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderChildrenFn = (children: Array<Node>) =>
|
||||
children.map((child, i) => renderNode(child, `${id}-${i}`, options));
|
||||
|
||||
return React.createElement(renderer, {
|
||||
node,
|
||||
id,
|
||||
renderChildren: renderChildrenFn,
|
||||
options,
|
||||
key: id,
|
||||
});
|
||||
}
|
||||
|
||||
export function render(nodes: Array<Node>, options: MarkdownParseOptions): React.ReactNode {
|
||||
const shouldJumboEmojis = options.context === MarkdownContext.STANDARD_WITH_JUMBO && shouldRenderJumboEmojis(nodes);
|
||||
|
||||
const renderOptions: MarkdownRenderOptions = {
|
||||
...options,
|
||||
shouldJumboEmojis,
|
||||
i18n,
|
||||
};
|
||||
|
||||
return nodes.map((node, i) => renderNode(node, `${options.context}-${i}`, renderOptions));
|
||||
}
|
||||
|
||||
export function wrapRenderedContent(content: React.ReactNode, context: MarkdownContext): React.ReactNode {
|
||||
if (context === MarkdownContext.RESTRICTED_INLINE_REPLY) {
|
||||
return <div className={markupStyles.inlineFormat}>{content}</div>;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
279
fluxer_app/src/lib/markdown/renderers/link-renderer.tsx
Normal file
279
fluxer_app/src/lib/markdown/renderers/link-renderer.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {CaretRightIcon, ChatTeardropIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as InviteActionCreators from '~/actions/InviteActionCreators';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as ThemeActionCreators from '~/actions/ThemeActionCreators';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import {ExternalLinkWarningModal} from '~/components/modals/ExternalLinkWarningModal';
|
||||
import {UserSettingsModal} from '~/components/modals/UserSettingsModal';
|
||||
import {GuildIcon} from '~/components/popouts/GuildIcon';
|
||||
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
|
||||
import {Routes} from '~/Routes';
|
||||
import type {ChannelRecord} from '~/records/ChannelRecord';
|
||||
import type {GuildRecord} from '~/records/GuildRecord';
|
||||
import ChannelStore from '~/stores/ChannelStore';
|
||||
import DeveloperModeStore from '~/stores/DeveloperModeStore';
|
||||
import GuildStore from '~/stores/GuildStore';
|
||||
import TrustedDomainStore from '~/stores/TrustedDomainStore';
|
||||
import markupStyles from '~/styles/Markup.module.css';
|
||||
import {APP_PROTOCOL_PREFIX} from '~/utils/appProtocol';
|
||||
import {getDMDisplayName, getIcon, getName} from '~/utils/ChannelUtils';
|
||||
import {
|
||||
isInternalChannelHost,
|
||||
parseChannelJumpLink,
|
||||
parseChannelUrl,
|
||||
parseMessageJumpLink,
|
||||
} from '~/utils/DeepLinkUtils';
|
||||
import * as InviteUtils from '~/utils/InviteUtils';
|
||||
import {goToMessage} from '~/utils/MessageNavigator';
|
||||
import {openExternalUrl} from '~/utils/NativeUtils';
|
||||
import * as RouterUtils from '~/utils/RouterUtils';
|
||||
import * as ThemeUtils from '~/utils/ThemeUtils';
|
||||
import type {LinkNode} from '../parser/types/nodes';
|
||||
import type {RendererProps} from '.';
|
||||
import jumpLinkStyles from './MessageJumpLink.module.css';
|
||||
|
||||
interface JumpLinkMentionProps {
|
||||
channel: ChannelRecord;
|
||||
guild: GuildRecord | null;
|
||||
messageId?: string;
|
||||
i18n: I18n;
|
||||
}
|
||||
|
||||
const JumpLinkMention = observer(function JumpLinkMention({channel, guild, messageId, i18n}: JumpLinkMentionProps) {
|
||||
const handleClick = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (messageId) {
|
||||
goToMessage(channel.id, messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
const channelPath = channel.guildId
|
||||
? Routes.guildChannel(channel.guildId, channel.id)
|
||||
: Routes.dmChannel(channel.id);
|
||||
RouterUtils.transitionTo(channelPath);
|
||||
},
|
||||
[channel.id, channel.guildId, messageId],
|
||||
);
|
||||
|
||||
const displayName = channel.isPrivate() ? getDMDisplayName(channel) : (channel.name ?? channel.id);
|
||||
const labelText = guild ? guild.name : displayName;
|
||||
const shouldShowChannelInfo = !messageId && Boolean(channel.guildId);
|
||||
const channelDisplayName = channel.name ?? getName(channel);
|
||||
const isDMChannel = channel.isPrivate() && !channel.guildId;
|
||||
const shouldShowDMIconLabel = isDMChannel && !messageId;
|
||||
const hasDetailChunk = Boolean(messageId) || shouldShowChannelInfo;
|
||||
const ariaLabel = messageId
|
||||
? labelText
|
||||
? i18n._(msg`Jump to the message in ${labelText}`)
|
||||
: i18n._(msg`Jump to the linked message`)
|
||||
: labelText
|
||||
? i18n._(msg`Jump to ${labelText}`)
|
||||
: i18n._(msg`Jump to the linked channel`);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(markupStyles.mention, markupStyles.interactive, jumpLinkStyles.jumpLinkButton)}
|
||||
onClick={handleClick}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<span className={jumpLinkStyles.jumpLinkInfo}>
|
||||
{guild ? (
|
||||
<span className={jumpLinkStyles.jumpLinkGuild}>
|
||||
<GuildIcon id={guild.id} name={guild.name} icon={guild.icon} className={jumpLinkStyles.jumpLinkGuildIcon} />
|
||||
<span className={jumpLinkStyles.jumpLinkGuildName}>{guild.name}</span>
|
||||
</span>
|
||||
) : shouldShowDMIconLabel ? (
|
||||
<span className={jumpLinkStyles.jumpLinkDM}>
|
||||
<span className={jumpLinkStyles.jumpLinkChannelIcon}>{getIcon(channel, {size: 12})}</span>
|
||||
<span className={jumpLinkStyles.jumpLinkDMName}>{displayName}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className={jumpLinkStyles.jumpLinkLabel}>{displayName}</span>
|
||||
)}
|
||||
{hasDetailChunk && (
|
||||
<span className={jumpLinkStyles.jumpLinkMessage} aria-hidden="true">
|
||||
<CaretRightIcon size={6} weight="bold" className={jumpLinkStyles.jumpLinkCaret} />
|
||||
{messageId ? (
|
||||
<span className={jumpLinkStyles.jumpLinkMessageIcon}>
|
||||
<ChatTeardropIcon size={12} weight="fill" />
|
||||
</span>
|
||||
) : (
|
||||
shouldShowChannelInfo && (
|
||||
<span className={jumpLinkStyles.jumpLinkChannel}>
|
||||
<span className={jumpLinkStyles.jumpLinkChannelIcon}>{getIcon(channel, {size: 12})}</span>
|
||||
<span className={jumpLinkStyles.jumpLinkChannelName}>{channelDisplayName}</span>
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
export const LinkRenderer = observer(function LinkRenderer({
|
||||
node,
|
||||
id,
|
||||
renderChildren,
|
||||
options,
|
||||
}: RendererProps<LinkNode>): React.ReactElement {
|
||||
const i18n = options.i18n!;
|
||||
const {url, text} = node;
|
||||
|
||||
const inviteCode = InviteUtils.findInvite(url);
|
||||
const themeCode = ThemeUtils.findTheme(url);
|
||||
const messageJumpTarget = parseMessageJumpLink(url);
|
||||
const jumpTarget = messageJumpTarget ?? parseChannelJumpLink(url);
|
||||
const jumpChannel = jumpTarget ? (ChannelStore.getChannel(jumpTarget.channelId) ?? null) : null;
|
||||
const jumpGuild = jumpChannel?.guildId ? (GuildStore.getGuild(jumpChannel.guildId) ?? null) : null;
|
||||
|
||||
if (jumpTarget && jumpChannel) {
|
||||
return (
|
||||
<FocusRing key={id}>
|
||||
<JumpLinkMention channel={jumpChannel} guild={jumpGuild} messageId={messageJumpTarget?.messageId} i18n={i18n} />
|
||||
</FocusRing>
|
||||
);
|
||||
}
|
||||
|
||||
const shouldShowAccessDeniedModal = Boolean(jumpTarget && !jumpChannel);
|
||||
|
||||
let isInternal = false;
|
||||
let handleClick: ((e: React.MouseEvent) => void) | undefined;
|
||||
|
||||
if (shouldShowAccessDeniedModal) {
|
||||
handleClick = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={i18n._(msg`Channel Access Denied`)}
|
||||
description={i18n._(msg`You do not have access to the channel where this message was sent.`)}
|
||||
primaryText={i18n._(msg`Okay`)}
|
||||
primaryVariant="primary"
|
||||
secondaryText={false}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
};
|
||||
isInternal = true;
|
||||
} else if (url === `${APP_PROTOCOL_PREFIX}dev`) {
|
||||
handleClick = (e) => {
|
||||
e.preventDefault();
|
||||
if (DeveloperModeStore.isDeveloper) {
|
||||
ModalActionCreators.push(modal(() => <UserSettingsModal initialTab="developer_options" />));
|
||||
} else {
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title="Secret Link Found!"
|
||||
description="You found a secret link, but it wasn't meant for you!"
|
||||
primaryText="Okay"
|
||||
primaryVariant="primary"
|
||||
secondaryText={false}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}
|
||||
};
|
||||
isInternal = true;
|
||||
} else {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
isInternal = isInternalChannelHost(parsed.host) && parsed.pathname.startsWith('/channels/');
|
||||
|
||||
if (inviteCode) {
|
||||
handleClick = (e) => {
|
||||
e.preventDefault();
|
||||
InviteActionCreators.acceptAndTransitionToChannel(inviteCode, i18n);
|
||||
};
|
||||
} else if (themeCode) {
|
||||
handleClick = (e) => {
|
||||
e.preventDefault();
|
||||
ThemeActionCreators.openAcceptModal(themeCode, i18n);
|
||||
};
|
||||
isInternal = true;
|
||||
} else if (isInternal) {
|
||||
const channelPath = parseChannelUrl(url);
|
||||
if (channelPath) {
|
||||
handleClick = (e) => {
|
||||
e.preventDefault();
|
||||
RouterUtils.transitionTo(channelPath);
|
||||
};
|
||||
} else {
|
||||
isInternal = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isInternal && !inviteCode) {
|
||||
const isTrusted = TrustedDomainStore.isTrustedDomain(parsed.hostname);
|
||||
if (!isTrusted) {
|
||||
handleClick = (e) => {
|
||||
e.preventDefault();
|
||||
ModalActionCreators.push(modal(() => <ExternalLinkWarningModal url={url} hostname={parsed.hostname} />));
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
console.warn('Invalid URL in link:', url);
|
||||
}
|
||||
}
|
||||
|
||||
const content = text ? renderChildren([text]) : url;
|
||||
|
||||
return (
|
||||
<FocusRing key={id}>
|
||||
<a
|
||||
href={url}
|
||||
target={isInternal ? undefined : '_blank'}
|
||||
rel={isInternal ? undefined : 'noopener noreferrer'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (handleClick) {
|
||||
handleClick(e);
|
||||
return;
|
||||
}
|
||||
if (!isInternal) {
|
||||
e.preventDefault();
|
||||
void openExternalUrl(url);
|
||||
}
|
||||
}}
|
||||
className={markupStyles.link}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
</FocusRing>
|
||||
);
|
||||
});
|
||||
284
fluxer_app/src/lib/markdown/renderers/mention-renderer.tsx
Normal file
284
fluxer_app/src/lib/markdown/renderers/mention-renderer.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import {ChannelTypes, Permissions} from '~/Constants';
|
||||
import {GenericErrorModal} from '~/components/alerts/GenericErrorModal';
|
||||
import {PreloadableUserPopout} from '~/components/channel/PreloadableUserPopout';
|
||||
import {ChannelContextMenu} from '~/components/uikit/ContextMenu/ChannelContextMenu';
|
||||
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
|
||||
import {Routes} from '~/Routes';
|
||||
import ChannelStore from '~/stores/ChannelStore';
|
||||
import GuildStore from '~/stores/GuildStore';
|
||||
import PermissionStore from '~/stores/PermissionStore';
|
||||
import SelectedGuildStore from '~/stores/SelectedGuildStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import markupStyles from '~/styles/Markup.module.css';
|
||||
import mentionRendererStyles from '~/styles/MentionRenderer.module.css';
|
||||
import * as ChannelUtils from '~/utils/ChannelUtils';
|
||||
import * as ColorUtils from '~/utils/ColorUtils';
|
||||
import * as NicknameUtils from '~/utils/NicknameUtils';
|
||||
import * as RouterUtils from '~/utils/RouterUtils';
|
||||
import {GuildNavKind, MentionKind} from '../parser/types/enums';
|
||||
import type {MentionNode} from '../parser/types/nodes';
|
||||
import type {RendererProps} from '.';
|
||||
|
||||
interface UserInfo {
|
||||
userId: string | null;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
function getUserInfo(userId: string, channelId?: string): UserInfo {
|
||||
if (!userId) {
|
||||
return {userId: null, name: null};
|
||||
}
|
||||
|
||||
const user = UserStore.getUser(userId);
|
||||
if (!user) {
|
||||
return {userId, name: userId};
|
||||
}
|
||||
|
||||
let name = user.displayName;
|
||||
|
||||
if (channelId) {
|
||||
const channel = ChannelStore.getChannel(channelId);
|
||||
if (channel?.guildId) {
|
||||
name = NicknameUtils.getNickname(user, channel.guildId) || name;
|
||||
}
|
||||
}
|
||||
|
||||
return {userId: user.id, name};
|
||||
}
|
||||
|
||||
export const MentionRenderer = observer(function MentionRenderer({
|
||||
node,
|
||||
id,
|
||||
options,
|
||||
}: RendererProps<MentionNode>): React.ReactElement {
|
||||
const {kind} = node;
|
||||
const {channelId} = options;
|
||||
const i18n = options.i18n!;
|
||||
|
||||
switch (kind.kind) {
|
||||
case MentionKind.User: {
|
||||
const {userId, name} = getUserInfo(kind.id, channelId);
|
||||
|
||||
const genericMention = (
|
||||
<span key={id} className={markupStyles.mention}>
|
||||
@{name || kind.id}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
return genericMention;
|
||||
}
|
||||
|
||||
const user = UserStore.getUser(userId);
|
||||
if (!user) {
|
||||
return genericMention;
|
||||
}
|
||||
|
||||
const channel = channelId ? ChannelStore.getChannel(channelId) : undefined;
|
||||
const guildId = channel?.guildId || '';
|
||||
|
||||
return (
|
||||
<PreloadableUserPopout key={id} user={user} isWebhook={false} guildId={guildId} position="right-start">
|
||||
<FocusRing>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={clsx(markupStyles.mention, markupStyles.interactive)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@{name || user.displayName}
|
||||
</span>
|
||||
</FocusRing>
|
||||
</PreloadableUserPopout>
|
||||
);
|
||||
}
|
||||
|
||||
case MentionKind.Role: {
|
||||
const selectedGuildId = SelectedGuildStore.selectedGuildId;
|
||||
const guild = selectedGuildId != null ? GuildStore.getGuild(selectedGuildId) : null;
|
||||
const role = guild?.roles[kind.id];
|
||||
|
||||
if (!role) {
|
||||
return (
|
||||
<span key={id} className={markupStyles.mention}>
|
||||
@{i18n._(msg`Unknown Role`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const roleColor = role.color ? ColorUtils.int2rgb(role.color) : undefined;
|
||||
const roleBgColor = role.color ? ColorUtils.int2rgba(role.color, 0.1) : undefined;
|
||||
const roleBgHoverColor = role.color ? ColorUtils.int2rgba(role.color, 0.2) : undefined;
|
||||
const style = {
|
||||
color: roleColor,
|
||||
backgroundColor: roleBgColor,
|
||||
transition: 'background-color var(--transition-fast)',
|
||||
'--hover-bg': roleBgHoverColor,
|
||||
} as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<span key={id} className={markupStyles.mention} style={style}>
|
||||
@{role.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
case MentionKind.Channel: {
|
||||
const unknownMention = (
|
||||
<span key={id} className={markupStyles.mention}>
|
||||
{ChannelUtils.getIcon({type: ChannelTypes.GUILD_TEXT}, {className: mentionRendererStyles.channelIcon})}
|
||||
{i18n._(msg`unknown-channel`)}
|
||||
</span>
|
||||
);
|
||||
|
||||
const channel = ChannelStore.getChannel(kind.id);
|
||||
if (!channel) {
|
||||
return unknownMention;
|
||||
}
|
||||
|
||||
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
|
||||
return <span key={id}>#{channel.name}</span>;
|
||||
}
|
||||
|
||||
if (
|
||||
channel.type !== ChannelTypes.GUILD_TEXT &&
|
||||
channel.type !== ChannelTypes.GUILD_VOICE &&
|
||||
channel.type !== ChannelTypes.GUILD_LINK
|
||||
) {
|
||||
return unknownMention;
|
||||
}
|
||||
|
||||
return (
|
||||
<FocusRing key={id}>
|
||||
<button
|
||||
className={clsx(markupStyles.mention, markupStyles.interactive)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (channel.type === ChannelTypes.GUILD_VOICE) {
|
||||
const canConnect = PermissionStore.can(Permissions.CONNECT, channel);
|
||||
if (!canConnect) {
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<GenericErrorModal
|
||||
title={i18n._(msg`Missing Permissions`)}
|
||||
message={i18n._(msg`You don't have permission to connect to this voice channel.`)}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
RouterUtils.transitionTo(Routes.guildChannel(channel.guildId!, channel.id));
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => (
|
||||
<ChannelContextMenu channel={channel} onClose={onClose} />
|
||||
));
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{ChannelUtils.getIcon(channel, {className: mentionRendererStyles.channelIcon})}
|
||||
{channel.name}
|
||||
</button>
|
||||
</FocusRing>
|
||||
);
|
||||
}
|
||||
|
||||
case MentionKind.Everyone: {
|
||||
return (
|
||||
<span key={id} className={clsx(markupStyles.mention, mentionRendererStyles.everyoneMention)}>
|
||||
@everyone
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
case MentionKind.Here: {
|
||||
return (
|
||||
<span key={id} className={clsx(markupStyles.mention, mentionRendererStyles.hereMention)}>
|
||||
@here
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
case MentionKind.Command: {
|
||||
const {name} = kind;
|
||||
return (
|
||||
<span key={id} className={markupStyles.mention}>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
case MentionKind.GuildNavigation: {
|
||||
const {navigationType} = kind;
|
||||
|
||||
let content: string;
|
||||
|
||||
switch (navigationType) {
|
||||
case GuildNavKind.Customize:
|
||||
content = '<id:customize>';
|
||||
break;
|
||||
|
||||
case GuildNavKind.Browse:
|
||||
content = '<id:browse>';
|
||||
break;
|
||||
|
||||
case GuildNavKind.Guide:
|
||||
content = '<id:guide>';
|
||||
break;
|
||||
|
||||
case GuildNavKind.LinkedRoles: {
|
||||
const linkedRolesId = (kind as {navigationType: typeof GuildNavKind.LinkedRoles; id?: string}).id;
|
||||
content = linkedRolesId ? `<id:linked-roles:${linkedRolesId}>` : '<id:linked-roles>';
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
content = `<id:${navigationType}>`;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={id} className={markupStyles.mention}>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return <span key={id}>{'<unknown-mention>'}</span>;
|
||||
}
|
||||
});
|
||||
37
fluxer_app/src/lib/markdown/renderers/text-renderer.tsx
Normal file
37
fluxer_app/src/lib/markdown/renderers/text-renderer.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import type {TextNode} from '../parser/types/nodes';
|
||||
import {MarkdownContext, type RendererProps} from '.';
|
||||
|
||||
export const TextRenderer = observer(function TextRenderer({
|
||||
node,
|
||||
id,
|
||||
options,
|
||||
}: RendererProps<TextNode>): React.ReactElement {
|
||||
let content = node.content;
|
||||
|
||||
if (options.context === MarkdownContext.RESTRICTED_INLINE_REPLY) {
|
||||
content = content.replace(/\n/g, ' ').replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
return <span key={id}>{content}</span>;
|
||||
});
|
||||
100
fluxer_app/src/lib/markdown/renderers/timestamp-renderer.tsx
Normal file
100
fluxer_app/src/lib/markdown/renderers/timestamp-renderer.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ClockIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {DateTime} from 'luxon';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type {ReactElement} from 'react';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import WindowStore from '~/stores/WindowStore';
|
||||
import markupStyles from '~/styles/Markup.module.css';
|
||||
import timestampRendererStyles from '~/styles/TimestampRenderer.module.css';
|
||||
import {TimestampStyle} from '../parser/types/enums';
|
||||
import type {TimestampNode} from '../parser/types/nodes';
|
||||
import {formatTimestamp} from '../utils/date-formatter';
|
||||
import type {RendererProps} from '.';
|
||||
|
||||
export const TimestampRenderer = observer(function TimestampRenderer({
|
||||
node,
|
||||
id,
|
||||
options,
|
||||
}: RendererProps<TimestampNode>): ReactElement {
|
||||
const {timestamp, style} = node;
|
||||
const i18n = options.i18n;
|
||||
|
||||
const totalMillis = timestamp * 1000;
|
||||
const date = DateTime.fromMillis(totalMillis);
|
||||
const now = DateTime.now();
|
||||
|
||||
const isPast = date < now;
|
||||
const isFuture = date > now;
|
||||
const isToday = date.hasSame(now, 'day');
|
||||
|
||||
const tooltipFormat = "EEEE, LLLL d, yyyy 'at' h:mm:ss a ZZZZ";
|
||||
const fullDateTime = date.toFormat(tooltipFormat);
|
||||
|
||||
const isRelativeStyle = style === TimestampStyle.RelativeTime;
|
||||
const isWindowFocused = WindowStore.focused;
|
||||
const [relativeDisplayTime, setRelativeDisplayTime] = useState(() => formatTimestamp(timestamp, style, i18n));
|
||||
const relativeTime = date.toRelative();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRelativeStyle || !isWindowFocused) {
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshDisplay = () => {
|
||||
setRelativeDisplayTime((previous) => {
|
||||
const nextValue = formatTimestamp(timestamp, style, i18n);
|
||||
return previous === nextValue ? previous : nextValue;
|
||||
});
|
||||
};
|
||||
|
||||
refreshDisplay();
|
||||
const intervalId = setInterval(refreshDisplay, 1000);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [isRelativeStyle, isWindowFocused, style, timestamp, i18n]);
|
||||
|
||||
const tooltipContent = (
|
||||
<div className={timestampRendererStyles.tooltipContainer}>
|
||||
<div className={timestampRendererStyles.tooltipFullDateTime}>{fullDateTime}</div>
|
||||
<div className={timestampRendererStyles.tooltipRelativeTime}>{relativeTime}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const displayTime = isRelativeStyle ? relativeDisplayTime : formatTimestamp(timestamp, style, i18n);
|
||||
|
||||
const timestampClasses = clsx(
|
||||
markupStyles.timestamp,
|
||||
isPast && !isToday && timestampRendererStyles.timestampPast,
|
||||
isFuture && timestampRendererStyles.timestampFuture,
|
||||
isToday && timestampRendererStyles.timestampToday,
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip key={id} text={() => tooltipContent} position="top" delay={200} maxWidth="xl">
|
||||
<time className={timestampClasses} dateTime={date.toISO() ?? ''}>
|
||||
<ClockIcon className={timestampRendererStyles.clockIcon} />
|
||||
{displayTime}
|
||||
</time>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
231
fluxer_app/src/lib/markdown/utils/date-formatter.ts
Normal file
231
fluxer_app/src/lib/markdown/utils/date-formatter.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {DateTime} from 'luxon';
|
||||
import {shouldUse12HourFormat} from '~/utils/DateUtils';
|
||||
import {getCurrentLocale} from '~/utils/LocaleUtils';
|
||||
import {TimestampStyle} from '../parser/types/enums';
|
||||
|
||||
function isToday(date: DateTime, now: DateTime): boolean {
|
||||
return date.hasSame(now, 'day');
|
||||
}
|
||||
|
||||
function isYesterday(date: DateTime, now: DateTime): boolean {
|
||||
const yesterday = now.minus({days: 1});
|
||||
return date.hasSame(yesterday, 'day');
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp: number, i18n: I18n): string {
|
||||
const locale = getCurrentLocale();
|
||||
const date = DateTime.fromSeconds(timestamp).setLocale(locale);
|
||||
const now = DateTime.now().setLocale(locale);
|
||||
|
||||
if (isToday(date, now)) {
|
||||
const timeString = date.toLocaleString({
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: shouldUse12HourFormat(locale),
|
||||
});
|
||||
return i18n._(msg`Today at ${timeString}`);
|
||||
}
|
||||
|
||||
if (isYesterday(date, now)) {
|
||||
const timeString = date.toLocaleString({
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: shouldUse12HourFormat(locale),
|
||||
});
|
||||
return i18n._(msg`Yesterday at ${timeString}`);
|
||||
}
|
||||
|
||||
const diff = date.diff(now).shiftTo('years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds');
|
||||
const {years, months, weeks, days, hours, minutes, seconds} = diff.toObject();
|
||||
|
||||
if (years && Math.abs(years) > 0) {
|
||||
const absYears = Math.abs(Math.round(years));
|
||||
return date > now
|
||||
? absYears === 1
|
||||
? i18n._(msg`next year`)
|
||||
: i18n._(msg`in ${absYears} years`)
|
||||
: absYears === 1
|
||||
? i18n._(msg`last year`)
|
||||
: i18n._(msg`${absYears} years ago`);
|
||||
}
|
||||
|
||||
if (months && Math.abs(months) > 0) {
|
||||
const absMonths = Math.abs(Math.round(months));
|
||||
return date > now
|
||||
? absMonths === 1
|
||||
? i18n._(msg`next month`)
|
||||
: i18n._(msg`in ${absMonths} months`)
|
||||
: absMonths === 1
|
||||
? i18n._(msg`last month`)
|
||||
: i18n._(msg`${absMonths} months ago`);
|
||||
}
|
||||
|
||||
if (weeks && Math.abs(weeks) > 0) {
|
||||
const absWeeks = Math.abs(Math.round(weeks));
|
||||
return date > now
|
||||
? absWeeks === 1
|
||||
? i18n._(msg`next week`)
|
||||
: i18n._(msg`in ${absWeeks} weeks`)
|
||||
: absWeeks === 1
|
||||
? i18n._(msg`last week`)
|
||||
: i18n._(msg`${absWeeks} weeks ago`);
|
||||
}
|
||||
|
||||
if (days && Math.abs(days) > 0) {
|
||||
const absDays = Math.abs(Math.round(days));
|
||||
return date > now
|
||||
? absDays === 1
|
||||
? i18n._(msg`tomorrow`)
|
||||
: absDays === 2
|
||||
? i18n._(msg`in two days`)
|
||||
: i18n._(msg`in ${absDays} days`)
|
||||
: absDays === 1
|
||||
? i18n._(msg`yesterday`)
|
||||
: absDays === 2
|
||||
? i18n._(msg`two days ago`)
|
||||
: i18n._(msg`${absDays} days ago`);
|
||||
}
|
||||
|
||||
if (hours && Math.abs(hours) > 0) {
|
||||
const absHours = Math.abs(Math.round(hours));
|
||||
return date > now
|
||||
? absHours === 1
|
||||
? i18n._(msg`in one hour`)
|
||||
: i18n._(msg`in ${absHours} hours`)
|
||||
: absHours === 1
|
||||
? i18n._(msg`one hour ago`)
|
||||
: i18n._(msg`${absHours} hours ago`);
|
||||
}
|
||||
|
||||
if (minutes && Math.abs(minutes) > 0) {
|
||||
const absMinutes = Math.abs(Math.round(minutes));
|
||||
return date > now
|
||||
? absMinutes === 1
|
||||
? i18n._(msg`in one minute`)
|
||||
: i18n._(msg`in ${absMinutes} minutes`)
|
||||
: absMinutes === 1
|
||||
? i18n._(msg`one minute ago`)
|
||||
: i18n._(msg`${absMinutes} minutes ago`);
|
||||
}
|
||||
|
||||
const absSeconds = Math.abs(Math.round(seconds ?? 1));
|
||||
return date > now
|
||||
? absSeconds === 0
|
||||
? i18n._(msg`now`)
|
||||
: absSeconds === 1
|
||||
? i18n._(msg`in one second`)
|
||||
: i18n._(msg`in ${absSeconds} seconds`)
|
||||
: absSeconds === 0
|
||||
? i18n._(msg`just now`)
|
||||
: absSeconds === 1
|
||||
? i18n._(msg`one second ago`)
|
||||
: i18n._(msg`${absSeconds} seconds ago`);
|
||||
}
|
||||
|
||||
export function formatTimestamp(timestamp: number, style: TimestampStyle, i18n: I18n): string {
|
||||
const locale = getCurrentLocale();
|
||||
const date = DateTime.fromMillis(timestamp * 1000).setLocale(locale);
|
||||
|
||||
switch (style) {
|
||||
case TimestampStyle.ShortTime:
|
||||
return date.toLocaleString({
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: shouldUse12HourFormat(locale),
|
||||
});
|
||||
|
||||
case TimestampStyle.LongTime:
|
||||
return date.toLocaleString({
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
hour12: shouldUse12HourFormat(locale),
|
||||
});
|
||||
|
||||
case TimestampStyle.ShortDate:
|
||||
return date.toLocaleString(DateTime.DATE_SHORT);
|
||||
|
||||
case TimestampStyle.LongDate:
|
||||
return date.toLocaleString({
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
case TimestampStyle.ShortDateTime:
|
||||
return date.toLocaleString({
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: shouldUse12HourFormat(locale),
|
||||
});
|
||||
|
||||
case TimestampStyle.LongDateTime:
|
||||
return date.toLocaleString({
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: shouldUse12HourFormat(locale),
|
||||
});
|
||||
|
||||
case TimestampStyle.ShortDateShortTime:
|
||||
return date.toLocaleString({
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: shouldUse12HourFormat(locale),
|
||||
});
|
||||
|
||||
case TimestampStyle.ShortDateMediumTime:
|
||||
return date.toLocaleString({
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
hour12: shouldUse12HourFormat(locale),
|
||||
});
|
||||
|
||||
case TimestampStyle.RelativeTime:
|
||||
return formatRelativeTime(timestamp, i18n);
|
||||
|
||||
default:
|
||||
return date.toLocaleString({
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: shouldUse12HourFormat(locale),
|
||||
});
|
||||
}
|
||||
}
|
||||
63
fluxer_app/src/lib/markdown/utils/emoji-detector.ts
Normal file
63
fluxer_app/src/lib/markdown/utils/emoji-detector.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import ChannelStore from '~/stores/ChannelStore';
|
||||
import EmojiStore from '~/stores/EmojiStore';
|
||||
import * as AvatarUtils from '~/utils/AvatarUtils';
|
||||
import * as EmojiUtils from '~/utils/EmojiUtils';
|
||||
import {EmojiKind} from '../parser/types/enums';
|
||||
import type {EmojiNode} from '../parser/types/nodes';
|
||||
|
||||
interface EmojiRenderData {
|
||||
url: string | null;
|
||||
name: string;
|
||||
isAnimated: boolean;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function getEmojiRenderData(
|
||||
emojiNode: EmojiNode,
|
||||
guildId?: string,
|
||||
disableAnimatedEmoji = false,
|
||||
): EmojiRenderData {
|
||||
const {kind} = emojiNode;
|
||||
const emojiName = `:${kind.name}:`;
|
||||
|
||||
if (kind.kind === EmojiKind.Standard) {
|
||||
return {
|
||||
url: EmojiUtils.getTwemojiURL(kind.codepoints),
|
||||
name: emojiName,
|
||||
isAnimated: false,
|
||||
};
|
||||
}
|
||||
|
||||
const {id, animated} = kind;
|
||||
const shouldAnimate = !disableAnimatedEmoji && animated;
|
||||
|
||||
const channel = guildId ? ChannelStore.getChannel(guildId) : undefined;
|
||||
const disambiguatedEmoji = EmojiStore.getDisambiguatedEmojiContext(channel?.guildId).getById(id);
|
||||
const finalEmojiName = `:${disambiguatedEmoji?.name || kind.name}:`;
|
||||
|
||||
return {
|
||||
url: AvatarUtils.getEmojiURL({id, animated: shouldAnimate}),
|
||||
name: finalEmojiName,
|
||||
isAnimated: shouldAnimate,
|
||||
id,
|
||||
};
|
||||
}
|
||||
48
fluxer_app/src/lib/markdown/utils/jumbo-detector.ts
Normal file
48
fluxer_app/src/lib/markdown/utils/jumbo-detector.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import UnicodeEmojis from '~/lib/UnicodeEmojis';
|
||||
import UserSettingsStore from '~/stores/UserSettingsStore';
|
||||
import {NodeType} from '../parser/types/enums';
|
||||
import type {Node, TextNode} from '../parser/types/nodes';
|
||||
|
||||
export function shouldRenderJumboEmojis(nodes: Array<Node>): boolean {
|
||||
if (UserSettingsStore.getMessageDisplayCompact()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const emojiCount = nodes.filter((node) => {
|
||||
return (
|
||||
node.type === NodeType.Emoji ||
|
||||
(node.type === NodeType.Text && UnicodeEmojis.EMOJI_NAME_RE.test((node as TextNode).content))
|
||||
);
|
||||
}).length;
|
||||
|
||||
return (
|
||||
emojiCount > 0 &&
|
||||
emojiCount <= 6 &&
|
||||
nodes.every((node) => {
|
||||
return (
|
||||
node.type === NodeType.Emoji ||
|
||||
(node.type === NodeType.Text &&
|
||||
((node as TextNode).content.trim() === '' || UnicodeEmojis.EMOJI_NAME_RE.test((node as TextNode).content)))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
133
fluxer_app/src/lib/router/builder.ts
Normal file
133
fluxer_app/src/lib/router/builder.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {NotFound, Redirect, RouteConfig, RouteContext} from './types';
|
||||
|
||||
type RouteGuard = (ctx: RouteContext) => undefined | Redirect | NotFound;
|
||||
|
||||
interface RouteBuilderConfig {
|
||||
id?: string;
|
||||
path?: string;
|
||||
component?: RouteConfig['component'];
|
||||
layout?: RouteConfig['layout'];
|
||||
onEnter?: RouteGuard;
|
||||
onLeave?: RouteConfig['onLeave'];
|
||||
preload?: RouteConfig['preload'];
|
||||
staticData?: unknown;
|
||||
}
|
||||
|
||||
let routeIdCounter = 0;
|
||||
function generateRouteId(): string {
|
||||
return `__route_${routeIdCounter++}`;
|
||||
}
|
||||
|
||||
export class RouteBuilder {
|
||||
private config: RouteBuilderConfig;
|
||||
private children: Array<RouteBuilder> = [];
|
||||
private parent: RouteBuilder | null = null;
|
||||
readonly id: string;
|
||||
|
||||
constructor(config: RouteBuilderConfig) {
|
||||
this.id = config.id ?? generateRouteId();
|
||||
this.config = {...config, id: this.id};
|
||||
}
|
||||
|
||||
addChildren(children: Array<RouteBuilder>): this {
|
||||
for (const child of children) {
|
||||
child.parent = this;
|
||||
this.children.push(child);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
getParent(): RouteBuilder | null {
|
||||
return this.parent;
|
||||
}
|
||||
|
||||
private collectRoutes(parentId?: string): Array<RouteConfig> {
|
||||
const routes: Array<RouteConfig> = [];
|
||||
|
||||
const thisRoute: RouteConfig = {
|
||||
id: this.id,
|
||||
path: this.config.path,
|
||||
parentId,
|
||||
component: this.config.component,
|
||||
layout: this.config.layout,
|
||||
onEnter: this.config.onEnter,
|
||||
onLeave: this.config.onLeave,
|
||||
preload: this.config.preload,
|
||||
staticData: this.config.staticData,
|
||||
};
|
||||
|
||||
routes.push(thisRoute);
|
||||
|
||||
for (const child of this.children) {
|
||||
routes.push(...child.collectRoutes(this.id));
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
build(): Array<RouteConfig> {
|
||||
return this.collectRoutes();
|
||||
}
|
||||
}
|
||||
|
||||
interface RootRouteConfig {
|
||||
component?: RouteConfig['component'];
|
||||
layout?: RouteConfig['layout'];
|
||||
onEnter?: RouteGuard;
|
||||
onLeave?: RouteConfig['onLeave'];
|
||||
staticData?: unknown;
|
||||
}
|
||||
|
||||
export function createRootRoute(config: RootRouteConfig = {}): RouteBuilder {
|
||||
return new RouteBuilder({
|
||||
id: '__root',
|
||||
path: '/',
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
interface CreateRouteConfig<TParent extends RouteBuilder = RouteBuilder> {
|
||||
getParentRoute?: () => TParent;
|
||||
id?: string;
|
||||
path?: string;
|
||||
component?: RouteConfig['component'];
|
||||
layout?: RouteConfig['layout'];
|
||||
onEnter?: RouteGuard;
|
||||
onLeave?: RouteConfig['onLeave'];
|
||||
preload?: RouteConfig['preload'];
|
||||
staticData?: unknown;
|
||||
}
|
||||
|
||||
export function createRoute<TParent extends RouteBuilder>(config: CreateRouteConfig<TParent>): RouteBuilder {
|
||||
const builder = new RouteBuilder({
|
||||
id: config.id,
|
||||
path: config.path,
|
||||
component: config.component,
|
||||
layout: config.layout,
|
||||
onEnter: config.onEnter,
|
||||
onLeave: config.onLeave,
|
||||
preload: config.preload,
|
||||
staticData: config.staticData,
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
514
fluxer_app/src/lib/router/core.ts
Normal file
514
fluxer_app/src/lib/router/core.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createBrowserHistory} from './history';
|
||||
import {
|
||||
type Match,
|
||||
type NavigateOptions,
|
||||
NotFound,
|
||||
Redirect,
|
||||
type Route,
|
||||
type RouteContext,
|
||||
type Router,
|
||||
type RouterOptions,
|
||||
type RouterState,
|
||||
type ScrollBehavior,
|
||||
type To,
|
||||
} from './types';
|
||||
|
||||
export class RouterImpl implements Router {
|
||||
private readonly routes: Array<Route>;
|
||||
private readonly routeById: Map<string, Route>;
|
||||
private readonly history;
|
||||
private readonly options: RouterOptions;
|
||||
private state: RouterState;
|
||||
private listeners = new Set<() => void>();
|
||||
private unsubscribeHistory?: () => void;
|
||||
private preloadCache = new Map<string, Promise<unknown> | 'done'>();
|
||||
|
||||
constructor(options: RouterOptions) {
|
||||
this.options = options;
|
||||
|
||||
this.history = options.history ?? createBrowserHistory();
|
||||
|
||||
this.routes = options.routes.map((cfg) => ({
|
||||
...cfg,
|
||||
pattern:
|
||||
cfg.pattern ?? (cfg.path != null ? new URLPattern({pathname: cfg.path}) : new URLPattern({pathname: '*'})),
|
||||
}));
|
||||
|
||||
this.routeById = new Map(this.routes.map((r) => [r.id, r]));
|
||||
|
||||
const loc = this.history.getLocation();
|
||||
const normalizedInitialUrl = this.normalizeUrl(loc.url);
|
||||
if (normalizedInitialUrl.toString() !== loc.url.toString()) {
|
||||
this.history.replace(normalizedInitialUrl, loc.state);
|
||||
}
|
||||
const matches = this.matchUrl(normalizedInitialUrl);
|
||||
|
||||
this.state = {
|
||||
location: normalizedInitialUrl,
|
||||
matches,
|
||||
navigating: false,
|
||||
pending: null,
|
||||
error: undefined,
|
||||
historyState: loc.state,
|
||||
};
|
||||
|
||||
this.unsubscribeHistory = this.history.listen((location) => {
|
||||
void this.handlePop(location.url, location.state);
|
||||
});
|
||||
|
||||
this.runInitialGuards();
|
||||
}
|
||||
|
||||
getState(): RouterState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
getRoutes(): Array<Route> {
|
||||
return this.routes;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.unsubscribeHistory) {
|
||||
this.unsubscribeHistory();
|
||||
this.unsubscribeHistory = undefined;
|
||||
}
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
private notify() {
|
||||
for (const l of this.listeners) l();
|
||||
}
|
||||
|
||||
private runInitialGuards(): void {
|
||||
const {location, historyState} = this.state;
|
||||
|
||||
void this.transitionTo(location, {
|
||||
replace: true,
|
||||
scroll: 'preserve',
|
||||
historyState,
|
||||
isPop: true,
|
||||
forceEnter: true,
|
||||
}).catch((err) => {
|
||||
if (typeof console !== 'undefined') {
|
||||
console.error('[router] initial guard check failed', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeUrl(url: URL): URL {
|
||||
if (url.pathname.length <= 1 || !url.pathname.endsWith('/')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const normalized = new URL(url.toString());
|
||||
const trimmedPath = url.pathname.replace(/\/+$/, '') || '/';
|
||||
normalized.pathname = trimmedPath;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
resolveTo(to: To, from?: URL): URL {
|
||||
const base = from ?? this.state.location ?? new URL(window.location.href);
|
||||
|
||||
if (typeof to === 'string') {
|
||||
return this.normalizeUrl(new URL(to, base));
|
||||
}
|
||||
|
||||
const url = new URL(to.to, base);
|
||||
|
||||
if (to.search) {
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(to.search)) {
|
||||
if (v === undefined) continue;
|
||||
if (v === null) {
|
||||
sp.set(k, '');
|
||||
continue;
|
||||
}
|
||||
sp.set(k, String(v));
|
||||
}
|
||||
const s = sp.toString();
|
||||
url.search = s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
if (to.hash) {
|
||||
url.hash = to.hash.startsWith('#') ? to.hash : `#${to.hash}`;
|
||||
}
|
||||
|
||||
return this.normalizeUrl(url);
|
||||
}
|
||||
|
||||
async navigate(to: To, opts: NavigateOptions = {}): Promise<void> {
|
||||
const from = this.state.location;
|
||||
const targetUrl = this.resolveTo(to, from);
|
||||
|
||||
let historyState =
|
||||
opts.state ?? (typeof to === 'object' && 'state' in to ? to.state : undefined) ?? this.state.historyState;
|
||||
|
||||
if (historyState === undefined) historyState = null;
|
||||
|
||||
const pendingMatches = this.matchUrl(targetUrl);
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
navigating: true,
|
||||
pending: pendingMatches,
|
||||
error: undefined,
|
||||
historyState,
|
||||
};
|
||||
this.notify();
|
||||
|
||||
await this.transitionTo(targetUrl, {
|
||||
replace: opts.replace,
|
||||
scroll: opts.scroll ?? this.options.scrollRestoration ?? 'top',
|
||||
historyState,
|
||||
isPop: false,
|
||||
});
|
||||
}
|
||||
|
||||
async preload(to: To): Promise<void> {
|
||||
const url = this.resolveTo(to, this.state.location);
|
||||
const matches = this.matchUrl(url);
|
||||
if (!matches.length) return;
|
||||
|
||||
const leaf = matches[matches.length - 1];
|
||||
if (!leaf.route.preload) return;
|
||||
|
||||
const key = this.preloadKey(leaf.route, url);
|
||||
const existing = this.preloadCache.get(key);
|
||||
if (existing) {
|
||||
if (existing === 'done') return;
|
||||
await existing;
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx: RouteContext = {
|
||||
url,
|
||||
params: leaf.params,
|
||||
search: leaf.search,
|
||||
state: this.state.historyState,
|
||||
route: leaf.route,
|
||||
matches,
|
||||
router: this,
|
||||
};
|
||||
|
||||
const promise = Promise.resolve(leaf.route.preload(ctx)).then(
|
||||
() => {
|
||||
this.preloadCache.set(key, 'done');
|
||||
},
|
||||
(err) => {
|
||||
this.preloadCache.delete(key);
|
||||
if (typeof console !== 'undefined') {
|
||||
console.error('[router] preload failed', err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.preloadCache.set(key, promise);
|
||||
await promise;
|
||||
}
|
||||
|
||||
private preloadKey(route: Route, url: URL): string {
|
||||
return `${route.id}|${url.pathname}|${url.search}`;
|
||||
}
|
||||
|
||||
private async handlePop(url: URL, state: unknown): Promise<void> {
|
||||
const normalizedUrl = this.normalizeUrl(url);
|
||||
if (normalizedUrl.toString() !== url.toString()) {
|
||||
this.history.replace(normalizedUrl, state);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.transitionTo(normalizedUrl, {
|
||||
isPop: true,
|
||||
historyState: state,
|
||||
scroll: 'preserve',
|
||||
});
|
||||
}
|
||||
|
||||
private matchUrl(url: URL): Array<Match> {
|
||||
const routesWithPaths = this.routes
|
||||
.filter((r) => r.path != null)
|
||||
.sort((a, b) => {
|
||||
const aSegments = (a.path ?? '').split('/').filter(Boolean);
|
||||
const bSegments = (b.path ?? '').split('/').filter(Boolean);
|
||||
if (bSegments.length !== aSegments.length) {
|
||||
return bSegments.length - aSegments.length;
|
||||
}
|
||||
const aWildcards = aSegments.filter((s) => s.startsWith(':') || s === '*').length;
|
||||
const bWildcards = bSegments.filter((s) => s.startsWith(':') || s === '*').length;
|
||||
if (aWildcards !== bWildcards) {
|
||||
return aWildcards - bWildcards;
|
||||
}
|
||||
if (a.parentId && !b.parentId) return -1;
|
||||
if (!a.parentId && b.parentId) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
for (const route of routesWithPaths) {
|
||||
const exec = route.pattern.exec(url);
|
||||
if (!exec) continue;
|
||||
|
||||
const search = url.searchParams;
|
||||
|
||||
const chain: Array<Match> = [];
|
||||
let current: Route | undefined = route;
|
||||
|
||||
while (current) {
|
||||
const currentExec =
|
||||
current === route ? exec : (current.pattern.exec(url) ?? ({pathname: {groups: {}}} as URLPatternResult));
|
||||
|
||||
const params = (currentExec.pathname.groups ?? {}) as Record<string, string>;
|
||||
|
||||
chain.push({
|
||||
route: current,
|
||||
params,
|
||||
search,
|
||||
});
|
||||
|
||||
if (!current.parentId) break;
|
||||
current = this.routeById.get(current.parentId);
|
||||
}
|
||||
|
||||
chain.reverse();
|
||||
return chain;
|
||||
}
|
||||
|
||||
const notFoundRouteId = this.options.notFoundRouteId;
|
||||
if (notFoundRouteId) {
|
||||
const nf = this.routeById.get(notFoundRouteId);
|
||||
if (nf) {
|
||||
return [
|
||||
{
|
||||
route: nf,
|
||||
params: {},
|
||||
search: url.searchParams,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private async transitionTo(
|
||||
url: URL,
|
||||
opts: {
|
||||
replace?: boolean;
|
||||
scroll?: ScrollBehavior;
|
||||
historyState?: unknown;
|
||||
isPop?: boolean;
|
||||
forceEnter?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
const prevState = this.state;
|
||||
const prevMatches = opts.forceEnter ? [] : prevState.matches;
|
||||
const nextMatches = this.matchUrl(url);
|
||||
const historyState = opts.historyState ?? prevState.historyState ?? null;
|
||||
|
||||
const mkCtx = (match: Match): RouteContext => ({
|
||||
url,
|
||||
params: match.params,
|
||||
search: match.search,
|
||||
state: historyState,
|
||||
route: match.route,
|
||||
matches: nextMatches,
|
||||
router: this,
|
||||
});
|
||||
|
||||
const firstDifferentIndex =
|
||||
opts.forceEnter === true
|
||||
? 0
|
||||
: (() => {
|
||||
let i = 0;
|
||||
while (
|
||||
i < prevMatches.length &&
|
||||
i < nextMatches.length &&
|
||||
prevMatches[i].route.id === nextMatches[i].route.id
|
||||
) {
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
})();
|
||||
|
||||
try {
|
||||
for (let i = firstDifferentIndex; i < nextMatches.length; i++) {
|
||||
const match = nextMatches[i];
|
||||
const route = match.route;
|
||||
if (route.onEnter) {
|
||||
const res = route.onEnter(mkCtx(match));
|
||||
if (res instanceof Redirect) {
|
||||
const redirectUrl = this.resolveTo(res.to, url);
|
||||
await this.transitionTo(redirectUrl, {
|
||||
replace: res.replace ?? true,
|
||||
scroll: opts.scroll,
|
||||
historyState,
|
||||
isPop: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (res instanceof NotFound) {
|
||||
await this.handleNotFound(url, historyState);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Redirect) {
|
||||
const redirectUrl = this.resolveTo(err.to, url);
|
||||
await this.transitionTo(redirectUrl, {
|
||||
replace: err.replace ?? true,
|
||||
scroll: opts.scroll,
|
||||
historyState,
|
||||
isPop: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (err instanceof NotFound) {
|
||||
await this.handleNotFound(url, historyState);
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
...prevState,
|
||||
navigating: false,
|
||||
pending: null,
|
||||
error: err,
|
||||
historyState,
|
||||
};
|
||||
this.notify();
|
||||
throw err;
|
||||
}
|
||||
|
||||
for (let i = prevMatches.length - 1; i >= firstDifferentIndex; i--) {
|
||||
const prevMatch = prevMatches[i];
|
||||
const route = prevMatch.route;
|
||||
if (route.onLeave) {
|
||||
route.onLeave({
|
||||
url: prevState.location,
|
||||
params: prevMatch.params,
|
||||
search: prevState.location.searchParams,
|
||||
state: prevState.historyState,
|
||||
route,
|
||||
matches: prevMatches,
|
||||
router: this,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isSamePath =
|
||||
prevState.location.pathname === url.pathname &&
|
||||
prevState.location.search === url.search &&
|
||||
prevState.location.hash === url.hash;
|
||||
|
||||
if (!opts.isPop) {
|
||||
if (opts.replace || isSamePath) {
|
||||
this.history.replace(url, historyState);
|
||||
} else {
|
||||
this.history.push(url, historyState);
|
||||
}
|
||||
}
|
||||
|
||||
this.state = {
|
||||
location: url,
|
||||
matches: nextMatches,
|
||||
navigating: false,
|
||||
pending: null,
|
||||
error: undefined,
|
||||
historyState,
|
||||
};
|
||||
this.notify();
|
||||
|
||||
const behavior = opts.scroll ?? this.options.scrollRestoration ?? 'top';
|
||||
queueMicrotask(() => {
|
||||
if (behavior === 'preserve') return;
|
||||
if (url.hash) {
|
||||
const id = url.hash.slice(1);
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.scrollIntoView();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (behavior === 'top') {
|
||||
window.scrollTo({top: 0, left: 0});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async handleNotFound(url: URL, historyState: unknown): Promise<void> {
|
||||
const notFoundRouteId = this.options.notFoundRouteId;
|
||||
if (!notFoundRouteId) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
location: url,
|
||||
matches: [],
|
||||
navigating: false,
|
||||
pending: null,
|
||||
error: new NotFound(),
|
||||
historyState,
|
||||
};
|
||||
this.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
const nf = this.routeById.get(notFoundRouteId);
|
||||
if (!nf) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
location: url,
|
||||
matches: [],
|
||||
navigating: false,
|
||||
pending: null,
|
||||
error: new NotFound(),
|
||||
historyState,
|
||||
};
|
||||
this.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
const match: Match = {
|
||||
route: nf,
|
||||
params: {},
|
||||
search: url.searchParams,
|
||||
};
|
||||
|
||||
this.state = {
|
||||
location: url,
|
||||
matches: [match],
|
||||
navigating: false,
|
||||
pending: null,
|
||||
error: undefined,
|
||||
historyState,
|
||||
};
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
export function createRouter(options: RouterOptions): Router {
|
||||
return new RouterImpl(options);
|
||||
}
|
||||
28
fluxer_app/src/lib/router/errors.ts
Normal file
28
fluxer_app/src/lib/router/errors.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {NotFound, Redirect, type To} from './types';
|
||||
|
||||
export function redirect(to: To, options?: {replace?: boolean}): Redirect {
|
||||
return new Redirect(to, options);
|
||||
}
|
||||
|
||||
export function notFound(message?: string): NotFound {
|
||||
return new NotFound(message);
|
||||
}
|
||||
124
fluxer_app/src/lib/router/history.ts
Normal file
124
fluxer_app/src/lib/router/history.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {HistoryAdapter, HistoryLocation} from './types';
|
||||
|
||||
export function createBrowserHistory(): HistoryAdapter {
|
||||
const listeners = new Set<(location: HistoryLocation, action: 'pop') => void>();
|
||||
|
||||
const getLocation = (): HistoryLocation => ({
|
||||
url: new URL(window.location.href),
|
||||
state: window.history.state ?? null,
|
||||
});
|
||||
|
||||
const notify = () => {
|
||||
const loc = getLocation();
|
||||
for (const l of listeners) l(loc, 'pop');
|
||||
};
|
||||
|
||||
const push = (url: URL, state?: unknown) => {
|
||||
window.history.pushState(state ?? null, '', url);
|
||||
notify();
|
||||
};
|
||||
|
||||
const replace = (url: URL, state?: unknown) => {
|
||||
window.history.replaceState(state ?? null, '', url);
|
||||
notify();
|
||||
};
|
||||
|
||||
const listen = (listener: (location: HistoryLocation, action: 'pop') => void) => {
|
||||
listeners.add(listener);
|
||||
const handler = () => listener(getLocation(), 'pop');
|
||||
window.addEventListener('popstate', handler);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
window.removeEventListener('popstate', handler);
|
||||
};
|
||||
};
|
||||
|
||||
const go = (delta: number) => {
|
||||
window.history.go(delta);
|
||||
};
|
||||
|
||||
const back = () => {
|
||||
window.history.back();
|
||||
};
|
||||
|
||||
return {
|
||||
getLocation,
|
||||
push,
|
||||
replace,
|
||||
listen,
|
||||
go,
|
||||
back,
|
||||
get location() {
|
||||
return getLocation().url;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMemoryHistory(initialHref = 'http://localhost/'): HistoryAdapter {
|
||||
const stack: Array<HistoryLocation> = [{url: new URL(initialHref), state: null}];
|
||||
let index = 0;
|
||||
const listeners = new Set<(location: HistoryLocation, action: 'pop') => void>();
|
||||
|
||||
const notify = () => {
|
||||
for (const l of listeners) l(stack[index], 'pop');
|
||||
};
|
||||
|
||||
const getLocation = (): HistoryLocation => stack[index];
|
||||
|
||||
const push = (url: URL, state?: unknown) => {
|
||||
index++;
|
||||
stack.splice(index, stack.length - index, {url, state: state ?? null});
|
||||
notify();
|
||||
};
|
||||
|
||||
const replace = (url: URL, state?: unknown) => {
|
||||
stack[index] = {url, state: state ?? null};
|
||||
notify();
|
||||
};
|
||||
|
||||
const listen = (listener: (location: HistoryLocation, action: 'pop') => void) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
};
|
||||
|
||||
const go = (delta: number) => {
|
||||
const newIndex = Math.max(0, Math.min(stack.length - 1, index + delta));
|
||||
if (newIndex !== index) {
|
||||
index = newIndex;
|
||||
notify();
|
||||
}
|
||||
};
|
||||
|
||||
const back = () => go(-1);
|
||||
|
||||
return {
|
||||
getLocation,
|
||||
push,
|
||||
replace,
|
||||
listen,
|
||||
go,
|
||||
back,
|
||||
get location() {
|
||||
return getLocation().url;
|
||||
},
|
||||
};
|
||||
}
|
||||
25
fluxer_app/src/lib/router/index.tsx
Normal file
25
fluxer_app/src/lib/router/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export * from './builder';
|
||||
export * from './core';
|
||||
export * from './errors';
|
||||
export * from './history';
|
||||
export * from './react';
|
||||
export * from './types';
|
||||
216
fluxer_app/src/lib/router/react.tsx
Normal file
216
fluxer_app/src/lib/router/react.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import type {
|
||||
Match,
|
||||
NavigateOptions,
|
||||
RouteComponentProps,
|
||||
RouteParams,
|
||||
Router,
|
||||
RouterProviderProps,
|
||||
RouterState,
|
||||
ScrollBehavior,
|
||||
SearchParamsInput,
|
||||
To,
|
||||
} from './types';
|
||||
|
||||
const RouterContext = React.createContext<Router | null>(null);
|
||||
|
||||
export const RouterProvider: React.FC<RouterProviderProps> = ({router, children, linkContainerRef}) => {
|
||||
React.useEffect(() => {
|
||||
const container = linkContainerRef?.current ?? document.body;
|
||||
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (event.defaultPrevented) return;
|
||||
if (event.button !== 0) return;
|
||||
if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
|
||||
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (!target || !('closest' in target)) return;
|
||||
|
||||
const anchor = target.closest('a[href]') as HTMLAnchorElement | null;
|
||||
if (!anchor) return;
|
||||
if (anchor.hasAttribute('download')) return;
|
||||
if (anchor.getAttribute('rel') === 'external') return;
|
||||
if (anchor.target && anchor.target !== '_self') return;
|
||||
if (anchor.dataset.routerIgnore === 'true') return;
|
||||
|
||||
const href = anchor.getAttribute('href');
|
||||
if (!href) return;
|
||||
if (href.startsWith('mailto:') || href.startsWith('tel:')) return;
|
||||
|
||||
const url = new URL(anchor.href, window.location.href);
|
||||
if (url.origin !== window.location.origin) return;
|
||||
|
||||
event.preventDefault();
|
||||
void router.navigate(url.toString(), {scroll: 'top'});
|
||||
};
|
||||
|
||||
container.addEventListener('click', onClick);
|
||||
return () => container.removeEventListener('click', onClick);
|
||||
}, [router, linkContainerRef]);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
router.destroy();
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return <RouterContext.Provider value={router}>{children ?? <Outlet />}</RouterContext.Provider>;
|
||||
};
|
||||
|
||||
export function useRouter(): Router {
|
||||
const ctx = React.useContext(RouterContext);
|
||||
if (!ctx) throw new Error('useRouter must be used within a RouterProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useRouterState<T>(selector: (state: RouterState) => T): T {
|
||||
const router = useRouter();
|
||||
return React.useSyncExternalStore(
|
||||
React.useCallback((onStoreChange) => router.subscribe(onStoreChange), [router]),
|
||||
React.useCallback(() => selector(router.getState()), [router, selector]),
|
||||
React.useCallback(() => selector(router.getState()), [router, selector]),
|
||||
);
|
||||
}
|
||||
|
||||
export function useLocation(): URL {
|
||||
return useRouterState((s) => s.location);
|
||||
}
|
||||
|
||||
export function useMatches(): Array<Match> {
|
||||
return useRouterState((s) => s.matches);
|
||||
}
|
||||
|
||||
export function useMatch(): Match | undefined {
|
||||
const matches = useMatches();
|
||||
return matches[matches.length - 1];
|
||||
}
|
||||
|
||||
export function useParams(): RouteParams {
|
||||
const match = useMatch();
|
||||
return match?.params ?? {};
|
||||
}
|
||||
|
||||
export function useSearch(): URLSearchParams {
|
||||
const match = useMatch();
|
||||
return match?.search ?? new URLSearchParams();
|
||||
}
|
||||
|
||||
export function useNavigate(): (to: To, opts?: NavigateOptions) => Promise<void> {
|
||||
const router = useRouter();
|
||||
return React.useCallback((to: To, opts?: NavigateOptions) => router.navigate(to, opts), [router]);
|
||||
}
|
||||
|
||||
export interface LinkProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
|
||||
to: string;
|
||||
search?: SearchParamsInput;
|
||||
hash?: string;
|
||||
state?: unknown;
|
||||
replace?: boolean;
|
||||
preload?: 'intent' | 'render' | 'none';
|
||||
scroll?: ScrollBehavior;
|
||||
}
|
||||
|
||||
export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(function Link(
|
||||
{to, search, hash, state, replace, preload = 'intent', scroll, onClick, onPointerEnter, onFocus, ...rest},
|
||||
ref,
|
||||
) {
|
||||
const router = useRouter();
|
||||
const location = useLocation();
|
||||
|
||||
const hrefUrl = React.useMemo(
|
||||
() => router.resolveTo({to, search, hash}, location),
|
||||
[router, to, search, hash, location],
|
||||
);
|
||||
|
||||
const handleIntentPreload = () => {
|
||||
if (preload === 'intent') {
|
||||
void router.preload({to, search, hash});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
onClick?.(event);
|
||||
if (event.defaultPrevented) return;
|
||||
if (event.button !== 0) return;
|
||||
if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
|
||||
const target = event.currentTarget.getAttribute('target');
|
||||
if (target && target !== '_self') return;
|
||||
event.preventDefault();
|
||||
void router.navigate({to, search, hash, state}, {replace, scroll});
|
||||
};
|
||||
|
||||
const handlePointerEnter = (event: React.PointerEvent<HTMLAnchorElement>) => {
|
||||
onPointerEnter?.(event);
|
||||
if (!event.defaultPrevented) handleIntentPreload();
|
||||
};
|
||||
|
||||
const handleFocus = (event: React.FocusEvent<HTMLAnchorElement>) => {
|
||||
onFocus?.(event);
|
||||
if (!event.defaultPrevented) handleIntentPreload();
|
||||
};
|
||||
|
||||
return (
|
||||
<a
|
||||
{...rest}
|
||||
ref={ref}
|
||||
href={hrefUrl.toString()}
|
||||
onClick={handleClick}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onFocus={handleFocus}
|
||||
data-router-link="true"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const Outlet: React.FC = () => {
|
||||
const matches = useMatches();
|
||||
const location = useLocation();
|
||||
|
||||
if (!matches.length) return null;
|
||||
|
||||
const renderAt = (index: number): React.ReactNode => {
|
||||
const match = matches[index];
|
||||
const props: RouteComponentProps = {
|
||||
match,
|
||||
params: match.params,
|
||||
search: match.search,
|
||||
url: location,
|
||||
};
|
||||
const isLeaf = index === matches.length - 1;
|
||||
|
||||
const child = isLeaf
|
||||
? match.route.component
|
||||
? React.createElement(match.route.component, props)
|
||||
: null
|
||||
: renderAt(index + 1);
|
||||
|
||||
const Layout = match.route.layout;
|
||||
if (Layout) {
|
||||
return <Layout {...props}>{child}</Layout>;
|
||||
}
|
||||
|
||||
return child;
|
||||
};
|
||||
|
||||
return <>{renderAt(0)}</>;
|
||||
};
|
||||
151
fluxer_app/src/lib/router/types.ts
Normal file
151
fluxer_app/src/lib/router/types.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type * as React from 'react';
|
||||
|
||||
export type SearchParamsInput = Record<string, string | number | boolean | null | undefined>;
|
||||
|
||||
export type To =
|
||||
| string
|
||||
| {
|
||||
to: string;
|
||||
search?: SearchParamsInput;
|
||||
hash?: string;
|
||||
state?: unknown;
|
||||
};
|
||||
|
||||
export type ScrollBehavior = 'preserve' | 'top';
|
||||
|
||||
export interface NavigateOptions {
|
||||
replace?: boolean;
|
||||
state?: unknown;
|
||||
scroll?: ScrollBehavior;
|
||||
from?: string;
|
||||
}
|
||||
|
||||
export type RouteParams = Record<string, string>;
|
||||
|
||||
export interface Match {
|
||||
route: Route;
|
||||
params: RouteParams;
|
||||
search: URLSearchParams;
|
||||
}
|
||||
|
||||
export interface RouterState {
|
||||
location: URL;
|
||||
matches: Array<Match>;
|
||||
navigating: boolean;
|
||||
pending?: Array<Match> | null;
|
||||
error?: unknown;
|
||||
historyState?: unknown;
|
||||
}
|
||||
|
||||
export interface RouteContext {
|
||||
url: URL;
|
||||
params: RouteParams;
|
||||
search: URLSearchParams;
|
||||
state: unknown;
|
||||
route: Route;
|
||||
matches: Array<Match>;
|
||||
router: Router;
|
||||
}
|
||||
|
||||
export interface RouteComponentProps {
|
||||
match: Match;
|
||||
params: RouteParams;
|
||||
search: URLSearchParams;
|
||||
url: URL;
|
||||
}
|
||||
|
||||
export interface RouteLayoutProps extends RouteComponentProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface RouteConfig {
|
||||
id: string;
|
||||
path?: string;
|
||||
pattern?: URLPattern;
|
||||
parentId?: string;
|
||||
component?: React.ComponentType<RouteComponentProps>;
|
||||
layout?: React.ComponentType<RouteLayoutProps>;
|
||||
onEnter?: (ctx: RouteContext) => undefined | Redirect | NotFound;
|
||||
onLeave?: (ctx: RouteContext) => void;
|
||||
preload?: (ctx: RouteContext) => Promise<unknown> | unknown;
|
||||
staticData?: unknown;
|
||||
}
|
||||
|
||||
export interface Route extends Omit<RouteConfig, 'pattern'> {
|
||||
pattern: URLPattern;
|
||||
}
|
||||
|
||||
export interface RouterOptions {
|
||||
routes: Array<RouteConfig>;
|
||||
history?: HistoryAdapter;
|
||||
baseHref?: string;
|
||||
notFoundRouteId?: string;
|
||||
scrollRestoration?: ScrollBehavior;
|
||||
}
|
||||
|
||||
export interface Router {
|
||||
getState(): RouterState;
|
||||
subscribe(listener: () => void): () => void;
|
||||
navigate(to: To, opts?: NavigateOptions): Promise<void>;
|
||||
preload(to: To): Promise<void>;
|
||||
resolveTo(to: To, from?: URL): URL;
|
||||
getRoutes(): Array<Route>;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export interface RouterProviderProps {
|
||||
router: Router;
|
||||
children?: React.ReactNode;
|
||||
linkContainerRef?: React.RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
export interface HistoryLocation {
|
||||
url: URL;
|
||||
state: unknown;
|
||||
}
|
||||
|
||||
export interface HistoryAdapter {
|
||||
getLocation(): HistoryLocation;
|
||||
push(url: URL, state?: unknown): void;
|
||||
replace(url: URL, state?: unknown): void;
|
||||
listen(listener: (location: HistoryLocation, action: 'pop') => void): () => void;
|
||||
go(delta: number): void;
|
||||
back(): void;
|
||||
readonly location: URL;
|
||||
}
|
||||
|
||||
export class Redirect extends Error {
|
||||
readonly to: To;
|
||||
readonly replace?: boolean;
|
||||
|
||||
constructor(to: To, options?: {replace?: boolean}) {
|
||||
super('Redirect');
|
||||
this.to = to;
|
||||
this.replace = options?.replace;
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFound extends Error {
|
||||
constructor(message = 'Not Found') {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
62
fluxer_app/src/lib/scroll/scrollPosition.ts
Normal file
62
fluxer_app/src/lib/scroll/scrollPosition.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface ScrollMetrics {
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
offsetHeight: number;
|
||||
}
|
||||
|
||||
export interface ScrollPinOptions {
|
||||
tolerance?: number;
|
||||
stickyTolerance?: number;
|
||||
hasMoreAfter?: boolean;
|
||||
wasPinned?: boolean;
|
||||
allowPinWhenHasMoreAfter?: boolean;
|
||||
}
|
||||
|
||||
export interface ScrollPinResult {
|
||||
distanceFromBottom: number;
|
||||
isAtBottom: boolean;
|
||||
isPinned: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_TOLERANCE = 8;
|
||||
const DEFAULT_STICKY_TOLERANCE = 64;
|
||||
|
||||
export function evaluateScrollPinning(metrics: ScrollMetrics, options: ScrollPinOptions = {}): ScrollPinResult {
|
||||
const tolerance = options.tolerance ?? DEFAULT_TOLERANCE;
|
||||
const stickyTolerance = options.stickyTolerance ?? DEFAULT_STICKY_TOLERANCE;
|
||||
const hasMoreAfter = options.hasMoreAfter ?? false;
|
||||
const allowPinWhenHasMoreAfter = options.allowPinWhenHasMoreAfter ?? true;
|
||||
const wasPinned = options.wasPinned ?? false;
|
||||
|
||||
const distanceFromBottom = Math.max(metrics.scrollHeight - metrics.offsetHeight - metrics.scrollTop, 0);
|
||||
const isWithinTolerance = distanceFromBottom <= tolerance;
|
||||
const isWithinStickyRange = distanceFromBottom <= stickyTolerance;
|
||||
|
||||
const shouldPin =
|
||||
(isWithinTolerance || (wasPinned && isWithinStickyRange)) && (allowPinWhenHasMoreAfter || !hasMoreAfter);
|
||||
|
||||
return {
|
||||
distanceFromBottom,
|
||||
isAtBottom: isWithinTolerance,
|
||||
isPinned: shouldPin,
|
||||
};
|
||||
}
|
||||
56
fluxer_app/src/lib/themePersistence.ts
Normal file
56
fluxer_app/src/lib/themePersistence.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ThemeTypes} from '~/Constants';
|
||||
|
||||
type ThemeValue = (typeof ThemeTypes)[keyof typeof ThemeTypes];
|
||||
|
||||
type ExplicitTheme = typeof ThemeTypes.DARK | typeof ThemeTypes.LIGHT | typeof ThemeTypes.COAL;
|
||||
|
||||
const STORAGE_KEY = 'theme';
|
||||
const EXPLICIT_THEMES = new Set<ThemeValue>([ThemeTypes.DARK, ThemeTypes.LIGHT, ThemeTypes.COAL]);
|
||||
|
||||
export function isExplicitTheme(theme: string | null | undefined): theme is ExplicitTheme {
|
||||
return typeof theme === 'string' && EXPLICIT_THEMES.has(theme as ThemeValue);
|
||||
}
|
||||
|
||||
export async function loadTheme(): Promise<ExplicitTheme | null> {
|
||||
try {
|
||||
if (!('localStorage' in window)) {
|
||||
return null;
|
||||
}
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
return isExplicitTheme(stored) ? stored : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function persistTheme(theme: string | null | undefined): Promise<void> {
|
||||
try {
|
||||
if (!isExplicitTheme(theme)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!('localStorage' in window)) {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(STORAGE_KEY, theme);
|
||||
} catch {}
|
||||
}
|
||||
145
fluxer_app/src/lib/versioning.ts
Normal file
145
fluxer_app/src/lib/versioning.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import UpdaterStore from '~/stores/UpdaterStore';
|
||||
|
||||
const CONTROLLER_CHANGE_TIMEOUT_MS = 4_000;
|
||||
|
||||
export const activateLatestServiceWorker = async (): Promise<void> => {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
if (!registration) {
|
||||
return;
|
||||
}
|
||||
|
||||
await registration.update().catch((error: unknown) => {
|
||||
console.warn('[Versioning] Failed to update service worker registration', error);
|
||||
});
|
||||
|
||||
const postSkipWaiting = (worker: ServiceWorker | null) => {
|
||||
if (!worker) return;
|
||||
try {
|
||||
worker.postMessage({type: 'SKIP_WAITING'});
|
||||
} catch (error) {
|
||||
console.warn('[Versioning] Failed to postMessage SKIP_WAITING', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (registration.waiting) {
|
||||
postSkipWaiting(registration.waiting);
|
||||
await waitForActivation(registration.waiting);
|
||||
} else if (registration.installing) {
|
||||
const installing = registration.installing;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const handleStateChange = () => {
|
||||
if (installing.state === 'installed') {
|
||||
postSkipWaiting(registration.waiting);
|
||||
if (registration.waiting) {
|
||||
waitForActivation(registration.waiting).then(resolve);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
} else if (installing.state === 'activated') {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
if (installing.state === 'installed') {
|
||||
handleStateChange();
|
||||
} else if (installing.state === 'activated') {
|
||||
resolve();
|
||||
} else {
|
||||
installing.addEventListener('statechange', handleStateChange);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await waitForControllerChange();
|
||||
} catch (error) {
|
||||
console.warn('[Versioning] Failed to activate latest service worker', error);
|
||||
}
|
||||
};
|
||||
|
||||
const waitForControllerChange = async (): Promise<void> => {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator.serviceWorker.controller) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
console.warn('[Versioning] Controller change timed out after', CONTROLLER_CHANGE_TIMEOUT_MS, 'ms');
|
||||
resolve();
|
||||
}
|
||||
}, CONTROLLER_CHANGE_TIMEOUT_MS);
|
||||
|
||||
const handleControllerChange = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
window.clearTimeout(timeoutId);
|
||||
navigator.serviceWorker.removeEventListener('controllerchange', handleControllerChange);
|
||||
resolve();
|
||||
};
|
||||
|
||||
navigator.serviceWorker.addEventListener('controllerchange', handleControllerChange);
|
||||
});
|
||||
};
|
||||
|
||||
const waitForActivation = async (worker: ServiceWorker): Promise<void> => {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (worker.state === 'activated') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const handleStateChange = () => {
|
||||
if (worker.state === 'activated') {
|
||||
worker.removeEventListener('statechange', handleStateChange);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
worker.addEventListener('statechange', handleStateChange);
|
||||
|
||||
setTimeout(() => {
|
||||
if (worker.state !== 'activated') {
|
||||
console.warn('[Versioning] Service worker activation timed out, current state:', worker.state);
|
||||
worker.removeEventListener('statechange', handleStateChange);
|
||||
resolve();
|
||||
}
|
||||
}, CONTROLLER_CHANGE_TIMEOUT_MS);
|
||||
});
|
||||
};
|
||||
|
||||
export const ensureLatestAssets = async (options: {force?: boolean} = {}): Promise<{updateFound: boolean}> => {
|
||||
await UpdaterStore.checkForUpdates(options.force ?? false);
|
||||
return {updateFound: UpdaterStore.updateInfo.web.available};
|
||||
};
|
||||
Reference in New Issue
Block a user