mirror of
https://github.com/flarum/core.git
synced 2025-08-06 16:36:47 +02:00
feat: Code Splitting (#3860)
* feat: configure webpack to allow splitting chunks * feat: `JsDirectoryCompiler` and expose js assets URL * chore: support es2020 dynamic importing * feat: control which URL to fetch chunks from * feat: allow showing async modals & split 'LogInModal' * feat: split `SignUpModal` * feat: allow rendering async pages & split `UserSecurityPage` * fix: module might not be listed in chunk * feat: lazy load user pages * feat: track the chunk containing each module * chore: lightly warn * chore: split `Composer` * feat: add common frontend (for split common chunks) * fix: jsDoc typing imports should be ignored * feat: split `PostStream` `ForgotPasswordModal` and `EditUserModal` * fix: multiple inline async imports not picked up * chore: new `common` frontend assets only needs a jsdir compiler * feat: add revision hash to chunk import URL * fix: nothing to split for `admin` frontend yet * chore: cleanup registry API * chore: throw an error in debug mode if attempting to import a non-loaded module * feat: defer `extend` & `override` until after module registration * fix: plugin not picking up on all module sources * fix: must override default chunk loader function from webpack plugin * feat: split tags `TagDiscussionModal` and `TagSelectionModal` * fix: wrong export name * feat: import chunked modules from external packages * feat: extensions compatibility * feat: Router frontend extender async component * chore: clean JS output path (removes stale chunks) * fix: common chunks also need flushing * chore: flush backend stale chunks * Apply fixes from StyleCI * feat: loading alert when async page component is loading * chore: `yarn format` * chore: typings * chore: remove exception * Apply fixes from StyleCI * chore(infra): bundlewatch * chore(infra): bundlewatch split chunks * feat: split text editor * chore: tag typings * chore: bundlewatch * fix: windows paths * fix: wrong planned ext import format
This commit is contained in:
14
framework/core/js/src/@types/global.d.ts
vendored
14
framework/core/js/src/@types/global.d.ts
vendored
@@ -54,7 +54,7 @@ declare type VnodeElementTag<Attrs = Record<string, unknown>, C extends Componen
|
||||
* import app from 'flarum/common/app';
|
||||
* ```
|
||||
*/
|
||||
declare const app: never;
|
||||
declare const app: import('../common/Application').default;
|
||||
|
||||
declare const m: import('mithril').Static;
|
||||
declare const dayjs: typeof import('dayjs');
|
||||
@@ -98,8 +98,16 @@ interface FlarumObject {
|
||||
* }
|
||||
*/
|
||||
extensions: Readonly<Record<string, ESModule>>;
|
||||
|
||||
reg: any;
|
||||
/**
|
||||
* Contains a registry of all exported modules,
|
||||
* as well as chunks that can be imported and the modules
|
||||
* each chunk contains.
|
||||
*/
|
||||
reg: import('../common/ExportRegistry').default;
|
||||
/**
|
||||
* For early operations, this object stores whether we are in debug mode or not.
|
||||
*/
|
||||
debug: boolean;
|
||||
}
|
||||
|
||||
declare const flarum: FlarumObject;
|
||||
|
@@ -2,7 +2,6 @@ import Mithril from 'mithril';
|
||||
|
||||
import app from '../../admin/app';
|
||||
|
||||
import EditUserModal from '../../common/components/EditUserModal';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import Button from '../../common/components/Button';
|
||||
|
||||
@@ -432,7 +431,7 @@ export default class UserListPage extends AdminPage {
|
||||
<Button
|
||||
className="Button UserList-editModalBtn"
|
||||
title={app.translator.trans('core.admin.users.grid.columns.edit_user.tooltip', { username: user.username() })}
|
||||
onclick={() => app.modal.show(EditUserModal, { user })}
|
||||
onclick={() => app.modal.show(() => import('../../common/components/EditUserModal'), { user })}
|
||||
>
|
||||
{app.translator.trans('core.admin.users.grid.columns.edit_user.button')}
|
||||
</Button>
|
||||
|
@@ -16,7 +16,7 @@ export default class ExtensionPageResolver<
|
||||
const extensionPage = app.extensionData.getPage<Attrs>(args.id);
|
||||
|
||||
if (extensionPage) {
|
||||
return extensionPage;
|
||||
return Promise.resolve(extensionPage);
|
||||
}
|
||||
|
||||
return super.onmatch(args, requestedPath, route);
|
||||
|
@@ -60,6 +60,9 @@ export interface FlarumRequestOptions<ResponseType> extends Omit<Mithril.Request
|
||||
modifyText?: (responseText: string) => string;
|
||||
}
|
||||
|
||||
export type NewComponent<Comp> = new () => Comp;
|
||||
export type AsyncNewComponent<Comp> = () => Promise<any & { default: NewComponent<Comp> }>;
|
||||
|
||||
/**
|
||||
* A valid route definition.
|
||||
*/
|
||||
@@ -82,14 +85,14 @@ export type RouteItem<
|
||||
/**
|
||||
* The component to render when this route matches.
|
||||
*/
|
||||
component: new () => Comp;
|
||||
component: NewComponent<Comp> | AsyncNewComponent<Comp>;
|
||||
/**
|
||||
* A custom resolver class.
|
||||
*
|
||||
* This should be the class itself, and **not** an instance of the
|
||||
* class.
|
||||
*/
|
||||
resolverClass?: new (component: new () => Comp, routeName: string) => DefaultResolver<Attrs, Comp, RouteArgs>;
|
||||
resolverClass?: new (component: NewComponent<Comp> | AsyncNewComponent<Comp>, routeName: string) => DefaultResolver<Attrs, Comp, RouteArgs>;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
@@ -113,7 +116,7 @@ export interface RouteResolver<
|
||||
*
|
||||
* @see https://mithril.js.org/route.html#routeresolveronmatch
|
||||
*/
|
||||
onmatch(this: this, args: RouteArgs, requestedPath: string, route: string): { new (): Comp };
|
||||
onmatch(this: this, args: RouteArgs, requestedPath: string, route: string): Promise<{ new (): Comp }>;
|
||||
/**
|
||||
* A function which renders the provided component.
|
||||
*
|
||||
|
@@ -7,6 +7,7 @@ export interface IExportRegistry {
|
||||
|
||||
/**
|
||||
* Add an instance to the registry.
|
||||
* Identified by a namespace (extension ID) and an ID (module path).
|
||||
*/
|
||||
add(namespace: string, id: string, object: any): void;
|
||||
|
||||
@@ -17,14 +18,79 @@ export interface IExportRegistry {
|
||||
onLoad(namespace: string, id: string, handler: Function): void;
|
||||
|
||||
/**
|
||||
* Retrieve an object of type `id` from the registry.
|
||||
* Retrieve a module from the registry by namespace and ID.
|
||||
*/
|
||||
get(namespace: string, id: string): any;
|
||||
}
|
||||
|
||||
export default class ExportRegistry implements IExportRegistry {
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export interface IChunkRegistry {
|
||||
chunks: Map<string, Chunk>;
|
||||
chunkModules: Map<string, Module>;
|
||||
|
||||
/**
|
||||
* Check if a module has been loaded.
|
||||
* Return the module if so, false otherwise.
|
||||
*/
|
||||
checkModule(namespace: string, id: string): any | false;
|
||||
|
||||
/**
|
||||
* Register a module by the chunk ID it belongs to, the webpack module ID it belongs to,
|
||||
* the namespace (extension ID), and its path.
|
||||
*/
|
||||
addChunkModule(chunkId: number | string, moduleId: number | string, namespace: string, urlPath: string): void;
|
||||
|
||||
/**
|
||||
* Get a registered chunk. Each chunk has at least one module (the default one).
|
||||
*/
|
||||
getChunk(chunkId: number | string): Chunk | null;
|
||||
|
||||
/**
|
||||
* The chunk loader which overrides the default Webpack chunk loader.
|
||||
*/
|
||||
loadChunk(original: Function, url: string, done: () => Promise<void>, key: number, chunkId: number | string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Responsible for loading external chunks.
|
||||
* Called automatically when an extension/package tries to async import a chunked module.
|
||||
*/
|
||||
asyncModuleImport(path: string): Promise<any>;
|
||||
}
|
||||
|
||||
type Chunk = {
|
||||
/**
|
||||
* The extension id of the chunk or 'core'.
|
||||
*/
|
||||
namespace: string;
|
||||
/**
|
||||
* The relative URL path to the chunk.
|
||||
*/
|
||||
urlPath: string;
|
||||
/**
|
||||
* An array of modules included in the chunk, by relative module path.
|
||||
*/
|
||||
modules?: string[];
|
||||
};
|
||||
|
||||
type Module = {
|
||||
/**
|
||||
* The chunk ID the module belongs to.
|
||||
*/
|
||||
chunkId: string;
|
||||
/**
|
||||
* The module ID. Not unique, as most chunk modules are concatenated into one module.
|
||||
*/
|
||||
moduleId: string;
|
||||
};
|
||||
|
||||
export default class ExportRegistry implements IExportRegistry, IChunkRegistry {
|
||||
moduleExports = new Map<string, Map<string, any>>();
|
||||
onLoads = new Map<string, Map<string, Function[]>>();
|
||||
chunks = new Map<string, Chunk>();
|
||||
chunkModules = new Map<string, Module>();
|
||||
private _revisions: any = null;
|
||||
|
||||
add(namespace: string, id: string, object: any): void {
|
||||
this.moduleExports.set(namespace, this.moduleExports.get(namespace) || new Map());
|
||||
@@ -36,7 +102,7 @@ export default class ExportRegistry implements IExportRegistry {
|
||||
?.forEach((handler) => handler(object));
|
||||
}
|
||||
|
||||
onLoad(namespace: string, id: string, handler: Function): void {
|
||||
onLoad(namespace: string, id: string, handler: (module: any) => void): void {
|
||||
if (this.moduleExports.has(namespace) && this.moduleExports.get(namespace)?.has(id)) {
|
||||
handler(this.moduleExports.get(namespace)?.get(id));
|
||||
} else {
|
||||
@@ -48,11 +114,126 @@ export default class ExportRegistry implements IExportRegistry {
|
||||
|
||||
get(namespace: string, id: string): any {
|
||||
const module = this.moduleExports.get(namespace)?.get(id);
|
||||
const error = `No module found for ${namespace}:${id}`;
|
||||
|
||||
if (!module) {
|
||||
console.warn(`No module found for ${namespace}:${id}`);
|
||||
// @ts-ignore
|
||||
if (!module && flarum.debug) {
|
||||
throw new Error(error);
|
||||
} else if (!module) {
|
||||
console.warn(error);
|
||||
}
|
||||
|
||||
return module;
|
||||
}
|
||||
|
||||
public checkModule(namespace: string, id: string): any | false {
|
||||
const exists = (this.moduleExports.has(namespace) && this.moduleExports.get(namespace)?.has(id)) || false;
|
||||
|
||||
return exists ? this.get(namespace, id) : false;
|
||||
}
|
||||
|
||||
addChunkModule(chunkId: number | string, moduleId: number | string, namespace: string, urlPath: string): void {
|
||||
if (!this.chunks.has(chunkId.toString())) {
|
||||
this.chunks.set(chunkId.toString(), {
|
||||
namespace,
|
||||
urlPath,
|
||||
modules: [urlPath],
|
||||
});
|
||||
} else {
|
||||
this.chunks.get(chunkId.toString())?.modules?.push(urlPath);
|
||||
}
|
||||
|
||||
this.chunkModules.set(`${namespace}:${urlPath}`, {
|
||||
chunkId: chunkId.toString(),
|
||||
moduleId: moduleId.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
getChunk(chunkId: number | string): Chunk | null {
|
||||
const chunk = this.chunks.get(chunkId.toString()) ?? null;
|
||||
|
||||
if (!chunk) {
|
||||
console.warn(`[Export Registry] No chunk by the ID ${chunkId} found.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return chunk;
|
||||
}
|
||||
|
||||
async loadChunk(original: Function, url: string, done: (...args: any) => Promise<void>, key: number, chunkId: number | string): Promise<void> {
|
||||
// @ts-ignore
|
||||
app.alerts.showLoading();
|
||||
|
||||
return await original(
|
||||
this.chunkUrl(chunkId) || url,
|
||||
(...args: any) => {
|
||||
// @ts-ignore
|
||||
app.alerts.clearLoading();
|
||||
|
||||
return done(...args);
|
||||
},
|
||||
key,
|
||||
chunkId
|
||||
);
|
||||
}
|
||||
|
||||
chunkUrl(chunkId: number | string): string | null {
|
||||
const chunk = this.getChunk(chunkId.toString());
|
||||
|
||||
if (!chunk) return null;
|
||||
|
||||
this._revisions ??= JSON.parse(document.getElementById('flarum-rev-manifest')?.textContent ?? '{}');
|
||||
|
||||
// @ts-ignore cannot import the app object here, so we use the global one.
|
||||
const path = `${app.forum.attribute<string>('jsChunksBaseUrl')}/${chunk.namespace}/${chunk.urlPath}.js`;
|
||||
|
||||
// The paths in the revision are stored as (relative path from the assets path) + the path.
|
||||
// @ts-ignore
|
||||
const assetsPath = app.forum.attribute<string>('assetsBaseUrl');
|
||||
const key = path.replace(assetsPath, '').replace(/^\//, '');
|
||||
const revision = this._revisions[key];
|
||||
|
||||
return revision ? `${path}?v=${revision}` : path;
|
||||
}
|
||||
|
||||
async asyncModuleImport(path: string): Promise<any> {
|
||||
const [namespace, id] = this.namespaceAndIdFromPath(path);
|
||||
const module = this.chunkModules.get(`${namespace}:${id}`);
|
||||
|
||||
if (!module) {
|
||||
throw new Error(`No chunk found for module ${namespace}:${id}`);
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const wr = __webpack_require__;
|
||||
|
||||
return await wr.e(module.chunkId).then(() => {
|
||||
// Needed to make sure the module is loaded.
|
||||
// Taken care of by webpack.
|
||||
wr.bind(wr, module.moduleId)();
|
||||
|
||||
const moduleExport = this.get(namespace, id);
|
||||
|
||||
// For consistent access to async modules.
|
||||
moduleExport.default = moduleExport.default || moduleExport;
|
||||
|
||||
return moduleExport;
|
||||
});
|
||||
}
|
||||
|
||||
namespaceAndIdFromPath(path: string): [string, string] {
|
||||
// Either we get a path like `flarum/forum/components/LogInModal` or `ext:flarum/tags/forum/components/TagPage`.
|
||||
const matches = /^(?:ext:([^\/]+)\/(?:flarum-(?:ext-)?)?([^\/]+)|(flarum))(?:\/(.+))?$/.exec(path);
|
||||
|
||||
const id = matches![4];
|
||||
let namespace;
|
||||
|
||||
if (matches![1]) {
|
||||
namespace = `${matches![1]}-${matches![2]}`;
|
||||
} else {
|
||||
namespace = 'core';
|
||||
}
|
||||
|
||||
return [namespace, id];
|
||||
}
|
||||
}
|
||||
|
@@ -64,9 +64,7 @@ import './components/ModalManager';
|
||||
import './components/Button';
|
||||
import './components/Modal';
|
||||
import './components/GroupBadge';
|
||||
import './components/TextEditor';
|
||||
import './components/TextEditorButton';
|
||||
import './components/EditUserModal';
|
||||
import './components/Tooltip';
|
||||
|
||||
import './helpers/fullTime';
|
||||
|
@@ -6,6 +6,7 @@ import { disableBodyScroll, clearAllBodyScrollLocks } from 'body-scroll-lock';
|
||||
|
||||
import type ModalManagerState from '../states/ModalManagerState';
|
||||
import type Mithril from 'mithril';
|
||||
import LoadingIndicator from './LoadingIndicator';
|
||||
|
||||
interface IModalManagerAttrs {
|
||||
state: ModalManagerState;
|
||||
@@ -60,13 +61,15 @@ export default class ModalManager extends Component<IModalManagerAttrs> {
|
||||
);
|
||||
})}
|
||||
|
||||
{this.attrs.state.backdropShown && (
|
||||
{(this.attrs.state.backdropShown || this.attrs.state.loadingModal) && (
|
||||
<div
|
||||
className="Modal-backdrop backdrop"
|
||||
ontransitionend={this.onBackdropTransitionEnd.bind(this)}
|
||||
data-showing={!!this.attrs.state.modalList.length}
|
||||
style={{ '--modal-count': this.attrs.state.modalList.length }}
|
||||
/>
|
||||
data-showing={!!this.attrs.state.modalList.length || this.attrs.state.loadingModal}
|
||||
style={{ '--modal-count': this.attrs.state.modalList.length + Number(this.attrs.state.loadingModal) }}
|
||||
>
|
||||
{this.attrs.state.loadingModal && <LoadingIndicator />}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@@ -7,6 +7,7 @@ import Button from './Button';
|
||||
|
||||
import BasicEditorDriver from '../utils/BasicEditorDriver';
|
||||
import Tooltip from './Tooltip';
|
||||
import LoadingIndicator from './LoadingIndicator';
|
||||
|
||||
/**
|
||||
* The `TextEditor` component displays a textarea with controls, including a
|
||||
@@ -36,17 +37,33 @@ export default class TextEditor extends Component {
|
||||
* Whether the editor is disabled.
|
||||
*/
|
||||
this.disabled = !!this.attrs.disabled;
|
||||
|
||||
/**
|
||||
* Whether the editor is loading.
|
||||
*/
|
||||
this.loading = true;
|
||||
|
||||
/**
|
||||
* Async operations to complete before the editor is ready.
|
||||
*/
|
||||
this._loaders = [];
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="TextEditor">
|
||||
<div className="TextEditor-editorContainer"></div>
|
||||
{this.loading ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<>
|
||||
<div className="TextEditor-editorContainer"></div>
|
||||
|
||||
<ul className="TextEditor-controls Composer-footer">
|
||||
{listItems(this.controlItems().toArray())}
|
||||
<li className="TextEditor-toolbar">{this.toolbarItems().toArray()}</li>
|
||||
</ul>
|
||||
<ul className="TextEditor-controls Composer-footer">
|
||||
{listItems(this.controlItems().toArray())}
|
||||
<li className="TextEditor-toolbar">{this.toolbarItems().toArray()}</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -54,6 +71,12 @@ export default class TextEditor extends Component {
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this._load().then(() => {
|
||||
setTimeout(this.onbuild.bind(this), 50);
|
||||
});
|
||||
}
|
||||
|
||||
onbuild() {
|
||||
this.attrs.composer.editor = this.buildEditor(this.$('.TextEditor-editorContainer')[0]);
|
||||
}
|
||||
|
||||
@@ -68,6 +91,13 @@ export default class TextEditor extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
_load() {
|
||||
return Promise.all(this._loaders.map((loader) => loader())).then(() => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
buildEditorParams() {
|
||||
return {
|
||||
classNames: ['FormControl', 'Composer-flexible', 'TextEditor-editor'],
|
||||
|
@@ -24,10 +24,19 @@
|
||||
* @param callback A callback which mutates the method's output
|
||||
*/
|
||||
export function extend<T extends Record<string, any>, K extends KeyOfType<T, Function>>(
|
||||
object: T,
|
||||
object: T | string,
|
||||
methods: K | K[],
|
||||
callback: (this: T, val: ReturnType<T[K]>, ...args: Parameters<T[K]>) => void
|
||||
) {
|
||||
// A lazy loaded module, only apply the function after the module is loaded.
|
||||
if (typeof object === 'string') {
|
||||
let [namespace, id] = flarum.reg.namespaceAndIdFromPath(object);
|
||||
|
||||
return flarum.reg.onLoad(namespace, id, (module) => {
|
||||
extend(module.prototype, methods, callback);
|
||||
});
|
||||
}
|
||||
|
||||
const allMethods = Array.isArray(methods) ? methods : [methods];
|
||||
|
||||
allMethods.forEach((method: K) => {
|
||||
@@ -73,17 +82,26 @@ export function extend<T extends Record<string, any>, K extends KeyOfType<T, Fun
|
||||
* @param newMethod The method to replace it with
|
||||
*/
|
||||
export function override<T extends Record<any, any>, K extends KeyOfType<T, Function>>(
|
||||
object: T,
|
||||
object: T | string,
|
||||
methods: K | K[],
|
||||
newMethod: (this: T, orig: T[K], ...args: Parameters<T[K]>) => void
|
||||
) {
|
||||
// A lazy loaded module, only apply the function after the module is loaded.
|
||||
if (typeof object === 'string') {
|
||||
let [namespace, id] = flarum.reg.namespaceAndIdFromPath(object);
|
||||
|
||||
return flarum.reg.onLoad(namespace, id, (module) => {
|
||||
override(module.prototype, methods, newMethod);
|
||||
});
|
||||
}
|
||||
|
||||
const allMethods = Array.isArray(methods) ? methods : [methods];
|
||||
|
||||
allMethods.forEach((method) => {
|
||||
const original: Function = object[method];
|
||||
|
||||
object[method] = function (this: T, ...args: Parameters<T[K]>) {
|
||||
return newMethod.apply(this, [original.bind(this), ...args]);
|
||||
return newMethod.apply(this, [original?.bind(this), ...args]);
|
||||
} as T[K];
|
||||
|
||||
Object.assign(object[method], original);
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import Application, { FlarumGenericRoute } from '../Application';
|
||||
import Application, { AsyncNewComponent, FlarumGenericRoute, NewComponent } from '../Application';
|
||||
import IExtender, { IExtensionModule } from './IExtender';
|
||||
|
||||
type HelperRoute = (...args: any) => string;
|
||||
@@ -14,7 +14,7 @@ export default class Routes implements IExtender {
|
||||
* @param path The path of the route.
|
||||
* @param component must extend `Page` component.
|
||||
*/
|
||||
add(name: string, path: `/${string}`, component: any): Routes {
|
||||
add(name: string, path: `/${string}`, component: NewComponent<any> | AsyncNewComponent<any>): Routes {
|
||||
this.routes[name] = { path, component };
|
||||
|
||||
return this;
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import type Mithril from 'mithril';
|
||||
import type { RouteResolver } from '../Application';
|
||||
import type { default as Component, ComponentAttrs } from '../Component';
|
||||
import type { AsyncNewComponent, NewComponent, RouteResolver } from '../Application';
|
||||
import type { ComponentAttrs } from '../Component';
|
||||
import Component from '../Component';
|
||||
|
||||
/**
|
||||
* Generates a route resolver for a given component.
|
||||
@@ -15,10 +16,10 @@ export default class DefaultResolver<
|
||||
RouteArgs extends Record<string, unknown> = {}
|
||||
> implements RouteResolver<Attrs, Comp, RouteArgs>
|
||||
{
|
||||
component: new () => Comp;
|
||||
component: NewComponent<Comp> | AsyncNewComponent<Comp>;
|
||||
routeName: string;
|
||||
|
||||
constructor(component: new () => Comp, routeName: string) {
|
||||
constructor(component: NewComponent<Comp> | AsyncNewComponent<Comp>, routeName: string) {
|
||||
this.component = component;
|
||||
this.routeName = routeName;
|
||||
}
|
||||
@@ -39,8 +40,12 @@ export default class DefaultResolver<
|
||||
};
|
||||
}
|
||||
|
||||
onmatch(args: RouteArgs, requestedPath: string, route: string): { new (): Comp } {
|
||||
return this.component;
|
||||
async onmatch(args: RouteArgs, requestedPath: string, route: string): Promise<NewComponent<Comp>> {
|
||||
if (this.component.prototype instanceof Component) {
|
||||
return this.component as NewComponent<Comp>;
|
||||
}
|
||||
|
||||
return (await (this.component as AsyncNewComponent<Comp>)()).default;
|
||||
}
|
||||
|
||||
render(vnode: Mithril.Vnode<Attrs, Comp>): Mithril.Children {
|
@@ -1,5 +1,6 @@
|
||||
import type Mithril from 'mithril';
|
||||
import Alert, { AlertAttrs } from '../components/Alert';
|
||||
import app from '../app';
|
||||
|
||||
/**
|
||||
* Returned by `AlertManagerState.show`. Used to dismiss alerts.
|
||||
@@ -17,6 +18,7 @@ export interface AlertState {
|
||||
export default class AlertManagerState {
|
||||
protected activeAlerts: AlertArray = {};
|
||||
protected alertId: AlertIdentifier = 0;
|
||||
protected loadingPool: number = 0;
|
||||
|
||||
getActiveAlerts() {
|
||||
return this.activeAlerts;
|
||||
@@ -71,4 +73,30 @@ export default class AlertManagerState {
|
||||
this.activeAlerts = {};
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a loading alert.
|
||||
*/
|
||||
showLoading(): AlertIdentifier | null {
|
||||
this.loadingPool++;
|
||||
|
||||
if (this.loadingPool > 1) return null;
|
||||
|
||||
return this.show(
|
||||
{
|
||||
type: 'warning',
|
||||
dismissible: false,
|
||||
},
|
||||
app.translator.trans('core.lib.loading_indicator.accessible_label')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides a loading alert.
|
||||
*/
|
||||
clearLoading(): void {
|
||||
this.loadingPool--;
|
||||
|
||||
if (this.loadingPool === 0) this.clear();
|
||||
}
|
||||
}
|
||||
|
@@ -10,6 +10,12 @@ import Modal, { IDismissibleOptions } from '../components/Modal';
|
||||
*/
|
||||
type UnsafeModalClass = ComponentClass<any, Modal> & { get dismissibleOptions(): IDismissibleOptions; component: typeof Component.component };
|
||||
|
||||
/**
|
||||
* Alternatively, `show` takes an async function that returns a modal class.
|
||||
* This is useful for lazy-loading modals.
|
||||
*/
|
||||
type AsyncModalClass = () => Promise<any & { default: UnsafeModalClass }>;
|
||||
|
||||
type ModalItem = {
|
||||
componentClass: UnsafeModalClass;
|
||||
attrs?: Record<string, unknown>;
|
||||
@@ -37,6 +43,11 @@ export default class ModalManagerState {
|
||||
*/
|
||||
backdropShown: boolean = false;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
loadingModal: boolean = false;
|
||||
|
||||
/**
|
||||
* Used to force re-initialization of modals if a modal
|
||||
* is replaced by another of the same type.
|
||||
@@ -61,14 +72,24 @@ export default class ModalManagerState {
|
||||
* @example <caption>Stacking modals</caption>
|
||||
* app.modal.show(MyCoolStackedModal, { attr: 'value' }, true);
|
||||
*/
|
||||
show(componentClass: UnsafeModalClass, attrs: Record<string, unknown> = {}, stackModal: boolean = false): void {
|
||||
if (!(componentClass.prototype instanceof Modal)) {
|
||||
async show(componentClass: UnsafeModalClass | AsyncModalClass, attrs: Record<string, unknown> = {}, stackModal: boolean = false): Promise<void> {
|
||||
if (!(componentClass.prototype instanceof Modal) && typeof componentClass !== 'function') {
|
||||
// This is duplicated so that if the error is caught, an error message still shows up in the debug console.
|
||||
const invalidModalWarning = 'The ModalManager can only show Modals.';
|
||||
console.error(invalidModalWarning);
|
||||
throw new Error(invalidModalWarning);
|
||||
}
|
||||
|
||||
if (!(componentClass.prototype instanceof Modal)) {
|
||||
this.loadingModal = true;
|
||||
m.redraw.sync();
|
||||
|
||||
componentClass = componentClass as AsyncModalClass;
|
||||
componentClass = (await componentClass()).default;
|
||||
|
||||
this.loadingModal = false;
|
||||
}
|
||||
|
||||
this.backdropShown = true;
|
||||
m.redraw.sync();
|
||||
|
||||
@@ -79,6 +100,8 @@ export default class ModalManagerState {
|
||||
// skip this RAF call, the hook will attempt to add a focus trap as well as lock scroll
|
||||
// onto the newly added modal before it's in the DOM, creating an extra scrollbar.
|
||||
requestAnimationFrame(() => {
|
||||
componentClass = componentClass as UnsafeModalClass;
|
||||
|
||||
// Set current modal
|
||||
this.modal = { componentClass, attrs, key: this.key++ };
|
||||
|
||||
|
@@ -3,10 +3,8 @@ import app from './app';
|
||||
import History from './utils/History';
|
||||
import Pane from './utils/Pane';
|
||||
import DiscussionPage from './components/DiscussionPage';
|
||||
import SignUpModal from './components/SignUpModal';
|
||||
import HeaderPrimary from './components/HeaderPrimary';
|
||||
import HeaderSecondary from './components/HeaderSecondary';
|
||||
import Composer from './components/Composer';
|
||||
import DiscussionRenamedNotification from './components/DiscussionRenamedNotification';
|
||||
import CommentPost from './components/CommentPost';
|
||||
import DiscussionRenamedPost from './components/DiscussionRenamedPost';
|
||||
@@ -119,7 +117,6 @@ export default class ForumApplication extends Application {
|
||||
m.mount(document.getElementById('header-navigation')!, Navigation);
|
||||
m.mount(document.getElementById('header-primary')!, HeaderPrimary);
|
||||
m.mount(document.getElementById('header-secondary')!, HeaderSecondary);
|
||||
m.mount(document.getElementById('composer')!, { view: () => <Composer state={this.composer} /> });
|
||||
|
||||
alertEmailConfirmation(this);
|
||||
|
||||
@@ -164,7 +161,7 @@ export default class ForumApplication extends Application {
|
||||
if (payload.loggedIn) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
this.modal.show(SignUpModal, payload);
|
||||
this.modal.show(() => import('./components/SignUpModal'), payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -4,7 +4,6 @@ import classList from '../../common/utils/classList';
|
||||
import PostUser from './PostUser';
|
||||
import PostMeta from './PostMeta';
|
||||
import PostEdited from './PostEdited';
|
||||
import EditPostComposer from './EditPostComposer';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import Button from '../../common/components/Button';
|
||||
@@ -88,6 +87,10 @@ export default class CommentPost extends Post {
|
||||
}
|
||||
|
||||
isEditing() {
|
||||
const EditPostComposer = flarum.reg.checkModule('core', 'forum/components/EditPostComposer');
|
||||
|
||||
if (!EditPostComposer) return false;
|
||||
|
||||
return app.composer.bodyMatches(EditPostComposer, { post: this.attrs.post });
|
||||
}
|
||||
|
||||
|
@@ -5,8 +5,6 @@ import Page, { IPageAttrs } from '../../common/components/Page';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import DiscussionHero from './DiscussionHero';
|
||||
import DiscussionListPane from './DiscussionListPane';
|
||||
import PostStream from './PostStream';
|
||||
import PostStreamScrubber from './PostStreamScrubber';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import SplitDropdown from '../../common/components/SplitDropdown';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
@@ -26,6 +24,11 @@ export interface IDiscussionPageAttrs extends IPageAttrs {
|
||||
* the discussion list pane, the hero, the posts, and the sidebar.
|
||||
*/
|
||||
export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = IDiscussionPageAttrs> extends Page<CustomAttrs> {
|
||||
protected loading: boolean = true;
|
||||
|
||||
protected PostStream: any = null;
|
||||
protected PostStreamScrubber: any = null;
|
||||
|
||||
/**
|
||||
* The discussion that is being viewed.
|
||||
*/
|
||||
@@ -83,7 +86,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
||||
return (
|
||||
<div className="DiscussionPage">
|
||||
<DiscussionListPane state={app.discussions} />
|
||||
<div className="DiscussionPage-discussion">{this.discussion ? this.pageContent().toArray() : this.loadingItems().toArray()}</div>
|
||||
<div className="DiscussionPage-discussion">{!this.loading ? this.pageContent().toArray() : this.loadingItems().toArray()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -140,7 +143,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
||||
items.add(
|
||||
'poststream',
|
||||
<div className="DiscussionPage-stream">
|
||||
<PostStream discussion={this.discussion} stream={this.stream} onPositionChange={this.positionChanged.bind(this)} />
|
||||
<this.PostStream discussion={this.discussion} stream={this.stream} onPositionChange={this.positionChanged.bind(this)} />
|
||||
</div>,
|
||||
10
|
||||
);
|
||||
@@ -152,18 +155,23 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
||||
* Load the discussion from the API or use the preloaded one.
|
||||
*/
|
||||
load(): void {
|
||||
const preloadedDiscussion = app.preloadedApiDocument<Discussion>();
|
||||
if (preloadedDiscussion) {
|
||||
// We must wrap this in a setTimeout because if we are mounting this
|
||||
// component for the first time on page load, then any calls to m.redraw
|
||||
// will be ineffective and thus any configs (scroll code) will be run
|
||||
// before stuff is drawn to the page.
|
||||
setTimeout(this.show.bind(this, preloadedDiscussion), 0);
|
||||
} else {
|
||||
const params = this.requestParams();
|
||||
Promise.all([import('./PostStream'), import('./PostStreamScrubber')]).then(([PostStreamImport, PostStreamScrubberImport]) => {
|
||||
this.PostStream = PostStreamImport.default;
|
||||
this.PostStreamScrubber = PostStreamScrubberImport.default;
|
||||
|
||||
app.store.find<Discussion>('discussions', m.route.param('id'), params).then(this.show.bind(this));
|
||||
}
|
||||
const preloadedDiscussion = app.preloadedApiDocument<Discussion>();
|
||||
if (preloadedDiscussion) {
|
||||
// We must wrap this in a setTimeout because if we are mounting this
|
||||
// component for the first time on page load, then any calls to m.redraw
|
||||
// will be ineffective and thus any configs (scroll code) will be run
|
||||
// before stuff is drawn to the page.
|
||||
setTimeout(this.show.bind(this, preloadedDiscussion), 0);
|
||||
} else {
|
||||
const params = this.requestParams();
|
||||
|
||||
app.store.find<Discussion>('discussions', m.route.param('id'), params).then(this.show.bind(this));
|
||||
}
|
||||
});
|
||||
|
||||
m.redraw();
|
||||
}
|
||||
@@ -183,6 +191,8 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
||||
* Initialize the component to display the given discussion.
|
||||
*/
|
||||
show(discussion: ApiResponseSingle<Discussion>): void {
|
||||
this.loading = false;
|
||||
|
||||
app.history.push('discussion', discussion.title());
|
||||
app.setTitle(discussion.title());
|
||||
app.setTitleCount(0);
|
||||
@@ -249,7 +259,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
||||
);
|
||||
}
|
||||
|
||||
items.add('scrubber', <PostStreamScrubber stream={this.stream} className="App-titleControl" />, -100);
|
||||
items.add('scrubber', <this.PostStreamScrubber stream={this.stream} className="App-titleControl" />, -100);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
@@ -1,8 +1,6 @@
|
||||
import app from '../../forum/app';
|
||||
import Component from '../../common/Component';
|
||||
import Button from '../../common/components/Button';
|
||||
import LogInModal from './LogInModal';
|
||||
import SignUpModal from './SignUpModal';
|
||||
import SessionDropdown from './SessionDropdown';
|
||||
import SelectDropdown from '../../common/components/SelectDropdown';
|
||||
import NotificationsDropdown from './NotificationsDropdown';
|
||||
@@ -71,7 +69,7 @@ export default class HeaderSecondary extends Component {
|
||||
if (app.forum.attribute('allowSignUp')) {
|
||||
items.add(
|
||||
'signUp',
|
||||
<Button className="Button Button--link" onclick={() => app.modal.show(SignUpModal)}>
|
||||
<Button className="Button Button--link" onclick={() => app.modal.show(() => import('./SignUpModal'))}>
|
||||
{app.translator.trans('core.forum.header.sign_up_link')}
|
||||
</Button>,
|
||||
10
|
||||
@@ -80,7 +78,7 @@ export default class HeaderSecondary extends Component {
|
||||
|
||||
items.add(
|
||||
'logIn',
|
||||
<Button className="Button Button--link" onclick={() => app.modal.show(LogInModal)}>
|
||||
<Button className="Button Button--link" onclick={() => app.modal.show(() => import('./LogInModal'))}>
|
||||
{app.translator.trans('core.forum.header.log_in_link')}
|
||||
</Button>,
|
||||
0
|
||||
|
@@ -4,8 +4,6 @@ import ItemList from '../../common/utils/ItemList';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import DiscussionList from './DiscussionList';
|
||||
import WelcomeHero from './WelcomeHero';
|
||||
import DiscussionComposer from './DiscussionComposer';
|
||||
import LogInModal from './LogInModal';
|
||||
import DiscussionPage from './DiscussionPage';
|
||||
import Dropdown from '../../common/components/Dropdown';
|
||||
import Button from '../../common/components/Button';
|
||||
@@ -284,12 +282,11 @@ export default class IndexPage<CustomAttrs extends IIndexPageAttrs = IIndexPageA
|
||||
newDiscussionAction(): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (app.session.user) {
|
||||
app.composer.load(DiscussionComposer, { user: app.session.user });
|
||||
app.composer.show();
|
||||
app.composer.load(() => import('./DiscussionComposer'), { user: app.session.user }).then(() => app.composer.show());
|
||||
|
||||
return resolve(app.composer);
|
||||
} else {
|
||||
app.modal.show(LogInModal);
|
||||
app.modal.show(() => import('./LogInModal'));
|
||||
|
||||
return reject();
|
||||
}
|
||||
|
@@ -1,7 +1,5 @@
|
||||
import app from '../../forum/app';
|
||||
import Modal, { IInternalModalAttrs } from '../../common/components/Modal';
|
||||
import ForgotPasswordModal from './ForgotPasswordModal';
|
||||
import SignUpModal from './SignUpModal';
|
||||
import Button from '../../common/components/Button';
|
||||
import LogInButtons from './LogInButtons';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
@@ -141,7 +139,7 @@ export default class LogInModal<CustomAttrs extends ILoginModalAttrs = ILoginMod
|
||||
const email = this.identification();
|
||||
const attrs = email.includes('@') ? { email } : undefined;
|
||||
|
||||
app.modal.show(ForgotPasswordModal, attrs);
|
||||
app.modal.show(() => import('./ForgotPasswordModal'), attrs);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,7 +153,7 @@ export default class LogInModal<CustomAttrs extends ILoginModalAttrs = ILoginMod
|
||||
[identification.includes('@') ? 'email' : 'username']: identification,
|
||||
};
|
||||
|
||||
app.modal.show(SignUpModal, attrs);
|
||||
app.modal.show(() => import('./SignUpModal'), attrs);
|
||||
}
|
||||
|
||||
onready() {
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import app from '../../forum/app';
|
||||
import Component from '../../common/Component';
|
||||
import ScrollListener from '../../common/utils/ScrollListener';
|
||||
import PostLoading from './LoadingPost';
|
||||
import LoadingPost from './LoadingPost';
|
||||
import ReplyPlaceholder from './ReplyPlaceholder';
|
||||
import Button from '../../common/components/Button';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
@@ -75,7 +75,7 @@ export default class PostStream extends Component {
|
||||
} else {
|
||||
attrs.key = 'post' + postIds[this.stream.visibleStart + i];
|
||||
|
||||
content = <PostLoading />;
|
||||
content = <LoadingPost />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@@ -1,6 +1,5 @@
|
||||
import app from '../../forum/app';
|
||||
import Modal, { IInternalModalAttrs } from '../../common/components/Modal';
|
||||
import LogInModal from './LogInModal';
|
||||
import Button from '../../common/components/Button';
|
||||
import LogInButtons from './LogInButtons';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
@@ -151,7 +150,7 @@ export default class SignUpModal<CustomAttrs extends ISignupModalAttrs = ISignup
|
||||
identification: this.email() || this.username(),
|
||||
};
|
||||
|
||||
app.modal.show(LogInModal, attrs);
|
||||
app.modal.show(() => import('./LogInModal'), attrs);
|
||||
}
|
||||
|
||||
onready() {
|
||||
|
@@ -8,7 +8,6 @@ import AccessTokensList from './AccessTokensList';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import Button from '../../common/components/Button';
|
||||
import NewAccessTokenModal from './NewAccessTokenModal';
|
||||
import { camelCaseToSnakeCase } from '../../common/utils/string';
|
||||
import type AccessToken from '../../common/models/AccessToken';
|
||||
import type Mithril from 'mithril';
|
||||
import Tooltip from '../../common/components/Tooltip';
|
||||
|
@@ -14,61 +14,40 @@ import './states/GlobalSearchState';
|
||||
import './states/NotificationListState';
|
||||
import './states/PostStreamState';
|
||||
import './states/SearchState';
|
||||
import './states/UserSecurityPageState';
|
||||
import './components/AffixedSidebar';
|
||||
import './components/DiscussionPage';
|
||||
import './components/DiscussionListPane';
|
||||
import './components/LogInModal';
|
||||
import './components/ComposerBody';
|
||||
import './components/ForgotPasswordModal';
|
||||
import './components/Notification';
|
||||
import './components/LogInButton';
|
||||
import './components/DiscussionsUserPage';
|
||||
import './components/Composer';
|
||||
import './components/SessionDropdown';
|
||||
import './components/HeaderPrimary';
|
||||
import './components/PostEdited';
|
||||
import './components/PostStream';
|
||||
import './components/ChangePasswordModal';
|
||||
import './components/IndexPage';
|
||||
import './components/DiscussionRenamedNotification';
|
||||
import './components/DiscussionsSearchSource';
|
||||
import './components/HeaderSecondary';
|
||||
import './components/ComposerButton';
|
||||
import './components/DiscussionList';
|
||||
import './components/ReplyPlaceholder';
|
||||
import './components/AvatarEditor';
|
||||
import './components/Post';
|
||||
import './components/SettingsPage';
|
||||
import './components/TerminalPost';
|
||||
import './components/ChangeEmailModal';
|
||||
import './components/NotificationsDropdown';
|
||||
import './components/UserPage';
|
||||
import './components/PostUser';
|
||||
import './components/UserCard';
|
||||
import './components/UsersSearchSource';
|
||||
import './components/UserSecurityPage';
|
||||
import './components/NotificationGrid';
|
||||
import './components/PostPreview';
|
||||
import './components/EventPost';
|
||||
import './components/DiscussionHero';
|
||||
import './components/PostMeta';
|
||||
import './components/DiscussionRenamedPost';
|
||||
import './components/DiscussionComposer';
|
||||
import './components/LogInButtons';
|
||||
import './components/NotificationList';
|
||||
import './components/WelcomeHero';
|
||||
import './components/SignUpModal';
|
||||
import './components/CommentPost';
|
||||
import './components/ComposerPostPreview';
|
||||
import './components/ReplyComposer';
|
||||
import './components/NotificationsPage';
|
||||
import './components/PostStreamScrubber';
|
||||
import './components/EditPostComposer';
|
||||
import './components/RenameDiscussionModal';
|
||||
import './components/Search';
|
||||
import './components/DiscussionListItem';
|
||||
import './components/LoadingPost';
|
||||
import './components/PostsUserPage';
|
||||
import './resolvers/DiscussionPageResolver';
|
||||
import './routes';
|
||||
|
@@ -2,14 +2,10 @@ import ForumApplication from './ForumApplication';
|
||||
import IndexPage from './components/IndexPage';
|
||||
import DiscussionPage from './components/DiscussionPage';
|
||||
import PostsUserPage from './components/PostsUserPage';
|
||||
import DiscussionsUserPage from './components/DiscussionsUserPage';
|
||||
import SettingsPage from './components/SettingsPage';
|
||||
import NotificationsPage from './components/NotificationsPage';
|
||||
import DiscussionPageResolver from './resolvers/DiscussionPageResolver';
|
||||
import Discussion from '../common/models/Discussion';
|
||||
import type Post from '../common/models/Post';
|
||||
import type User from '../common/models/User';
|
||||
import UserSecurityPage from './components/UserSecurityPage';
|
||||
|
||||
/**
|
||||
* Helper functions to generate URLs to form pages.
|
||||
@@ -32,11 +28,11 @@ export default function (app: ForumApplication) {
|
||||
|
||||
user: { path: '/u/:username', component: PostsUserPage },
|
||||
'user.posts': { path: '/u/:username', component: PostsUserPage },
|
||||
'user.discussions': { path: '/u/:username/discussions', component: DiscussionsUserPage },
|
||||
'user.discussions': { path: '/u/:username/discussions', component: () => import('./components/DiscussionsUserPage') },
|
||||
|
||||
settings: { path: '/settings', component: SettingsPage },
|
||||
'user.security': { path: '/u/:username/security', component: UserSecurityPage },
|
||||
notifications: { path: '/notifications', component: NotificationsPage },
|
||||
settings: { path: '/settings', component: () => import('./components/SettingsPage') },
|
||||
'user.security': { path: '/u/:username/security', component: () => import('./components/UserSecurityPage') },
|
||||
notifications: { path: '/notifications', component: () => import('./components/NotificationsPage') },
|
||||
};
|
||||
}
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import app from '../../forum/app';
|
||||
import subclassOf from '../../common/utils/subclassOf';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import ReplyComposer from '../components/ReplyComposer';
|
||||
import Component from '../../common/Component';
|
||||
|
||||
class ComposerState {
|
||||
constructor() {
|
||||
@@ -34,15 +34,27 @@ class ComposerState {
|
||||
*/
|
||||
this.editor = null;
|
||||
|
||||
/**
|
||||
* If the composer was loaded and mounted.
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.mounted = false;
|
||||
|
||||
this.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a content component into the composer.
|
||||
*
|
||||
* @param {typeof import('../components/ComposerBody').default} componentClass
|
||||
* @param {() => Promise<any & { default: typeof import('../components/ComposerBody') }> | typeof import('../components/ComposerBody').default} componentClass
|
||||
* @param {object} attrs
|
||||
*/
|
||||
load(componentClass, attrs) {
|
||||
async load(componentClass, attrs) {
|
||||
if (!(componentClass.prototype instanceof Component)) {
|
||||
componentClass = (await componentClass()).default;
|
||||
}
|
||||
|
||||
const body = { componentClass, attrs };
|
||||
|
||||
if (this.preventExit()) return;
|
||||
@@ -81,7 +93,13 @@ class ComposerState {
|
||||
/**
|
||||
* Show the composer.
|
||||
*/
|
||||
show() {
|
||||
async show() {
|
||||
if (!this.mounted) {
|
||||
const Composer = (await import('../components/Composer')).default;
|
||||
m.mount(document.getElementById('composer'), { view: () => <Composer state={this} /> });
|
||||
this.mounted = true;
|
||||
}
|
||||
|
||||
if (this.position === ComposerState.Position.NORMAL || this.position === ComposerState.Position.FULLSCREEN) return;
|
||||
|
||||
this.position = ComposerState.Position.NORMAL;
|
||||
@@ -185,6 +203,10 @@ class ComposerState {
|
||||
* @return {boolean}
|
||||
*/
|
||||
composingReplyTo(discussion) {
|
||||
const ReplyComposer = flarum.reg.checkModule('core', 'forum/components/ReplyComposer');
|
||||
|
||||
if (!ReplyComposer) return false;
|
||||
|
||||
return this.isVisible() && this.bodyMatches(ReplyComposer, { discussion });
|
||||
}
|
||||
|
||||
|
@@ -1,7 +1,5 @@
|
||||
import app from '../../forum/app';
|
||||
import DiscussionPage from '../components/DiscussionPage';
|
||||
import ReplyComposer from '../components/ReplyComposer';
|
||||
import LogInModal from '../components/LogInModal';
|
||||
import Button from '../../common/components/Button';
|
||||
import Separator from '../../common/components/Separator';
|
||||
import RenameDiscussionModal from '../components/RenameDiscussionModal';
|
||||
@@ -168,12 +166,15 @@ const DiscussionControls = {
|
||||
if (app.session.user) {
|
||||
if (this.canReply()) {
|
||||
if (!app.composer.composingReplyTo(this) || forceRefresh) {
|
||||
app.composer.load(ReplyComposer, {
|
||||
user: app.session.user,
|
||||
discussion: this,
|
||||
});
|
||||
app.composer
|
||||
.load(() => import('../components/ReplyComposer'), {
|
||||
user: app.session.user,
|
||||
discussion: this,
|
||||
})
|
||||
.then(() => app.composer.show());
|
||||
} else {
|
||||
app.composer.show();
|
||||
}
|
||||
app.composer.show();
|
||||
|
||||
if (goToLast && app.viewingDiscussion(this) && !app.composer.isFullScreen()) {
|
||||
app.current.get('stream').goToNumber('reply');
|
||||
@@ -185,7 +186,7 @@ const DiscussionControls = {
|
||||
}
|
||||
}
|
||||
|
||||
app.modal.show(LogInModal);
|
||||
app.modal.show(() => import('../components/LogInModal'));
|
||||
|
||||
return reject();
|
||||
});
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import app from '../../forum/app';
|
||||
import EditPostComposer from '../components/EditPostComposer';
|
||||
import Button from '../../common/components/Button';
|
||||
import Separator from '../../common/components/Separator';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
@@ -121,8 +120,7 @@ const PostControls = {
|
||||
*/
|
||||
editAction() {
|
||||
return new Promise((resolve) => {
|
||||
app.composer.load(EditPostComposer, { post: this });
|
||||
app.composer.show();
|
||||
app.composer.load(() => import('../components/EditPostComposer'), { post: this }).then(() => app.composer.show());
|
||||
|
||||
return resolve();
|
||||
});
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import app from '../../forum/app';
|
||||
import Button from '../../common/components/Button';
|
||||
import Separator from '../../common/components/Separator';
|
||||
import EditUserModal from '../../common/components/EditUserModal';
|
||||
import UserPage from '../components/UserPage';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
@@ -143,7 +142,7 @@ const UserControls = {
|
||||
* @param {import('../../common/models/User').default} user
|
||||
*/
|
||||
editAction(user) {
|
||||
app.modal.show(EditUserModal, { user });
|
||||
app.modal.show(() => import('../../common/components/EditUserModal'), { user });
|
||||
},
|
||||
};
|
||||
|
||||
|
Reference in New Issue
Block a user