diff --git a/js/src/admin/AdminApplication.js b/js/src/admin/AdminApplication.ts similarity index 100% rename from js/src/admin/AdminApplication.js rename to js/src/admin/AdminApplication.ts diff --git a/js/src/common/Application.js b/js/src/common/Application.tsx similarity index 58% rename from js/src/common/Application.js rename to js/src/common/Application.tsx index 65592ad08..3f22c3c38 100644 --- a/js/src/common/Application.js +++ b/js/src/common/Application.tsx @@ -26,6 +26,97 @@ import PageState from './states/PageState'; import ModalManagerState from './states/ModalManagerState'; import AlertManagerState from './states/AlertManagerState'; +import type DefaultResolver from './resolvers/DefaultResolver'; +import type Mithril from 'mithril'; +import type Component from './Component'; +import type { ComponentAttrs } from './Component'; + +export type FlarumScreens = 'phone' | 'tablet' | 'desktop' | 'desktop-hd'; + +export type FlarumGenericRoute = RouteItem< + Record, + Component<{ routeName: string; [key: string]: unknown }>, + Record +>; + +export interface FlarumRequestOptions extends Omit, 'extract'> { + errorHandler: (errorMessage: string) => void; + url: string; + // TODO: [Flarum 2.0] Remove deprecated option + /** + * Manipulate the response text before it is parsed into JSON. + * + * @deprecated Please use `modifyText` instead. + */ + extract: (responseText: string) => string; + /** + * Manipulate the response text before it is parsed into JSON. + * + * This overrides any `extract` method provided. + */ + modifyText: (responseText: string) => string; +} + +/** + * A valid route definition. + */ +export type RouteItem< + Attrs extends ComponentAttrs, + Comp extends Component, + RouteArgs extends Record = {} +> = { + /** + * The path for your route. + * + * This might be a specific URL path (e.g.,`/myPage`), or it might + * contain a variable used by a resolver (e.g., `/myPage/:id`). + * + * @see https://docs.flarum.org/extend/frontend-pages.html#route-resolvers-advanced + */ + path: `/${string}`; +} & ( + | { + /** + * The component to render when this route matches. + */ + component: { new (): Comp }; + /** + * A custom resolver class. + * + * This should be the class itself, and **not** an instance of the + * class. + */ + resolverClass?: { new (): DefaultResolver }; + } + | { + /** + * An instance of a route resolver. + */ + resolver: RouteResolver; + } +); + +export interface RouteResolver< + Attrs extends ComponentAttrs, + Comp extends Component, + RouteArgs extends Record = {} +> { + /** + * A method which selects which component to render based on + * conditional logic. + * + * Returns the component class, and **not** a Vnode or JSX + * expression. + */ + onmatch(this: this, args: RouteArgs, requestedPath: string, route: string): { new (): Comp }; + /** + * A function which renders the provided component. + * + * Returns a Mithril Vnode or other children. + */ + render(this: this, vnode: Mithril.Vnode): Mithril.Children; +} + /** * The `App` class provides a container for an application, as well as various * utilities for the rest of the app to use. @@ -33,11 +124,8 @@ import AlertManagerState from './states/AlertManagerState'; export default class Application { /** * The forum model for this application. - * - * @type {Forum} - * @public */ - forum = null; + forum!: Forum; /** * A map of routes, keyed by a unique route name. Each route is an object @@ -47,44 +135,31 @@ export default class Application { * - `component` The Mithril component to render when this route is active. * * @example - * app.routes.discussion = {path: '/d/:id', component: DiscussionPage.component()}; - * - * @type {Object} - * @public + * app.routes.discussion = { path: '/d/:id', component: DiscussionPage }; */ - routes = {}; + routes: Record = {}; /** * An ordered list of initializers to bootstrap the application. - * - * @type {ItemList} - * @public */ - initializers = new ItemList(); + initializers: ItemList<(app: this) => void> = new ItemList(); /** * The app's session. * - * @type {Session} - * @public + * Stores info about the current user. */ - session = null; + session!: Session; /** * The app's translator. - * - * @type {Translator} - * @public */ - translator = new Translator(); + translator: Translator = new Translator(); /** * The app's data store. - * - * @type {Store} - * @public */ - store = new Store({ + store: Store = new Store({ forums: Forum, users: User, discussions: Discussion, @@ -96,28 +171,13 @@ export default class Application { /** * A local cache that can be used to store data at the application level, so * that is persists between different routes. - * - * @type {Object} - * @public */ - cache = {}; + cache: Record = {}; /** * Whether or not the app has been booted. - * - * @type {Boolean} - * @public */ - booted = false; - - /** - * The key for an Alert that was shown as a result of an AJAX request error. - * If present, it will be dismissed on the next successful request. - * - * @type {int} - * @private - */ - requestErrorAlert = null; + booted: boolean = false; /** * The page the app is currently on. @@ -125,10 +185,8 @@ export default class Application { * This object holds information about the type of page we are currently * visiting, and sometimes additional arbitrary page state that may be * relevant to lower-level components. - * - * @type {PageState} */ - current = new PageState(null); + current: PageState = new PageState(null); /** * The page the app was on before the current page. @@ -136,33 +194,61 @@ export default class Application { * Once the application navigates to another page, the object previously * assigned to this.current will be moved to this.previous, while this.current * is re-initialized. - * - * @type {PageState} */ - previous = new PageState(null); + previous: PageState = new PageState(null); - /* + /** * An object that manages modal state. - * - * @type {ModalManagerState} */ - modal = new ModalManagerState(); + modal: ModalManagerState = new ModalManagerState(); /** * An object that manages the state of active alerts. - * - * @type {AlertManagerState} */ - alerts = new AlertManagerState(); + alerts: AlertManagerState = new AlertManagerState(); - data; + /** + * An object that manages the state of the navigation drawer. + */ + drawer!: Drawer; - title = ''; - titleCount = 0; + data!: { + apiDocument: Record | null; + locale: string; + locales: Record; + resources: Record[]; + session: { userId: number; csrfToken: string }; + [key: string]: unknown; + }; - initialRoute; + private _title: string = ''; + private _titleCount: number = 0; - load(payload) { + private set title(val: string) { + this._title = val; + } + + get title() { + return this._title; + } + + private set titleCount(val: number) { + this._titleCount = val; + } + + get titleCount() { + return this._titleCount; + } + + /** + * The key for an Alert that was shown as a result of an AJAX request error. + * If present, it will be dismissed on the next successful request. + */ + private requestErrorAlert: number | null = null; + + initialRoute!: string; + + load(payload: Application['data']) { this.data = payload; this.translator.setLocale(payload.locale); } @@ -182,7 +268,7 @@ export default class Application { } // TODO: This entire system needs a do-over for v2 - bootExtensions(extensions) { + bootExtensions(extensions: Record) { Object.keys(extensions).forEach((name) => { const extension = extensions[name]; @@ -197,44 +283,43 @@ export default class Application { }); } - mount(basePath = '') { + mount(basePath: string = '') { // An object with a callable view property is used in order to pass arguments to the component; see https://mithril.js.org/mount.html - m.mount(document.getElementById('modal'), { view: () => ModalManager.component({ state: this.modal }) }); - m.mount(document.getElementById('alerts'), { view: () => AlertManager.component({ state: this.alerts }) }); + m.mount(document.getElementById('modal')!, { view: () => ModalManager.component({ state: this.modal }) }); + m.mount(document.getElementById('alerts')!, { view: () => AlertManager.component({ state: this.alerts }) }); this.drawer = new Drawer(); - m.route(document.getElementById('content'), basePath + '/', mapRoutes(this.routes, basePath)); + m.route(document.getElementById('content')!, basePath + '/', mapRoutes(this.routes, basePath)); + + const appEl = document.getElementById('app')!; + const appHeaderEl = document.querySelector('.App-header')!; // Add a class to the body which indicates that the page has been scrolled // down. When this happens, we'll add classes to the header and app body // which will set the navbar's position to fixed. We don't want to always // have it fixed, as that could overlap with custom headers. - const scrollListener = new ScrollListener((top) => { - const $app = $('#app'); - const offset = $app.offset().top; + const scrollListener = new ScrollListener((top: number) => { + const offset = appEl.getBoundingClientRect().top + document.body.scrollTop; - $app.toggleClass('affix', top >= offset).toggleClass('scrolled', top > offset); - $('.App-header').toggleClass('navbar-fixed-top', top >= offset); + appEl.classList.toggle('affix', top >= offset); + appEl.classList.toggle('scrolled', top > offset); + + appHeaderEl.classList.toggle('navbar-fixed-top', top >= offset); }); scrollListener.start(); scrollListener.update(); - $(() => { - $('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch'); - }); + document.body.classList.add('ontouchstart' in window ? 'touch' : 'no-touch'); liveHumanTimes(); } /** * Get the API response document that has been preloaded into the application. - * - * @return {Object|null} - * @public */ - preloadedApiDocument() { + preloadedApiDocument(): Record | null { // If the URL has changed, the preloaded Api document is invalid. if (this.data.apiDocument && window.location.href === this.initialRoute) { const results = this.store.pushPayload(this.data.apiDocument); @@ -249,36 +334,33 @@ export default class Application { /** * Determine the current screen mode, based on our media queries. - * - * @returns {String} - one of "phone", "tablet", "desktop" or "desktop-hd" */ - screen() { + screen(): FlarumScreens { const styles = getComputedStyle(document.documentElement); - return styles.getPropertyValue('--flarum-screen'); + return styles.getPropertyValue('--flarum-screen') as ReturnType; } /** - * Set the of the page. + * Set the `<title>` of the page. * - * @param {String} title - * @public + * @param title New page title */ - setTitle(title) { + setTitle(title: string): void { this.title = title; this.updateTitle(); } /** - * Set a number to display in the <title> of the page. + * Set a number to display in the `<title>` of the page. * - * @param {Integer} count + * @param count Number to display in title */ - setTitleCount(count) { + setTitleCount(count: number): void { this.titleCount = count; this.updateTitle(); } - updateTitle() { + updateTitle(): void { const count = this.titleCount ? `(${this.titleCount}) ` : ''; const pageTitleWithSeparator = this.title && m.route.get() !== this.forum.attribute('basePath') + '/' ? this.title + ' - ' : ''; const title = this.forum.attribute('title'); @@ -289,46 +371,55 @@ export default class Application { * Make an AJAX request, handling any low-level errors that may occur. * * @see https://mithril.js.org/request.html - * @param {Object} options + * + * @param options * @return {Promise} - * @public */ - request(originalOptions) { - const options = Object.assign({}, originalOptions); + request<ResponseType>(originalOptions: FlarumRequestOptions<ResponseType>): Promise<ResponseType | string> { + const options = { ...originalOptions }; // Set some default options if they haven't been overridden. We want to // authenticate all requests with the session token. We also want all // requests to run asynchronously in the background, so that they don't // prevent redraws from occurring. - options.background = options.background || true; + options.background ||= true; - extend(options, 'config', (result, xhr) => xhr.setRequestHeader('X-CSRF-Token', this.session.csrfToken)); + extend(options, 'config', (_: undefined, xhr: XMLHttpRequest) => { + xhr.setRequestHeader('X-CSRF-Token', this.session.csrfToken!); + }); // If the method is something like PATCH or DELETE, which not all servers // and clients support, then we'll send it as a POST request with the // intended method specified in the X-HTTP-Method-Override header. - if (options.method !== 'GET' && options.method !== 'POST') { + if (options.method && !['GET', 'POST'].includes(options.method)) { const method = options.method; - extend(options, 'config', (result, xhr) => xhr.setRequestHeader('X-HTTP-Method-Override', method)); + + extend(options, 'config', (_: undefined, xhr: XMLHttpRequest) => { + xhr.setRequestHeader('X-HTTP-Method-Override', method); + }); + options.method = 'POST'; } // When we deserialize JSON data, if for some reason the server has provided // a dud response, we don't want the application to crash. We'll show an // error message to the user instead. - options.deserialize = options.deserialize || ((responseText) => responseText); - options.errorHandler = - options.errorHandler || - ((error) => { - throw error; - }); + // @ts-expect-error Typescript doesn't know we return promisified `ReturnType` OR `string`, + // so it errors due to Mithril's typings + options.deserialize ||= (responseText: string) => responseText; + + options.errorHandler ||= (error) => { + throw error; + }; // When extracting the data from the response, we can check the server // response code and show an error message to the user if something's gone // awry. - const original = options.extract; - options.extract = (xhr) => { + const original = options.modifyText || options.extract; + + // @ts-expect-error + options.extract = (xhr: XMLHttpRequest) => { let responseText; if (original) { @@ -340,7 +431,7 @@ export default class Application { const status = xhr.status; if (status < 200 || status > 299) { - throw new RequestError(status, responseText, options, xhr); + throw new RequestError(`${status}`, `${responseText}`, options, xhr); } if (xhr.getResponseHeader) { @@ -349,9 +440,10 @@ export default class Application { } try { + // @ts-expect-error return JSON.parse(responseText); } catch (e) { - throw new RequestError(500, responseText, options, xhr); + throw new RequestError('500', `${responseText}`, options, xhr); } }; @@ -366,9 +458,9 @@ export default class Application { switch (error.status) { case 422: - content = error.response.errors + content = (error.response.errors as Record<string, unknown>[]) .map((error) => [error.detail, <br />]) - .reduce((a, b) => a.concat(b), []) + .flat() .slice(0, -1); break; @@ -405,7 +497,7 @@ export default class Application { content, controls: isDebug && [ <Button className="Button Button--link" onclick={this.showDebug.bind(this, error, formattedError)}> - Debug + {app.translator.trans('core.lib.debug_button')} </Button>, ], }; @@ -432,38 +524,28 @@ export default class Application { ); } - /** - * @param {RequestError} error - * @param {string[]} [formattedError] - * @private - */ - showDebug(error, formattedError) { - this.alerts.dismiss(this.requestErrorAlert); + private showDebug(error: RequestError, formattedError?: string[]) { + if (this.requestErrorAlert !== null) this.alerts.dismiss(this.requestErrorAlert); this.modal.show(RequestErrorModal, { error, formattedError }); } /** * Construct a URL to the route with the given name. - * - * @param {String} name - * @param {Object} params - * @return {String} - * @public */ - route(name, params = {}) { + route(name: string, params: Record<string, unknown> = {}): string { const route = this.routes[name]; if (!route) throw new Error(`Route '${name}' does not exist`); - const url = route.path.replace(/:([^\/]+)/g, (m, key) => extract(params, key)); + const url = route.path.replace(/:([^\/]+)/g, (m, key) => `${extract(params, key)}`); // Remove falsy values in params to avoid having urls like '/?sort&q' for (const key in params) { if (params.hasOwnProperty(key) && !params[key]) delete params[key]; } - const queryString = m.buildQueryString(params); + const queryString = m.buildQueryString(params as any); const prefix = m.route.prefix === '' ? this.forum.attribute('basePath') : ''; return prefix + url + (queryString ? '?' + queryString : ''); diff --git a/js/src/common/resolvers/DefaultResolver.ts b/js/src/common/resolvers/DefaultResolver.ts index 4950ffe7f..68db9be78 100644 --- a/js/src/common/resolvers/DefaultResolver.ts +++ b/js/src/common/resolvers/DefaultResolver.ts @@ -1,16 +1,24 @@ import type Mithril from 'mithril'; +import type { RouteResolver } from '../Application'; +import type { default as Component, ComponentAttrs } from '../Component'; /** * Generates a route resolver for a given component. + * * In addition to regular route resolver functionality: * - It provide the current route name as an attr * - It sets a key on the component so a rerender will be triggered on route change. */ -export default class DefaultResolver { - component: Mithril.Component; +export default class DefaultResolver< + Attrs extends ComponentAttrs, + Comp extends Component<Attrs & { routeName: string }>, + RouteArgs extends Record<string, unknown> = {} +> implements RouteResolver<Attrs, Comp, RouteArgs> +{ + component: { new (): Comp }; routeName: string; - constructor(component, routeName) { + constructor(component: { new (): Comp }, routeName: string) { this.component = component; this.routeName = routeName; } @@ -20,22 +28,22 @@ export default class DefaultResolver { * rerender occurs. This method can be overriden in subclasses * to prevent rerenders on some route changes. */ - makeKey() { + makeKey(): string { return this.routeName + JSON.stringify(m.route.param()); } - makeAttrs(vnode) { + makeAttrs(vnode: Mithril.Vnode<Attrs, Comp>): Attrs & { routeName: string } { return { ...vnode.attrs, routeName: this.routeName, }; } - onmatch(args, requestedPath, route) { + onmatch(args: RouteArgs, requestedPath: string, route: string): { new (): Comp } { return this.component; } - render(vnode) { + render(vnode: Mithril.Vnode<Attrs, Comp>): Mithril.Children { return [{ ...vnode, attrs: this.makeAttrs(vnode), key: this.makeKey() }]; } } diff --git a/js/src/common/utils/ScrollListener.js b/js/src/common/utils/ScrollListener.js index c69f1c600..432fd553b 100644 --- a/js/src/common/utils/ScrollListener.js +++ b/js/src/common/utils/ScrollListener.js @@ -12,7 +12,7 @@ const later = */ export default class ScrollListener { /** - * @param {Function} callback The callback to run when the scroll position + * @param {(top: number) => void} callback The callback to run when the scroll position * changes. * @public */ diff --git a/js/src/common/utils/mapRoutes.js b/js/src/common/utils/mapRoutes.ts similarity index 66% rename from js/src/common/utils/mapRoutes.js rename to js/src/common/utils/mapRoutes.ts index 703beb137..ea46fb7b9 100644 --- a/js/src/common/utils/mapRoutes.js +++ b/js/src/common/utils/mapRoutes.ts @@ -1,3 +1,5 @@ +import type { FlarumGenericRoute, RouteResolver } from '../Application'; +import type Component from '../Component'; import DefaultResolver from '../resolvers/DefaultResolver'; /** @@ -6,12 +8,12 @@ import DefaultResolver from '../resolvers/DefaultResolver'; * to provide each route with the current route name. * * @see https://mithril.js.org/route.html#signature - * @param {Object} routes - * @param {String} [basePath] - * @return {Object} */ -export default function mapRoutes(routes, basePath = '') { - const map = {}; +export default function mapRoutes(routes: Record<string, FlarumGenericRoute>, basePath: string = '') { + const map: Record< + string, + RouteResolver<Record<string, unknown>, Component<{ routeName: string; [key: string]: unknown }>, Record<string, unknown>> + > = {}; for (const routeName in routes) { const route = routes[routeName]; @@ -19,7 +21,7 @@ export default function mapRoutes(routes, basePath = '') { if ('resolver' in route) { map[basePath + route.path] = route.resolver; } else if ('component' in route) { - const resolverClass = 'resolverClass' in route ? route.resolverClass : DefaultResolver; + const resolverClass = 'resolverClass' in route ? route.resolverClass! : DefaultResolver; map[basePath + route.path] = new resolverClass(route.component, routeName); } else { throw new Error(`Either a resolver or a component must be provided for the route [${routeName}]`); diff --git a/js/src/forum/ForumApplication.js b/js/src/forum/ForumApplication.ts similarity index 78% rename from js/src/forum/ForumApplication.js rename to js/src/forum/ForumApplication.ts index c7597994c..4ee9385aa 100644 --- a/js/src/forum/ForumApplication.js +++ b/js/src/forum/ForumApplication.ts @@ -1,4 +1,5 @@ import app from '../forum/app'; + import History from './utils/History'; import Pane from './utils/Pane'; import DiscussionPage from './components/DiscussionPage'; @@ -19,79 +20,62 @@ import DiscussionListState from './states/DiscussionListState'; import ComposerState from './states/ComposerState'; import isSafariMobile from './utils/isSafariMobile'; +import type Notification from './components/Notification'; +import type Post from './components/Post'; + export default class ForumApplication extends Application { /** * A map of notification types to their components. - * - * @type {Object} */ - notificationComponents = { + notificationComponents: Record<string, typeof Notification> = { discussionRenamed: DiscussionRenamedNotification, }; + /** * A map of post types to their components. - * - * @type {Object} */ - postComponents = { + postComponents: Record<string, typeof Post> = { comment: CommentPost, discussionRenamed: DiscussionRenamedPost, }; /** * An object which controls the state of the page's side pane. - * - * @type {Pane} */ - pane = null; - - /** - * An object which controls the state of the page's drawer. - * - * @type {Drawer} - */ - drawer = null; + pane: Pane | null = null; /** * The app's history stack, which keeps track of which routes the user visits * so that they can easily navigate back to the previous route. - * - * @type {History} */ - history = new History(); + history: History = new History(); /** * An object which controls the state of the user's notifications. - * - * @type {NotificationListState} */ - notifications = new NotificationListState(this); + notifications: NotificationListState = new NotificationListState(); - /* + /** * An object which stores previously searched queries and provides convenient * tools for retrieving and managing search values. - * - * @type {GlobalSearchState} */ - search = new GlobalSearchState(); + search: GlobalSearchState = new GlobalSearchState(); - /* + /** * An object which controls the state of the composer. */ - composer = new ComposerState(); + composer: ComposerState = new ComposerState(); + + /** + * An object which controls the state of the cached discussion list, which + * is used in the index page and the slideout pane. + */ + discussions: DiscussionListState = new DiscussionListState({}); constructor() { super(); routes(this); - - /** - * An object which controls the state of the cached discussion list, which - * is used in the index page and the slideout pane. - * - * @type {DiscussionListState} - */ - this.discussions = new DiscussionListState({}); } /** @@ -119,17 +103,17 @@ export default class ForumApplication extends Application { // We mount navigation and header components after the page, so components // like the back button can access the updated state when rendering. - m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) }); - 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.component({ state: this.composer }) }); + m.mount(document.getElementById('app-navigation')!, { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) }); + 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.component({ state: this.composer }) }); alertEmailConfirmation(this); // Route the home link back home when clicked. We do not want it to register // if the user is opening it in a new tab, however. - $('#home-link').click((e) => { + document.getElementById('home-link')!.addEventListener('click', (e) => { if (e.ctrlKey || e.metaKey || e.which === 2) return; e.preventDefault(); app.history.home(); diff --git a/js/src/forum/states/DiscussionListState.ts b/js/src/forum/states/DiscussionListState.ts index 6f687c83a..06390e55c 100644 --- a/js/src/forum/states/DiscussionListState.ts +++ b/js/src/forum/states/DiscussionListState.ts @@ -5,7 +5,7 @@ import Discussion from '../../common/models/Discussion'; export default class DiscussionListState extends PaginatedListState<Discussion> { protected extraDiscussions: Discussion[] = []; - constructor(params: any, page: number) { + constructor(params: any, page: number = 1) { super(params, page, 20); } diff --git a/locale/core.yml b/locale/core.yml index d68a39fce..1842a92b9 100644 --- a/locale/core.yml +++ b/locale/core.yml @@ -499,6 +499,7 @@ core: # Translations in this namespace are used by the forum and admin interfaces. lib: + debug_button: Debug # These translations are displayed as tooltips for discussion badges. badge: