initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View 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();

View 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;

View 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();

View 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;
}
}

View 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();

View 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();

View 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,
});
}
}

View 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();

View 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;
}
}

View 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});
}
}

View 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};

View 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;
}
}

View 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';

View 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);

View 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();

View 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';
}
}
}

View 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();

View 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();

View 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);
}
};

View 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]);
}

View 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
View 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);
}
}
}
}

View 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},
);
}

File diff suppressed because it is too large Load Diff

View 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();

View 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()],
});
}

View 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';

View 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,
};

View 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();

View 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
View 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;

View 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]*$/;

View 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);
}

View 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;
}

View 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;

View 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>;
});

View 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 {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);
});
});
});

View File

@@ -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'}`;
}
}

View 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: '.'},
]);
});
});

View 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('>>> ')))
);
}
}

File diff suppressed because it is too large Load Diff

View 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;
}

View 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®'}]);
});
});

View 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);
}
}
}

View File

@@ -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_'},
]);
});
});
});

View 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} : {}),
};
}

File diff suppressed because it is too large Load Diff

View 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;
}

View 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);
});
});
});

View 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;
}

View File

@@ -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: '!'},
]);
});
});

View 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,
},
};
}

View 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);
});
});
});

View 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;
}

View File

@@ -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>'}]);
});
});

View 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;
}
}

View 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;

View 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];

View 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;
}

View 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);
});
});
});

View 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});
}
}

View 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);
});
});
});

View 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;
}

View 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
);
}

View 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;
}
}

View 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);
}

View 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>
);
});

View 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>
);
});

View 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 {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>
);
});

View 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;

View 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;
}

View 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>
);
});

View 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>;
}
});

View 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>;
});

View 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>
);
});

View 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),
});
}
}

View 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,
};
}

View 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)))
);
})
);
}

View 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;
}

View 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);
}

View 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);
}

View 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;
},
};
}

View 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';

View 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)}</>;
};

View 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);
}
}

View 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,
};
}

View 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 {}
}

View 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};
};