/*
* 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 .
*/
import type {ModalRender} from '@app/actions/ModalRender';
import {Logger} from '@app/lib/Logger';
import ToastStore from '@app/stores/ToastStore';
import {makeAutoObservable} from 'mobx';
import type React from 'react';
const logger = new Logger('ModalStore');
type KeyboardModeStateResolver = () => boolean;
type KeyboardModeRestoreCallback = (showIntro: boolean) => void;
let keyboardModeStateResolver: KeyboardModeStateResolver | undefined;
let keyboardModeRestoreCallback: KeyboardModeRestoreCallback | undefined;
export function registerKeyboardModeStateResolver(resolver: KeyboardModeStateResolver): void {
keyboardModeStateResolver = resolver;
}
export function registerKeyboardModeRestoreCallback(callback: KeyboardModeRestoreCallback): void {
keyboardModeRestoreCallback = callback;
}
const BASE_Z_INDEX = 10000;
const Z_INDEX_INCREMENT = 2;
export function getZIndexForStack(stackIndex: number): number {
return BASE_Z_INDEX + stackIndex * Z_INDEX_INCREMENT;
}
export function getBackdropZIndexForStack(stackIndex: number): number {
return BASE_Z_INDEX + stackIndex * Z_INDEX_INCREMENT - 1;
}
interface Modal {
modal: ModalRender;
key: string;
focusReturnTarget: HTMLElement | null;
keyboardModeEnabled: boolean;
isBackground: boolean;
}
interface ModalWithStackInfo extends Modal {
stackIndex: number;
isVisible: boolean;
needsBackdrop: boolean;
isTopmost: boolean;
}
interface PushOptions {
isBackground?: boolean;
}
class ModalStore {
modals: Array = [];
private hasShownStackingToast = false;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
push(modal: ModalRender, key: string | number, options: PushOptions = {}): void {
const isBackground = options.isBackground ?? false;
const keyboardModeEnabled = keyboardModeStateResolver ? keyboardModeStateResolver() : false;
this.modals.push({
modal,
key: key.toString(),
focusReturnTarget: this.getActiveElement(),
keyboardModeEnabled,
isBackground,
});
this.checkAlternatingStackPattern();
}
private getModalSignature(modal: Modal): string {
const element = modal.modal();
const typeName = typeof element.type === 'function' ? element.type.name : String(element.type);
try {
return `${typeName}:${JSON.stringify(element.props)}`;
} catch {
return `${typeName}:${modal.key}`;
}
}
private checkAlternatingStackPattern(): void {
if (this.hasShownStackingToast) return;
if (this.modals.length < 5) return;
const lastFive = this.modals.slice(-5);
const signatures = lastFive.map((m) => this.getModalSignature(m));
const signatureA = signatures[0];
const signatureB = signatures[1];
if (signatureA === signatureB) return;
const isAlternating = signatures[2] === signatureA && signatures[3] === signatureB && signatures[4] === signatureA;
if (isAlternating) {
this.hasShownStackingToast = true;
ToastStore.createToast({type: 'info', children: 'Having fun?', timeout: 3000});
}
}
update(key: string | number, updater: (currentModal: ModalRender) => ModalRender, options?: PushOptions): void {
const modalIndex = this.modals.findIndex((modal) => modal.key === key.toString());
if (modalIndex === -1) return;
const existingModal = this.modals[modalIndex];
this.modals[modalIndex] = {
...existingModal,
modal: updater(existingModal.modal),
isBackground: options?.isBackground ?? existingModal.isBackground,
};
}
pop(key?: string | number): void {
let removed: Modal | undefined;
let wasTopmost = false;
if (key) {
const keyStr = key.toString();
const idx = this.modals.findIndex((modal) => modal.key === keyStr);
if (idx !== -1) {
wasTopmost = idx === this.modals.length - 1;
[removed] = this.modals.splice(idx, 1);
}
} else {
removed = this.modals.pop();
wasTopmost = true;
}
if (removed && wasTopmost) {
logger.debug(`ModalStore.pop restoring focus topmost=${wasTopmost} keyboardMode=${removed.keyboardModeEnabled}`);
this.scheduleFocus(removed.focusReturnTarget, removed.keyboardModeEnabled);
}
}
popAll(): void {
const lastModal = this.modals.at(-1);
this.modals = [];
if (lastModal) {
this.scheduleFocus(lastModal.focusReturnTarget, lastModal.keyboardModeEnabled);
}
}
popByType(component: React.ComponentType): void {
const modalIndex = this.modals.findLastIndex((modal) => modal.modal().type === component);
if (modalIndex === -1) return;
const wasTopmost = modalIndex === this.modals.length - 1;
const [removed] = this.modals.splice(modalIndex, 1);
if (removed && wasTopmost) {
logger.debug(
`ModalStore.popByType restoring focus topmost=${wasTopmost} keyboardMode=${removed.keyboardModeEnabled}`,
);
this.scheduleFocus(removed.focusReturnTarget, removed.keyboardModeEnabled);
}
}
get orderedModals(): Array {
const topmostRegularIndex = this.modals.findLastIndex((m) => !m.isBackground);
const topmostIndex = this.modals.length - 1;
return this.modals.map((modal, index) => {
const isVisible = modal.isBackground || index === topmostRegularIndex;
const needsBackdrop = modal.isBackground || (!modal.isBackground && index === topmostRegularIndex);
return {
...modal,
stackIndex: index,
isVisible,
needsBackdrop,
isTopmost: index === topmostIndex,
};
});
}
getModal(): Modal | undefined {
return this.modals.at(-1);
}
hasModalOpen(): boolean {
return this.modals.length > 0;
}
hasModal(key: string): boolean {
return this.modals.some((modal) => modal.key === key);
}
hasModalOfType(component: React.ComponentType): boolean {
return this.modals.some((modal) => modal.modal().type === component);
}
private getActiveElement(): HTMLElement | null {
const active = document.activeElement;
return active instanceof HTMLElement ? active : null;
}
private scheduleFocus(target: HTMLElement | null, keyboardModeEnabled: boolean): void {
logger.debug(
`ModalStore.scheduleFocus target=${target ? target.tagName : 'null'} keyboardMode=${keyboardModeEnabled}`,
);
if (!target) return;
queueMicrotask(() => {
if (!target.isConnected) {
logger.debug('ModalStore.scheduleFocus aborted: target disconnected');
return;
}
try {
target.focus({preventScroll: true});
logger.debug('ModalStore.scheduleFocus applied focus to target');
} catch (error) {
logger.error('ModalStore.scheduleFocus failed to focus target', error as Error);
return;
}
if (keyboardModeEnabled && keyboardModeRestoreCallback) {
logger.debug('ModalStore.scheduleFocus re-entering keyboard mode');
keyboardModeRestoreCallback(false);
}
});
}
}
export default new ModalStore();