mirror of
https://github.com/flarum/core.git
synced 2025-10-22 20:26:15 +02:00
Add typechecks, typescript coverage GH action, fix many type errors (#3136)
This commit is contained in:
committed by
GitHub
parent
563d40d7da
commit
bac0e594ee
7
js/src/@types/global.d.ts
vendored
7
js/src/@types/global.d.ts
vendored
@@ -98,3 +98,10 @@ interface JSX {
|
||||
attrs: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
interface Event {
|
||||
/**
|
||||
* Whether this event should trigger a Mithril redraw.
|
||||
*/
|
||||
redraw: boolean;
|
||||
}
|
||||
|
@@ -6,6 +6,32 @@ import Navigation from '../common/components/Navigation';
|
||||
import AdminNav from './components/AdminNav';
|
||||
import ExtensionData from './utils/ExtensionData';
|
||||
|
||||
export type Extension = {
|
||||
id: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
icon?: {
|
||||
name: string;
|
||||
};
|
||||
links: {
|
||||
authors?: {
|
||||
name?: string;
|
||||
link?: string;
|
||||
}[];
|
||||
discuss?: string;
|
||||
documentation?: string;
|
||||
support?: string;
|
||||
website?: string;
|
||||
donate?: string;
|
||||
source?: string;
|
||||
};
|
||||
extra: {
|
||||
'flarum-extension': {
|
||||
title: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export default class AdminApplication extends Application {
|
||||
extensionData = new ExtensionData();
|
||||
|
||||
@@ -24,6 +50,20 @@ export default class AdminApplication extends Application {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Settings are serialized to the admin dashboard as strings.
|
||||
* Additional encoding/decoding is possible, but must take
|
||||
* place on the client side.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
|
||||
data!: Application['data'] & {
|
||||
extensions: Record<string, Extension>;
|
||||
settings: Record<string, string>;
|
||||
modelStatistics: Record<string, { total: number }>;
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
@@ -41,20 +81,20 @@ export default class AdminApplication extends Application {
|
||||
m.route.prefix = '#';
|
||||
super.mount();
|
||||
|
||||
m.mount(document.getElementById('app-navigation'), {
|
||||
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('admin-navigation'), AdminNav);
|
||||
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('admin-navigation')!, AdminNav);
|
||||
}
|
||||
|
||||
getRequiredPermissions(permission) {
|
||||
getRequiredPermissions(permission: string) {
|
||||
const required = [];
|
||||
|
||||
if (permission === 'startDiscussion' || permission.indexOf('discussion.') === 0) {
|
||||
|
@@ -2,7 +2,7 @@ import Admin from './AdminApplication';
|
||||
|
||||
const app = new Admin();
|
||||
|
||||
// @ts-ignore
|
||||
// @ts-expect-error We need to do this for backwards compatibility purposes.
|
||||
window.app = app;
|
||||
|
||||
export default app;
|
||||
|
@@ -12,26 +12,39 @@ import ExtensionPermissionGrid from './ExtensionPermissionGrid';
|
||||
import isExtensionEnabled from '../utils/isExtensionEnabled';
|
||||
import AdminPage from './AdminPage';
|
||||
import ReadmeModal from './ReadmeModal';
|
||||
import RequestError from '../../common/utils/RequestError';
|
||||
import { Extension } from '../AdminApplication';
|
||||
import { IPageAttrs } from '../../common/components/Page';
|
||||
import type Mithril from 'mithril';
|
||||
|
||||
export default class ExtensionPage extends AdminPage {
|
||||
oninit(vnode) {
|
||||
export interface ExtensionPageAttrs extends IPageAttrs {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export default class ExtensionPage<Attrs extends ExtensionPageAttrs = ExtensionPageAttrs> extends AdminPage<Attrs> {
|
||||
extension!: Extension;
|
||||
|
||||
changingState = false;
|
||||
|
||||
infoFields = {
|
||||
discuss: 'fas fa-comment-alt',
|
||||
documentation: 'fas fa-book',
|
||||
support: 'fas fa-life-ring',
|
||||
website: 'fas fa-link',
|
||||
donate: 'fas fa-donate',
|
||||
source: 'fas fa-code',
|
||||
};
|
||||
|
||||
oninit(vnode: Mithril.Vnode<Attrs, this>) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.extension = app.data.extensions[this.attrs.id];
|
||||
this.changingState = false;
|
||||
const extension = app.data.extensions[this.attrs.id];
|
||||
|
||||
this.infoFields = {
|
||||
discuss: 'fas fa-comment-alt',
|
||||
documentation: 'fas fa-book',
|
||||
support: 'fas fa-life-ring',
|
||||
website: 'fas fa-link',
|
||||
donate: 'fas fa-donate',
|
||||
source: 'fas fa-code',
|
||||
};
|
||||
|
||||
if (!this.extension) {
|
||||
if (!extension) {
|
||||
return m.route.set(app.route('dashboard'));
|
||||
}
|
||||
|
||||
this.extension = extension;
|
||||
}
|
||||
|
||||
className() {
|
||||
@@ -40,7 +53,7 @@ export default class ExtensionPage extends AdminPage {
|
||||
return this.extension.id + '-Page';
|
||||
}
|
||||
|
||||
view() {
|
||||
view(vnode: Mithril.VnodeDOM<Attrs, this>) {
|
||||
if (!this.extension) return null;
|
||||
|
||||
return (
|
||||
@@ -51,7 +64,7 @@ export default class ExtensionPage extends AdminPage {
|
||||
<h3 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.enable_to_see')}</h3>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ExtensionPage-body">{this.sections().toArray()}</div>
|
||||
<div className="ExtensionPage-body">{this.sections(vnode).toArray()}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -92,10 +105,10 @@ export default class ExtensionPage extends AdminPage {
|
||||
];
|
||||
}
|
||||
|
||||
sections() {
|
||||
sections(vnode: Mithril.VnodeDOM<Attrs, this>) {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('content', this.content());
|
||||
items.add('content', this.content(vnode));
|
||||
|
||||
items.add('permissions', [
|
||||
<div className="ExtensionPage-permissions">
|
||||
@@ -117,7 +130,7 @@ export default class ExtensionPage extends AdminPage {
|
||||
return items;
|
||||
}
|
||||
|
||||
content() {
|
||||
content(vnode: Mithril.VnodeDOM<Attrs, this>) {
|
||||
const settings = app.extensionData.getSettings(this.extension.id);
|
||||
|
||||
return (
|
||||
@@ -126,7 +139,7 @@ export default class ExtensionPage extends AdminPage {
|
||||
{settings ? (
|
||||
<div className="Form">
|
||||
{settings.map(this.buildSettingComponent.bind(this))}
|
||||
<div className="Form-group">{this.submitButton()}</div>
|
||||
<div className="Form-group">{this.submitButton(vnode)}</div>
|
||||
</div>
|
||||
) : (
|
||||
<h3 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_settings')}</h3>
|
||||
@@ -172,21 +185,17 @@ export default class ExtensionPage extends AdminPage {
|
||||
|
||||
const links = this.extension.links;
|
||||
|
||||
if (links.authors.length) {
|
||||
let authors = [];
|
||||
|
||||
links.authors.map((author) => {
|
||||
authors.push(
|
||||
<Link href={author.link} external={true} target="_blank">
|
||||
{author.name}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
if (links.authors?.length) {
|
||||
const authors = links.authors.map((author) => (
|
||||
<Link href={author.link} external={true} target="_blank">
|
||||
{author.name}
|
||||
</Link>
|
||||
));
|
||||
|
||||
items.add('authors', [icon('fas fa-user'), <span>{punctuateSeries(authors)}</span>]);
|
||||
}
|
||||
|
||||
Object.keys(this.infoFields).map((field) => {
|
||||
(Object.keys(this.infoFields) as (keyof ExtensionPage['infoFields'])[]).map((field) => {
|
||||
if (links[field]) {
|
||||
items.add(
|
||||
field,
|
||||
@@ -240,7 +249,7 @@ export default class ExtensionPage extends AdminPage {
|
||||
return isExtensionEnabled(this.extension.id);
|
||||
}
|
||||
|
||||
onerror(e) {
|
||||
onerror(e: RequestError) {
|
||||
// We need to give the modal animation time to start; if we close the modal too early,
|
||||
// it breaks the bootstrap modal library.
|
||||
// TODO: This workaround should be removed when we move away from bootstrap JS for modals.
|
||||
@@ -254,14 +263,16 @@ export default class ExtensionPage extends AdminPage {
|
||||
throw e;
|
||||
}
|
||||
|
||||
const error = e.response.errors[0];
|
||||
const error = e.response?.errors?.[0];
|
||||
|
||||
app.alerts.show(
|
||||
{ type: 'error' },
|
||||
app.translator.trans(`core.lib.error.${error.code}_message`, {
|
||||
extension: error.extension,
|
||||
extensions: error.extensions.join(', '),
|
||||
})
|
||||
);
|
||||
if (error) {
|
||||
app.alerts.show(
|
||||
{ type: 'error' },
|
||||
app.translator.trans(`core.lib.error.${error.code}_message`, {
|
||||
extension: error.extension,
|
||||
extensions: (error.extensions as string[]).join(', '),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,11 +1,11 @@
|
||||
import app from '../../admin/app';
|
||||
import Modal from '../../common/components/Modal';
|
||||
|
||||
export default class LoadingModal extends Modal {
|
||||
export default class LoadingModal<ModalAttrs = {}> extends Modal<ModalAttrs> {
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
static isDismissible = false;
|
||||
static readonly isDismissible: boolean = false;
|
||||
|
||||
className() {
|
||||
return 'LoadingModal Modal--small';
|
||||
@@ -18,4 +18,8 @@ export default class LoadingModal extends Modal {
|
||||
content() {
|
||||
return '';
|
||||
}
|
||||
|
||||
onsubmit(e: Event): void {
|
||||
throw new Error('LoadingModal should not throw errors.');
|
||||
}
|
||||
}
|
@@ -8,6 +8,7 @@ export { app };
|
||||
import compatObj from './compat';
|
||||
import proxifyCompat from '../common/utils/proxifyCompat';
|
||||
|
||||
// @ts-expect-error The `app` instance needs to be available on compat.
|
||||
compatObj.app = app;
|
||||
|
||||
export const compat = proxifyCompat(compatObj, 'admin');
|
||||
|
@@ -1,14 +1,18 @@
|
||||
import app from '../../admin/app';
|
||||
import DefaultResolver from '../../common/resolvers/DefaultResolver';
|
||||
import ExtensionPage, { ExtensionPageAttrs } from '../components/ExtensionPage';
|
||||
|
||||
/**
|
||||
* A custom route resolver for ExtensionPage that generates handles routes
|
||||
* to default extension pages or a page provided by an extension.
|
||||
*/
|
||||
export default class ExtensionPageResolver extends DefaultResolver {
|
||||
export default class ExtensionPageResolver<
|
||||
Attrs extends ExtensionPageAttrs = ExtensionPageAttrs,
|
||||
RouteArgs extends Record<string, unknown> = {}
|
||||
> extends DefaultResolver<Attrs, ExtensionPage<Attrs>, RouteArgs> {
|
||||
static extension: string | null = null;
|
||||
|
||||
onmatch(args, requestedPath, route) {
|
||||
onmatch(args: Attrs & RouteArgs, requestedPath: string, route: string) {
|
||||
const extensionPage = app.extensionData.getPage(args.id);
|
||||
|
||||
if (extensionPage) {
|
||||
|
@@ -11,9 +11,10 @@ import Session from './Session';
|
||||
import extract from './utils/extract';
|
||||
import Drawer from './utils/Drawer';
|
||||
import mapRoutes from './utils/mapRoutes';
|
||||
import RequestError from './utils/RequestError';
|
||||
import RequestError, { InternalFlarumRequestOptions } from './utils/RequestError';
|
||||
import ScrollListener from './utils/ScrollListener';
|
||||
import liveHumanTimes from './utils/liveHumanTimes';
|
||||
// @ts-expect-error We need to explicitly use the prefix to distinguish between the extend folder.
|
||||
import { extend } from './extend.ts';
|
||||
|
||||
import Forum from './models/Forum';
|
||||
@@ -40,7 +41,7 @@ export type FlarumGenericRoute = RouteItem<
|
||||
>;
|
||||
|
||||
export interface FlarumRequestOptions<ResponseType> extends Omit<Mithril.RequestOptions<ResponseType>, 'extract'> {
|
||||
errorHandler: (errorMessage: string) => void;
|
||||
errorHandler?: (error: RequestError) => void;
|
||||
url: string;
|
||||
// TODO: [Flarum 2.0] Remove deprecated option
|
||||
/**
|
||||
@@ -48,13 +49,13 @@ export interface FlarumRequestOptions<ResponseType> extends Omit<Mithril.Request
|
||||
*
|
||||
* @deprecated Please use `modifyText` instead.
|
||||
*/
|
||||
extract: (responseText: string) => string;
|
||||
extract?: (responseText: string) => string;
|
||||
/**
|
||||
* Manipulate the response text before it is parsed into JSON.
|
||||
*
|
||||
* This overrides any `extract` method provided.
|
||||
*/
|
||||
modifyText: (responseText: string) => string;
|
||||
modifyText?: (responseText: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,12 +249,12 @@ export default class Application {
|
||||
|
||||
initialRoute!: string;
|
||||
|
||||
load(payload: Application['data']) {
|
||||
public load(payload: Application['data']) {
|
||||
this.data = payload;
|
||||
this.translator.setLocale(payload.locale);
|
||||
}
|
||||
|
||||
boot() {
|
||||
public boot() {
|
||||
this.initializers.toArray().forEach((initializer) => initializer(this));
|
||||
|
||||
this.store.pushPayload({ data: this.data.resources });
|
||||
@@ -268,7 +269,7 @@ export default class Application {
|
||||
}
|
||||
|
||||
// TODO: This entire system needs a do-over for v2
|
||||
bootExtensions(extensions: Record<string, { extend?: unknown[] }>) {
|
||||
public bootExtensions(extensions: Record<string, { extend?: unknown[] }>) {
|
||||
Object.keys(extensions).forEach((name) => {
|
||||
const extension = extensions[name];
|
||||
|
||||
@@ -278,12 +279,13 @@ export default class Application {
|
||||
const extenders = extension.extend.flat(Infinity);
|
||||
|
||||
for (const extender of extenders) {
|
||||
// @ts-expect-error This is beyond saving atm.
|
||||
extender.extend(this, { name, exports: extension });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
mount(basePath: string = '') {
|
||||
protected 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 }) });
|
||||
@@ -367,22 +369,36 @@ export default class Application {
|
||||
document.title = count + pageTitleWithSeparator + title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an AJAX request, handling any low-level errors that may occur.
|
||||
*
|
||||
* @see https://mithril.js.org/request.html
|
||||
*
|
||||
* @param options
|
||||
* @return {Promise}
|
||||
*/
|
||||
request<ResponseType>(originalOptions: FlarumRequestOptions<ResponseType>): Promise<ResponseType | string> {
|
||||
const options = { ...originalOptions };
|
||||
protected transformRequestOptions<ResponseType>(flarumOptions: FlarumRequestOptions<ResponseType>): InternalFlarumRequestOptions<ResponseType> {
|
||||
const { background, deserialize, errorHandler, extract, modifyText, ...tmpOptions } = { ...flarumOptions };
|
||||
|
||||
// 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 ||= true;
|
||||
// Unless specified otherwise, requests should run asynchronously in the
|
||||
// background, so that they don't prevent redraws from occurring.
|
||||
const defaultBackground = true;
|
||||
|
||||
// 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.
|
||||
|
||||
// @ts-expect-error Typescript doesn't know we return promisified `ReturnType` OR `string`,
|
||||
// so it errors due to Mithril's typings
|
||||
const defaultDeserialize = (response: string) => response as ResponseType;
|
||||
|
||||
const defaultErrorHandler = (error: RequestError) => {
|
||||
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 originalExtract = modifyText || extract;
|
||||
|
||||
const options: InternalFlarumRequestOptions<ResponseType> = {
|
||||
background: background ?? defaultBackground,
|
||||
deserialize: deserialize ?? defaultDeserialize,
|
||||
errorHandler: errorHandler ?? defaultErrorHandler,
|
||||
...tmpOptions,
|
||||
};
|
||||
|
||||
extend(options, 'config', (_: undefined, xhr: XMLHttpRequest) => {
|
||||
xhr.setRequestHeader('X-CSRF-Token', this.session.csrfToken!);
|
||||
@@ -401,37 +417,19 @@ export default class Application {
|
||||
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.
|
||||
|
||||
// @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.modifyText || options.extract;
|
||||
|
||||
// @ts-expect-error
|
||||
options.extract = (xhr: XMLHttpRequest) => {
|
||||
let responseText;
|
||||
|
||||
if (original) {
|
||||
responseText = original(xhr.responseText);
|
||||
if (originalExtract) {
|
||||
responseText = originalExtract(xhr.responseText);
|
||||
} else {
|
||||
responseText = xhr.responseText || null;
|
||||
responseText = xhr.responseText;
|
||||
}
|
||||
|
||||
const status = xhr.status;
|
||||
|
||||
if (status < 200 || status > 299) {
|
||||
throw new RequestError(`${status}`, `${responseText}`, options, xhr);
|
||||
throw new RequestError<ResponseType>(status, `${responseText}`, options, xhr);
|
||||
}
|
||||
|
||||
if (xhr.getResponseHeader) {
|
||||
@@ -440,25 +438,38 @@ export default class Application {
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-expect-error
|
||||
return JSON.parse(responseText);
|
||||
} catch (e) {
|
||||
throw new RequestError('500', `${responseText}`, options, xhr);
|
||||
throw new RequestError<ResponseType>(500, `${responseText}`, options, xhr);
|
||||
}
|
||||
};
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an AJAX request, handling any low-level errors that may occur.
|
||||
*
|
||||
* @see https://mithril.js.org/request.html
|
||||
*
|
||||
* @param options
|
||||
* @return {Promise}
|
||||
*/
|
||||
request<ResponseType>(originalOptions: FlarumRequestOptions<ResponseType>): Promise<ResponseType | string> {
|
||||
const options = this.transformRequestOptions(originalOptions);
|
||||
|
||||
if (this.requestErrorAlert) this.alerts.dismiss(this.requestErrorAlert);
|
||||
|
||||
// Now make the request. If it's a failure, inspect the error that was
|
||||
// returned and show an alert containing its contents.
|
||||
return m.request(options).then(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
(error: RequestError) => {
|
||||
let content;
|
||||
|
||||
switch (error.status) {
|
||||
case 422:
|
||||
content = (error.response.errors as Record<string, unknown>[])
|
||||
content = ((error.response?.errors ?? {}) as Record<string, unknown>[])
|
||||
.map((error) => [error.detail, <br />])
|
||||
.flat()
|
||||
.slice(0, -1);
|
||||
@@ -490,7 +501,7 @@ export default class Application {
|
||||
// contains a formatted errors if possible, response must be an JSON API array of errors
|
||||
// the details property is decoded to transform escaped characters such as '\n'
|
||||
const errors = error.response && error.response.errors;
|
||||
const formattedError = Array.isArray(errors) && errors[0] && errors[0].detail && errors.map((e) => decodeURI(e.detail));
|
||||
const formattedError = (Array.isArray(errors) && errors?.[0]?.detail && errors.map((e) => decodeURI(e.detail ?? ''))) || undefined;
|
||||
|
||||
error.alert = {
|
||||
type: 'error',
|
||||
@@ -505,18 +516,22 @@ export default class Application {
|
||||
try {
|
||||
options.errorHandler(error);
|
||||
} catch (error) {
|
||||
if (isDebug && error.xhr) {
|
||||
const { method, url } = error.options;
|
||||
const { status = '' } = error.xhr;
|
||||
if (error instanceof RequestError) {
|
||||
if (isDebug && error.xhr) {
|
||||
const { method, url } = error.options;
|
||||
const { status = '' } = error.xhr;
|
||||
|
||||
console.group(`${method} ${url} ${status}`);
|
||||
console.group(`${method} ${url} ${status}`);
|
||||
|
||||
console.error(...(formattedError || [error]));
|
||||
console.error(...(formattedError || [error]));
|
||||
|
||||
console.groupEnd();
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
this.requestErrorAlert = this.alerts.show(error.alert, error.alert.content);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.requestErrorAlert = this.alerts.show(error.alert, error.alert.content);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
|
@@ -121,7 +121,7 @@ export default abstract class Component<Attrs extends ComponentAttrs = Component
|
||||
*
|
||||
* @see https://mithril.js.org/hyperscript.html#mselector,-attributes,-children
|
||||
*/
|
||||
static component(attrs: Attrs = {}, children: Mithril.Children = null): Mithril.Vnode {
|
||||
static component<SAttrs extends ComponentAttrs = ComponentAttrs>(attrs: SAttrs = {} as SAttrs, children: Mithril.Children = null): Mithril.Vnode {
|
||||
const componentAttrs = { ...attrs };
|
||||
|
||||
return m(this as any, componentAttrs, children);
|
||||
|
@@ -34,8 +34,8 @@ export default abstract class Fragment {
|
||||
* @returns {jQuery} the jQuery object for the DOM node
|
||||
* @final
|
||||
*/
|
||||
public $(selector) {
|
||||
const $element = $(this.element);
|
||||
public $(selector?: string): JQuery {
|
||||
const $element = $(this.element) as JQuery<HTMLElement>;
|
||||
|
||||
return selector ? $element.find(selector) : $element;
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { RichMessageFormatter, mithrilRichHandler } from '@askvortsov/rich-icu-message-formatter';
|
||||
import { pluralTypeHandler, selectTypeHandler } from '@ultraq/icu-message-formatter';
|
||||
import username from './helpers/username';
|
||||
import User from './models/User';
|
||||
import extract from './utils/extract';
|
||||
|
||||
type Translations = Record<string, string>;
|
||||
@@ -50,7 +51,7 @@ export default class Translator {
|
||||
// translation key is used.
|
||||
|
||||
if ('user' in parameters) {
|
||||
const user = extract(parameters, 'user');
|
||||
const user = extract(parameters, 'user') as User;
|
||||
|
||||
if (!parameters.username) parameters.username = username(user);
|
||||
}
|
||||
|
@@ -20,21 +20,21 @@ export interface AlertAttrs extends ComponentAttrs {
|
||||
* some controls, and may be dismissible.
|
||||
*/
|
||||
export default class Alert<T extends AlertAttrs = AlertAttrs> extends Component<T> {
|
||||
view(vnode: Mithril.Vnode) {
|
||||
view(vnode: Mithril.VnodeDOM<T, this>) {
|
||||
const attrs = Object.assign({}, this.attrs);
|
||||
|
||||
const type = extract(attrs, 'type');
|
||||
attrs.className = 'Alert Alert--' + type + ' ' + (attrs.className || '');
|
||||
|
||||
const content = extract(attrs, 'content') || vnode.children;
|
||||
const controls = (extract(attrs, 'controls') || []) as Mithril.ChildArray;
|
||||
const controls = (extract(attrs, 'controls') || []) as Mithril.Vnode[];
|
||||
|
||||
// If the alert is meant to be dismissible (which is the case by default),
|
||||
// then we will create a dismiss button to append as the final control in
|
||||
// the alert.
|
||||
const dismissible = extract(attrs, 'dismissible');
|
||||
const ondismiss = extract(attrs, 'ondismiss');
|
||||
const dismissControl = [];
|
||||
const dismissControl: Mithril.Vnode[] = [];
|
||||
|
||||
if (dismissible || dismissible === undefined) {
|
||||
dismissControl.push(<Button icon="fas fa-times" className="Button Button--link Button--icon Alert-dismiss" onclick={ondismiss} />);
|
||||
|
@@ -67,7 +67,7 @@ export interface IButtonAttrs extends ComponentAttrs {
|
||||
* styles can be applied by providing `className="Button"` to the Button component.
|
||||
*/
|
||||
export default class Button<CustomAttrs extends IButtonAttrs = IButtonAttrs> extends Component<CustomAttrs> {
|
||||
view(vnode: Mithril.Vnode<IButtonAttrs, never>) {
|
||||
view(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
|
||||
let { type, title, 'aria-label': ariaLabel, icon: iconName, disabled, loading, className, class: _class, ...attrs } = this.attrs;
|
||||
|
||||
// If no `type` attr provided, set to "button"
|
||||
@@ -102,7 +102,7 @@ export default class Button<CustomAttrs extends IButtonAttrs = IButtonAttrs> ext
|
||||
return <button {...buttonAttrs}>{this.getButtonContent(vnode.children)}</button>;
|
||||
}
|
||||
|
||||
oncreate(vnode: Mithril.VnodeDOM<IButtonAttrs, this>) {
|
||||
oncreate(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
const { 'aria-label': ariaLabel } = this.attrs;
|
||||
|
@@ -22,7 +22,7 @@ export default abstract class Modal<ModalAttrs = {}> extends Component<ModalAttr
|
||||
/**
|
||||
* Determine whether or not the modal should be dismissible via an 'x' button.
|
||||
*/
|
||||
static readonly isDismissible = true;
|
||||
static readonly isDismissible: boolean = true;
|
||||
|
||||
protected loading: boolean = false;
|
||||
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import type Mithril from 'mithril';
|
||||
import app from '../app';
|
||||
import Component from '../Component';
|
||||
import PageState from '../states/PageState';
|
||||
@@ -13,7 +14,22 @@ export interface IPageAttrs {
|
||||
* @abstract
|
||||
*/
|
||||
export default abstract class Page<CustomAttrs extends IPageAttrs = IPageAttrs> extends Component<CustomAttrs> {
|
||||
oninit(vnode) {
|
||||
/**
|
||||
* A class name to apply to the body while the route is active.
|
||||
*/
|
||||
protected bodyClass = '';
|
||||
|
||||
/**
|
||||
* Whether we should scroll to the top of the page when its rendered.
|
||||
*/
|
||||
protected scrollTopOnCreate = true;
|
||||
|
||||
/**
|
||||
* Whether the browser should restore scroll state on refreshes.
|
||||
*/
|
||||
protected useBrowserScrollRestoration = true;
|
||||
|
||||
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
|
||||
super.oninit(vnode);
|
||||
|
||||
app.previous = app.current;
|
||||
@@ -21,30 +37,9 @@ export default abstract class Page<CustomAttrs extends IPageAttrs = IPageAttrs>
|
||||
|
||||
app.drawer.hide();
|
||||
app.modal.close();
|
||||
|
||||
/**
|
||||
* A class name to apply to the body while the route is active.
|
||||
*
|
||||
* @type {String}
|
||||
*/
|
||||
this.bodyClass = '';
|
||||
|
||||
/**
|
||||
* Whether we should scroll to the top of the page when its rendered.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.scrollTopOnCreate = true;
|
||||
|
||||
/**
|
||||
* Whether the browser should restore scroll state on refreshes.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.useBrowserScrollRestoration = true;
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
oncreate(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
if (this.bodyClass) {
|
||||
@@ -60,7 +55,7 @@ export default abstract class Page<CustomAttrs extends IPageAttrs = IPageAttrs>
|
||||
}
|
||||
}
|
||||
|
||||
onremove(vnode) {
|
||||
onremove(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
|
||||
super.onremove(vnode);
|
||||
|
||||
if (this.bodyClass) {
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import Component from '../Component';
|
||||
import type Mithril from 'mithril';
|
||||
import classList from '../utils/classList';
|
||||
import { TooltipCreationOptions } from '../../../@types/tooltips';
|
||||
import extractText from '../utils/extractText';
|
||||
|
||||
export interface TooltipAttrs extends Mithril.CommonAttributes<TooltipAttrs, Tooltip> {
|
||||
|
@@ -1,13 +1,16 @@
|
||||
import type Mithril from 'mithril';
|
||||
import type { ComponentAttrs } from '../Component';
|
||||
import User from '../models/User';
|
||||
|
||||
export interface AvatarAttrs extends ComponentAttrs {}
|
||||
|
||||
/**
|
||||
* The `avatar` helper displays a user's avatar.
|
||||
*
|
||||
* @param user
|
||||
* @param attrs Attributes to apply to the avatar element
|
||||
*/
|
||||
export default function avatar(user: User, attrs: Object = {}): Mithril.Vnode {
|
||||
export default function avatar(user: User, attrs: ComponentAttrs = {}): Mithril.Vnode {
|
||||
attrs.className = 'Avatar ' + (attrs.className || '');
|
||||
let content: string = '';
|
||||
|
||||
|
@@ -1,15 +1,29 @@
|
||||
import type Mithril from 'mithril';
|
||||
import Component, { ComponentAttrs } from '../Component';
|
||||
import Separator from '../components/Separator';
|
||||
import classList from '../utils/classList';
|
||||
import type * as Component from '../Component';
|
||||
|
||||
function isSeparator(item: Mithril.Children): boolean {
|
||||
export interface ModdedVnodeAttrs {
|
||||
itemClassName?: string;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
export type ModdedVnode<Attrs> = Mithril.Vnode<ModdedVnodeAttrs, Component<Attrs> | {}> & {
|
||||
itemName?: string;
|
||||
itemClassName?: string;
|
||||
tag: Mithril.Vnode['tag'] & {
|
||||
isListItem?: boolean;
|
||||
isActive?: (attrs: ComponentAttrs) => boolean;
|
||||
};
|
||||
};
|
||||
|
||||
function isSeparator<Attrs>(item: ModdedVnode<Attrs>): boolean {
|
||||
return item.tag === Separator;
|
||||
}
|
||||
|
||||
function withoutUnnecessarySeparators(items: Mithril.Children): Mithril.Children {
|
||||
const newItems: Mithril.Children = [];
|
||||
let prevItem: Mithril.Child;
|
||||
function withoutUnnecessarySeparators<Attrs>(items: ModdedVnode<Attrs>[]): ModdedVnode<Attrs>[] {
|
||||
const newItems: ModdedVnode<Attrs>[] = [];
|
||||
let prevItem: ModdedVnode<Attrs>;
|
||||
|
||||
items.filter(Boolean).forEach((item: Mithril.Vnode, i: number) => {
|
||||
if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) {
|
||||
@@ -21,16 +35,6 @@ function withoutUnnecessarySeparators(items: Mithril.Children): Mithril.Children
|
||||
return newItems;
|
||||
}
|
||||
|
||||
export interface ModdedVnodeAttrs {
|
||||
itemClassName?: string;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
export type ModdedVnode<Attrs> = Mithril.Vnode<ModdedVnodeAttrs, Component.default<Attrs> | {}> & {
|
||||
itemName?: string;
|
||||
itemClassName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The `listItems` helper wraps an array of components in the provided tag,
|
||||
* stripping out any unnecessary `Separator` components.
|
||||
@@ -39,12 +43,11 @@ export type ModdedVnode<Attrs> = Mithril.Vnode<ModdedVnodeAttrs, Component.defau
|
||||
* second function parameter, `customTag`.
|
||||
*/
|
||||
export default function listItems<Attrs extends Record<string, unknown>>(
|
||||
items: ModdedVnode<Attrs> | ModdedVnode<Attrs>[],
|
||||
customTag: string | Component.default<Attrs> = 'li',
|
||||
attributes: Attrs = {}
|
||||
rawItems: ModdedVnode<Attrs> | ModdedVnode<Attrs>[],
|
||||
customTag: string | Component<Attrs> = 'li',
|
||||
attributes: Attrs = {} as Attrs
|
||||
): Mithril.Vnode[] {
|
||||
if (!(items instanceof Array)) items = [items];
|
||||
|
||||
const items = rawItems instanceof Array ? rawItems : [rawItems];
|
||||
const Tag = customTag;
|
||||
|
||||
return withoutUnnecessarySeparators(items).map((item: ModdedVnode<Attrs>) => {
|
||||
|
@@ -5,8 +5,10 @@ import icon from './icon';
|
||||
/**
|
||||
* The `useronline` helper displays a green circle if the user is online
|
||||
*/
|
||||
export default function userOnline(user: User): Mithril.Vnode {
|
||||
export default function userOnline(user: User): Mithril.Vnode<{}, {}> | null {
|
||||
if (user.lastSeenAt() && user.isOnline()) {
|
||||
return <span className="UserOnline">{icon('fas fa-circle')}</span>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
@@ -14,7 +14,7 @@ export default class ModalManagerState {
|
||||
attrs?: Record<string, unknown>;
|
||||
} = null;
|
||||
|
||||
private closeTimeout?: number;
|
||||
private closeTimeout?: NodeJS.Timeout;
|
||||
|
||||
/**
|
||||
* Shows a modal dialog.
|
||||
@@ -36,7 +36,7 @@ export default class ModalManagerState {
|
||||
throw new Error(invalidModalWarning);
|
||||
}
|
||||
|
||||
clearTimeout(this.closeTimeout);
|
||||
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
||||
|
||||
this.modal = { componentClass, attrs };
|
||||
|
||||
|
@@ -15,18 +15,22 @@ export interface PaginationLocation {
|
||||
endIndex?: number;
|
||||
}
|
||||
|
||||
export default abstract class PaginatedListState<T extends Model> {
|
||||
export interface PaginatedListParams {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export default abstract class PaginatedListState<T extends Model, P extends PaginatedListParams = PaginatedListParams> {
|
||||
protected location!: PaginationLocation;
|
||||
protected pageSize: number;
|
||||
|
||||
protected pages: Page<T>[] = [];
|
||||
protected params: any = {};
|
||||
protected params: P = {} as P;
|
||||
|
||||
protected initialLoading: boolean = false;
|
||||
protected loadingPrev: boolean = false;
|
||||
protected loadingNext: boolean = false;
|
||||
|
||||
protected constructor(params: any = {}, page: number = 1, pageSize: number = 20) {
|
||||
protected constructor(params: P = {} as P, page: number = 1, pageSize: number = 20) {
|
||||
this.params = params;
|
||||
|
||||
this.location = { page };
|
||||
@@ -123,12 +127,14 @@ export default abstract class PaginatedListState<T extends Model> {
|
||||
* @param page
|
||||
* @see requestParams
|
||||
*/
|
||||
public refreshParams(newParams, page: number) {
|
||||
public refreshParams(newParams: P, page: number): Promise<void> {
|
||||
if (this.isEmpty() || this.paramsChanged(newParams)) {
|
||||
this.params = newParams;
|
||||
|
||||
return this.refresh(page);
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public refresh(page: number = 1) {
|
||||
@@ -222,7 +228,7 @@ export default abstract class PaginatedListState<T extends Model> {
|
||||
}
|
||||
}
|
||||
|
||||
protected paramsChanged(newParams): boolean {
|
||||
protected paramsChanged(newParams: P): boolean {
|
||||
return Object.keys(newParams).some((key) => this.getParams()[key] !== newParams[key]);
|
||||
}
|
||||
|
||||
|
@@ -18,7 +18,7 @@ export default class BasicEditorDriver implements EditorDriverInterface {
|
||||
this.el.placeholder = params.placeholder;
|
||||
this.el.value = params.value;
|
||||
|
||||
const callInputListeners = (e) => {
|
||||
const callInputListeners = (e: Event) => {
|
||||
params.inputListeners.forEach((listener) => {
|
||||
listener();
|
||||
});
|
||||
@@ -46,7 +46,7 @@ export default class BasicEditorDriver implements EditorDriverInterface {
|
||||
keyHandlers(params: EditorDriverParams): ItemList {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('submit', function (e) {
|
||||
items.add('submit', function (e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
params.onsubmit();
|
||||
}
|
||||
|
@@ -1,14 +1,28 @@
|
||||
export default class RequestError {
|
||||
import type Mithril from 'mithril';
|
||||
|
||||
export type InternalFlarumRequestOptions<ResponseType> = Mithril.RequestOptions<ResponseType> & {
|
||||
errorHandler: (error: RequestError) => void;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export default class RequestError<ResponseType = string> {
|
||||
status: number;
|
||||
options: Record<string, unknown>;
|
||||
options: InternalFlarumRequestOptions<ResponseType>;
|
||||
xhr: XMLHttpRequest;
|
||||
|
||||
responseText: string | null;
|
||||
response: Record<string, unknown> | null;
|
||||
response: {
|
||||
[key: string]: unknown;
|
||||
errors?: {
|
||||
detail?: string;
|
||||
code?: string;
|
||||
[key: string]: unknown;
|
||||
}[];
|
||||
} | null;
|
||||
|
||||
alert: any;
|
||||
|
||||
constructor(status: number, responseText: string | null, options: Record<string, unknown>, xhr: XMLHttpRequest) {
|
||||
constructor(status: number, responseText: string | null, options: InternalFlarumRequestOptions<ResponseType>, xhr: XMLHttpRequest) {
|
||||
this.status = status;
|
||||
this.responseText = responseText;
|
||||
this.options = options;
|
||||
|
@@ -3,12 +3,17 @@
|
||||
//
|
||||
// Needed to provide support for Safari on iOS < 12
|
||||
|
||||
// ts-ignored because we can afford to encapsulate some messy logic behind the clean typings.
|
||||
|
||||
if (!Array.prototype['flat']) {
|
||||
Array.prototype['flat'] = function flat(this: any[], depth: number = 1): any[] {
|
||||
return depth > 0
|
||||
? Array.prototype.reduce.call(this, (acc, val): any[] => acc.concat(Array.isArray(val) ? flat.call(val, depth - 1) : val), [])
|
||||
Array.prototype['flat'] = function flat<A, D extends number = 1>(this: A, depth?: D | unknown): any[] {
|
||||
// @ts-ignore
|
||||
return (depth ?? 1) > 0
|
||||
? // @ts-ignore
|
||||
Array.prototype.reduce.call(this, (acc, val): any[] => acc.concat(Array.isArray(val) ? flat.call(val, depth - 1) : val), [])
|
||||
: // If no depth is provided, or depth is 0, just return a copy of
|
||||
// the array. Spread is supported in all major browsers (iOS 8+)
|
||||
// @ts-ignore
|
||||
[...this];
|
||||
};
|
||||
}
|
||||
|
@@ -4,7 +4,7 @@ import dayjs from 'dayjs';
|
||||
* The `humanTime` utility converts a date to a localized, human-readable time-
|
||||
* ago string.
|
||||
*/
|
||||
export default function humanTime(time: Date): string {
|
||||
export default function humanTime(time: dayjs.ConfigType): string {
|
||||
let d = dayjs(time);
|
||||
const now = dayjs();
|
||||
|
||||
|
@@ -1,9 +1,9 @@
|
||||
type RGB = { r: number; g: number; b: number };
|
||||
|
||||
function hsvToRgb(h: number, s: number, v: number): RGB {
|
||||
let r;
|
||||
let g;
|
||||
let b;
|
||||
let r!: number;
|
||||
let g!: number;
|
||||
let b!: number;
|
||||
|
||||
const i = Math.floor(h * 6);
|
||||
const f = h * 6 - i;
|
||||
|
@@ -11,5 +11,5 @@
|
||||
*/
|
||||
export default (key: string, cb: Function) =>
|
||||
function (this: Element) {
|
||||
cb(this.getAttribute(key) || this[key]);
|
||||
cb(this.getAttribute(key) || (this as any)[key]);
|
||||
};
|
||||
|
@@ -22,6 +22,7 @@ import isSafariMobile from './utils/isSafariMobile';
|
||||
|
||||
import type Notification from './components/Notification';
|
||||
import type Post from './components/Post';
|
||||
import Discussion from '../common/models/Discussion';
|
||||
|
||||
export default class ForumApplication extends Application {
|
||||
/**
|
||||
@@ -134,11 +135,8 @@ export default class ForumApplication extends Application {
|
||||
|
||||
/**
|
||||
* Check whether or not the user is currently viewing a discussion.
|
||||
*
|
||||
* @param {Discussion} discussion
|
||||
* @return {Boolean}
|
||||
*/
|
||||
viewingDiscussion(discussion) {
|
||||
public viewingDiscussion(discussion: Discussion): boolean {
|
||||
return this.current.matches(DiscussionPage, { discussion });
|
||||
}
|
||||
|
||||
@@ -149,13 +147,8 @@ export default class ForumApplication extends Application {
|
||||
* If the payload indicates that the user has been logged in, then the page
|
||||
* will be reloaded. Otherwise, a SignUpModal will be opened, prefilled
|
||||
* with the provided details.
|
||||
*
|
||||
* @param {Object} payload A dictionary of attrs to pass into the sign up
|
||||
* modal. A truthy `loggedIn` attr indicates that the user has logged
|
||||
* in, and thus the page is reloaded.
|
||||
* @public
|
||||
*/
|
||||
authenticationComplete(payload) {
|
||||
public authenticationComplete(payload: Record<string, unknown>): void {
|
||||
if (payload.loggedIn) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
|
@@ -2,7 +2,7 @@ import Forum from './ForumApplication';
|
||||
|
||||
const app = new Forum();
|
||||
|
||||
// @ts-ignore
|
||||
// @ts-expect-error We need to do this for backwards compatibility purposes.
|
||||
window.app = app;
|
||||
|
||||
export default app;
|
||||
|
@@ -1,5 +1,7 @@
|
||||
import type Mithril from 'mithril';
|
||||
|
||||
import app from '../../forum/app';
|
||||
import Page from '../../common/components/Page';
|
||||
import Page, { IPageAttrs } from '../../common/components/Page';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import DiscussionHero from './DiscussionHero';
|
||||
import DiscussionListPane from './DiscussionListPane';
|
||||
@@ -10,31 +12,39 @@ import SplitDropdown from '../../common/components/SplitDropdown';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import DiscussionControls from '../utils/DiscussionControls';
|
||||
import PostStreamState from '../states/PostStreamState';
|
||||
import Discussion from '../../common/models/Discussion';
|
||||
import Post from '../../common/models/Post';
|
||||
|
||||
export interface IDiscussionPageAttrs extends IPageAttrs {
|
||||
id: string;
|
||||
near?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `DiscussionPage` component displays a whole discussion page, including
|
||||
* the discussion list pane, the hero, the posts, and the sidebar.
|
||||
*/
|
||||
export default class DiscussionPage extends Page {
|
||||
oninit(vnode) {
|
||||
export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = IDiscussionPageAttrs> extends Page<CustomAttrs> {
|
||||
/**
|
||||
* The discussion that is being viewed.
|
||||
*/
|
||||
protected discussion: Discussion | null = null;
|
||||
|
||||
/**
|
||||
* A public API for interacting with the post stream.
|
||||
*/
|
||||
protected stream: PostStreamState | null = null;
|
||||
|
||||
/**
|
||||
* The number of the first post that is currently visible in the viewport.
|
||||
*/
|
||||
protected near: number = 0;
|
||||
|
||||
protected useBrowserScrollRestoration = true;
|
||||
|
||||
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.useBrowserScrollRestoration = false;
|
||||
|
||||
/**
|
||||
* The discussion that is being viewed.
|
||||
*
|
||||
* @type {Discussion}
|
||||
*/
|
||||
this.discussion = null;
|
||||
|
||||
/**
|
||||
* The number of the first post that is currently visible in the viewport.
|
||||
*
|
||||
* @type {number}
|
||||
*/
|
||||
this.near = m.route.param('near') || 0;
|
||||
|
||||
this.load();
|
||||
|
||||
// If the discussion list has been loaded, then we'll enable the pane (and
|
||||
@@ -43,25 +53,23 @@ export default class DiscussionPage extends Page {
|
||||
// then the pane would redraw which would be slow and would cause problems with
|
||||
// event handlers.
|
||||
if (app.discussions.hasItems()) {
|
||||
app.pane.enable();
|
||||
app.pane.hide();
|
||||
app.pane?.enable();
|
||||
app.pane?.hide();
|
||||
}
|
||||
|
||||
app.history.push('discussion');
|
||||
|
||||
this.bodyClass = 'App--discussion';
|
||||
}
|
||||
|
||||
onremove(vnode) {
|
||||
onremove(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
|
||||
super.onremove(vnode);
|
||||
|
||||
// If we are indeed navigating away from this discussion, then disable the
|
||||
// discussion list pane. Also, if we're composing a reply to this
|
||||
// discussion, minimize the composer – unless it's empty, in which case
|
||||
// we'll just close it.
|
||||
app.pane.disable();
|
||||
app.pane?.disable();
|
||||
|
||||
if (app.composer.composingReplyTo(this.discussion) && !app.composer.fields.content()) {
|
||||
if (app.composer.composingReplyTo(this.discussion) && !app.composer?.fields?.content()) {
|
||||
app.composer.hide();
|
||||
} else {
|
||||
app.composer.minimize();
|
||||
@@ -155,7 +163,7 @@ export default class DiscussionPage extends Page {
|
||||
* Load the discussion from the API or use the preloaded one.
|
||||
*/
|
||||
load() {
|
||||
const preloadedDiscussion = app.preloadedApiDocument();
|
||||
const preloadedDiscussion = app.preloadedApiDocument() as Discussion | null;
|
||||
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
|
||||
@@ -186,10 +194,8 @@ export default class DiscussionPage extends Page {
|
||||
|
||||
/**
|
||||
* Initialize the component to display the given discussion.
|
||||
*
|
||||
* @param {Discussion} discussion
|
||||
*/
|
||||
show(discussion) {
|
||||
show(discussion: Discussion) {
|
||||
app.history.push('discussion', discussion.title());
|
||||
app.setTitle(discussion.title());
|
||||
app.setTitleCount(0);
|
||||
@@ -214,7 +220,7 @@ export default class DiscussionPage extends Page {
|
||||
record.relationships.discussion.data.id === discussionId
|
||||
)
|
||||
.map((record) => app.store.getById('posts', record.id))
|
||||
.sort((a, b) => a.createdAt() - b.createdAt())
|
||||
.sort((a: Post, b: Post) => a.createdAt() - b.createdAt())
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
||||
@@ -266,13 +272,12 @@ export default class DiscussionPage extends Page {
|
||||
/**
|
||||
* When the posts that are visible in the post stream change (i.e. the user
|
||||
* scrolls up or down), then we update the URL and mark the posts as read.
|
||||
*
|
||||
* @param {Integer} startNumber
|
||||
* @param {Integer} endNumber
|
||||
*/
|
||||
positionChanged(startNumber, endNumber) {
|
||||
positionChanged(startNumber: number, endNumber: number): void {
|
||||
const discussion = this.discussion;
|
||||
|
||||
if (!discussion) return;
|
||||
|
||||
// Construct a URL to this discussion with the updated position, then
|
||||
// replace it into the window's history and our own history stack.
|
||||
const url = app.route.discussion(discussion, (this.near = startNumber));
|
@@ -4,15 +4,16 @@ import LinkButton from '../../common/components/LinkButton';
|
||||
import Link from '../../common/components/Link';
|
||||
import { SearchSource } from './Search';
|
||||
import type Mithril from 'mithril';
|
||||
import Discussion from '../../common/models/Discussion';
|
||||
|
||||
/**
|
||||
* The `DiscussionsSearchSource` finds and displays discussion search results in
|
||||
* the search dropdown.
|
||||
*/
|
||||
export default class DiscussionsSearchSource implements SearchSource {
|
||||
protected results = new Map<string, unknown[]>();
|
||||
protected results = new Map<string, Discussion[]>();
|
||||
|
||||
search(query: string) {
|
||||
async search(query: string): Promise<void> {
|
||||
query = query.toLowerCase();
|
||||
|
||||
this.results.set(query, []);
|
||||
@@ -23,13 +24,16 @@ export default class DiscussionsSearchSource implements SearchSource {
|
||||
include: 'mostRelevantPost',
|
||||
};
|
||||
|
||||
return app.store.find('discussions', params).then((results) => this.results.set(query, results));
|
||||
return app.store.find('discussions', params).then((results) => {
|
||||
this.results.set(query, results);
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
view(query: string): Array<Mithril.Vnode> {
|
||||
query = query.toLowerCase();
|
||||
|
||||
const results = (this.results.get(query) || []).map((discussion: unknown) => {
|
||||
const results = (this.results.get(query) || []).map((discussion) => {
|
||||
const mostRelevantPost = discussion.mostRelevantPost();
|
||||
|
||||
return (
|
||||
|
@@ -10,6 +10,7 @@ import SearchState from '../states/SearchState';
|
||||
import DiscussionsSearchSource from './DiscussionsSearchSource';
|
||||
import UsersSearchSource from './UsersSearchSource';
|
||||
import type Mithril from 'mithril';
|
||||
import Model from '../../common/Model';
|
||||
|
||||
/**
|
||||
* The `SearchSource` interface defines a section of search results in the
|
||||
@@ -24,8 +25,9 @@ import type Mithril from 'mithril';
|
||||
export interface SearchSource {
|
||||
/**
|
||||
* Make a request to get results for the given query.
|
||||
* The results will be updated internally in the search source, not exposed.
|
||||
*/
|
||||
search(query: string);
|
||||
search(query: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get an array of virtual <li>s that list the search results for the given
|
||||
@@ -57,7 +59,7 @@ export default class Search<T extends SearchAttrs = SearchAttrs> extends Compone
|
||||
*/
|
||||
protected static MIN_SEARCH_LEN = 3;
|
||||
|
||||
protected state!: SearchState;
|
||||
protected searchState!: SearchState;
|
||||
|
||||
/**
|
||||
* Whether or not the search input has focus.
|
||||
@@ -84,18 +86,18 @@ export default class Search<T extends SearchAttrs = SearchAttrs> extends Compone
|
||||
|
||||
protected navigator!: KeyboardNavigatable;
|
||||
|
||||
protected searchTimeout?: number;
|
||||
protected searchTimeout?: NodeJS.Timeout;
|
||||
|
||||
private updateMaxHeightHandler?: () => void;
|
||||
|
||||
oninit(vnode: Mithril.Vnode<T, this>) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.state = this.attrs.state;
|
||||
this.searchState = this.attrs.state;
|
||||
}
|
||||
|
||||
view() {
|
||||
const currentSearch = this.state.getInitialSearch();
|
||||
const currentSearch = this.searchState.getInitialSearch();
|
||||
|
||||
// Initialize search sources in the view rather than the constructor so
|
||||
// that we have access to app.forum.
|
||||
@@ -107,15 +109,15 @@ export default class Search<T extends SearchAttrs = SearchAttrs> extends Compone
|
||||
const searchLabel = extractText(app.translator.trans('core.forum.header.search_placeholder'));
|
||||
|
||||
const isActive = !!currentSearch;
|
||||
const shouldShowResults = !!(!this.loadingSources && this.state.getValue() && this.hasFocus);
|
||||
const shouldShowClearButton = !!(!this.loadingSources && this.state.getValue());
|
||||
const shouldShowResults = !!(!this.loadingSources && this.searchState.getValue() && this.hasFocus);
|
||||
const shouldShowClearButton = !!(!this.loadingSources && this.searchState.getValue());
|
||||
|
||||
return (
|
||||
<div
|
||||
role="search"
|
||||
aria-label={app.translator.trans('core.forum.header.search_role_label')}
|
||||
className={classList('Search', {
|
||||
open: this.state.getValue() && this.hasFocus,
|
||||
open: this.searchState.getValue() && this.hasFocus,
|
||||
focused: this.hasFocus,
|
||||
active: isActive,
|
||||
loading: !!this.loadingSources,
|
||||
@@ -127,8 +129,8 @@ export default class Search<T extends SearchAttrs = SearchAttrs> extends Compone
|
||||
className="FormControl"
|
||||
type="search"
|
||||
placeholder={searchLabel}
|
||||
value={this.state.getValue()}
|
||||
oninput={(e) => this.state.setValue(e.target.value)}
|
||||
value={this.searchState.getValue()}
|
||||
oninput={(e: InputEvent) => this.searchState.setValue((e?.target as HTMLInputElement)?.value)}
|
||||
onfocus={() => (this.hasFocus = true)}
|
||||
onblur={() => (this.hasFocus = false)}
|
||||
/>
|
||||
@@ -148,7 +150,7 @@ export default class Search<T extends SearchAttrs = SearchAttrs> extends Compone
|
||||
aria-hidden={!shouldShowResults || undefined}
|
||||
aria-live={shouldShowResults ? 'polite' : undefined}
|
||||
>
|
||||
{shouldShowResults && this.sources.map((source) => source.view(this.state.getValue()))}
|
||||
{shouldShowResults && this.sources.map((source) => source.view(this.searchState.getValue()))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
@@ -159,11 +161,12 @@ export default class Search<T extends SearchAttrs = SearchAttrs> extends Compone
|
||||
// we need to calculate and set the max height dynamically.
|
||||
const resultsElementMargin = 14;
|
||||
const maxHeight =
|
||||
window.innerHeight - this.element.querySelector('.Search-input>.FormControl').getBoundingClientRect().bottom - resultsElementMargin;
|
||||
this.element.querySelector('.Search-results').style['max-height'] = `${maxHeight}px`;
|
||||
window.innerHeight - this.element.querySelector('.Search-input>.FormControl')!.getBoundingClientRect().bottom - resultsElementMargin;
|
||||
|
||||
this.element.querySelector('.Search-results')?.setAttribute('style', `max-height: ${maxHeight}px`);
|
||||
}
|
||||
|
||||
onupdate(vnode) {
|
||||
onupdate(vnode: Mithril.VnodeDOM<T, this>) {
|
||||
super.onupdate(vnode);
|
||||
|
||||
// Highlight the item that is currently selected.
|
||||
@@ -175,11 +178,11 @@ export default class Search<T extends SearchAttrs = SearchAttrs> extends Compone
|
||||
this.updateMaxHeight();
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
oncreate(vnode: Mithril.VnodeDOM<T, this>) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
const search = this;
|
||||
const state = this.state;
|
||||
const state = this.searchState;
|
||||
|
||||
// Highlight the item that is currently selected.
|
||||
this.setIndex(this.getCurrentNumericIndex());
|
||||
@@ -210,7 +213,7 @@ export default class Search<T extends SearchAttrs = SearchAttrs> extends Compone
|
||||
|
||||
if (!query) return;
|
||||
|
||||
clearTimeout(search.searchTimeout);
|
||||
if (search.searchTimeout) clearTimeout(search.searchTimeout);
|
||||
search.searchTimeout = setTimeout(() => {
|
||||
if (state.isCached(query)) return;
|
||||
|
||||
@@ -242,21 +245,25 @@ export default class Search<T extends SearchAttrs = SearchAttrs> extends Compone
|
||||
window.addEventListener('resize', this.updateMaxHeightHandler);
|
||||
}
|
||||
|
||||
onremove(vnode) {
|
||||
onremove(vnode: Mithril.VnodeDOM<T, this>) {
|
||||
super.onremove(vnode);
|
||||
|
||||
window.removeEventListener('resize', this.updateMaxHeightHandler);
|
||||
if (this.updateMaxHeightHandler) {
|
||||
window.removeEventListener('resize', this.updateMaxHeightHandler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the currently selected search result and close the list.
|
||||
*/
|
||||
selectResult() {
|
||||
clearTimeout(this.searchTimeout);
|
||||
if (this.searchTimeout) clearTimeout(this.searchTimeout);
|
||||
|
||||
this.loadingSources = 0;
|
||||
|
||||
if (this.state.getValue()) {
|
||||
m.route.set(this.getItem(this.index).find('a').attr('href'));
|
||||
const selectedUrl = this.getItem(this.index).find('a').attr('href');
|
||||
if (this.searchState.getValue() && selectedUrl) {
|
||||
m.route.set(selectedUrl);
|
||||
} else {
|
||||
this.clear();
|
||||
}
|
||||
@@ -268,7 +275,7 @@ export default class Search<T extends SearchAttrs = SearchAttrs> extends Compone
|
||||
* Clear the search
|
||||
*/
|
||||
clear() {
|
||||
this.state.clear();
|
||||
this.searchState.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -331,11 +338,11 @@ export default class Search<T extends SearchAttrs = SearchAttrs> extends Compone
|
||||
this.index = parseInt($item.attr('data-index') as string) || fixedIndex;
|
||||
|
||||
if (scrollToItem) {
|
||||
const dropdownScroll = $dropdown.scrollTop();
|
||||
const dropdownTop = $dropdown.offset().top;
|
||||
const dropdownBottom = dropdownTop + $dropdown.outerHeight();
|
||||
const itemTop = $item.offset().top;
|
||||
const itemBottom = itemTop + $item.outerHeight();
|
||||
const dropdownScroll = $dropdown.scrollTop()!;
|
||||
const dropdownTop = $dropdown.offset()!.top;
|
||||
const dropdownBottom = dropdownTop + $dropdown.outerHeight()!;
|
||||
const itemTop = $item.offset()!.top;
|
||||
const itemBottom = itemTop + $item.outerHeight()!;
|
||||
|
||||
let scrollTop;
|
||||
if (itemTop < dropdownTop) {
|
||||
|
@@ -1,19 +1,21 @@
|
||||
import type Mithril from 'mithril';
|
||||
|
||||
import app from '../../forum/app';
|
||||
import highlight from '../../common/helpers/highlight';
|
||||
import avatar from '../../common/helpers/avatar';
|
||||
import username from '../../common/helpers/username';
|
||||
import Link from '../../common/components/Link';
|
||||
import { SearchSource } from './Search';
|
||||
import type Mithril from 'mithril';
|
||||
import User from '../../common/models/User';
|
||||
|
||||
/**
|
||||
* The `UsersSearchSource` finds and displays user search results in the search
|
||||
* dropdown.
|
||||
*/
|
||||
export default class UsersSearchResults implements SearchSource {
|
||||
protected results = new Map<string, unknown[]>();
|
||||
protected results = new Map<string, User[]>();
|
||||
|
||||
search(query: string) {
|
||||
async search(query: string): Promise<void> {
|
||||
return app.store
|
||||
.find('users', {
|
||||
filter: { q: query },
|
||||
|
@@ -10,6 +10,7 @@ export { app };
|
||||
import compatObj from './compat';
|
||||
import proxifyCompat from '../common/utils/proxifyCompat';
|
||||
|
||||
// @ts-ignore
|
||||
compatObj.app = app;
|
||||
|
||||
export const compat = proxifyCompat(compatObj, 'forum');
|
||||
|
@@ -1,14 +1,19 @@
|
||||
import type Mithril from 'mithril';
|
||||
|
||||
import app from '../../forum/app';
|
||||
import DefaultResolver from '../../common/resolvers/DefaultResolver';
|
||||
import DiscussionPage from '../components/DiscussionPage';
|
||||
import DiscussionPage, { IDiscussionPageAttrs } from '../components/DiscussionPage';
|
||||
|
||||
/**
|
||||
* A custom route resolver for DiscussionPage that generates the same key to all posts
|
||||
* on the same discussion. It triggers a scroll when going from one post to another
|
||||
* in the same discussion.
|
||||
*/
|
||||
export default class DiscussionPageResolver extends DefaultResolver {
|
||||
static scrollToPostNumber: string | null = null;
|
||||
export default class DiscussionPageResolver<
|
||||
Attrs extends IDiscussionPageAttrs = IDiscussionPageAttrs,
|
||||
RouteArgs extends Record<string, unknown> = {}
|
||||
> extends DefaultResolver<Attrs, DiscussionPage<Attrs>, RouteArgs> {
|
||||
static scrollToPostNumber: number | null = null;
|
||||
|
||||
/**
|
||||
* Remove optional parts of a discussion's slug to keep the substring
|
||||
@@ -34,16 +39,16 @@ export default class DiscussionPageResolver extends DefaultResolver {
|
||||
return this.routeName.replace('.near', '') + JSON.stringify(params);
|
||||
}
|
||||
|
||||
onmatch(args, requestedPath, route) {
|
||||
onmatch(args: Attrs & RouteArgs, requestedPath: string, route: string) {
|
||||
if (app.current.matches(DiscussionPage) && this.canonicalizeDiscussionSlug(args.id) === this.canonicalizeDiscussionSlug(m.route.param('id'))) {
|
||||
// By default, the first post number of any discussion is 1
|
||||
DiscussionPageResolver.scrollToPostNumber = args.near || '1';
|
||||
DiscussionPageResolver.scrollToPostNumber = args.near || 1;
|
||||
}
|
||||
|
||||
return super.onmatch(args, requestedPath, route);
|
||||
}
|
||||
|
||||
render(vnode) {
|
||||
render(vnode: Mithril.Vnode<Attrs, DiscussionPage<Attrs>>) {
|
||||
if (DiscussionPageResolver.scrollToPostNumber !== null) {
|
||||
const number = DiscussionPageResolver.scrollToPostNumber;
|
||||
// Scroll after a timeout to avoid clashes with the render.
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import app from '../../forum/app';
|
||||
import PaginatedListState, { Page } from '../../common/states/PaginatedListState';
|
||||
import PaginatedListState, { Page, PaginatedListParams } from '../../common/states/PaginatedListState';
|
||||
import Discussion from '../../common/models/Discussion';
|
||||
|
||||
export interface IRequestParams {
|
||||
@@ -8,10 +8,14 @@ export interface IRequestParams {
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export default class DiscussionListState extends PaginatedListState<Discussion> {
|
||||
export interface DiscussionListParams extends PaginatedListParams {
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export default class DiscussionListState<P extends DiscussionListParams = DiscussionListParams> extends PaginatedListState<Discussion, P> {
|
||||
protected extraDiscussions: Discussion[] = [];
|
||||
|
||||
constructor(params: any, page: number = 1) {
|
||||
constructor(params: P, page: number = 1) {
|
||||
super(params, page, 20);
|
||||
}
|
||||
|
||||
@@ -25,7 +29,7 @@ export default class DiscussionListState extends PaginatedListState<Discussion>
|
||||
filter: this.params.filter || {},
|
||||
};
|
||||
|
||||
params.sort = this.sortMap()[this.params.sort];
|
||||
params.sort = this.sortMap()[this.params.sort ?? ''];
|
||||
|
||||
if (this.params.q) {
|
||||
params.filter.q = this.params.q;
|
||||
|
@@ -1,5 +1,11 @@
|
||||
import setRouteWithForcedRefresh from '../../common/utils/setRouteWithForcedRefresh';
|
||||
|
||||
export interface HistoryEntry {
|
||||
name: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `History` class keeps track and manages a stack of routes that the user
|
||||
* has navigated to in their session.
|
||||
@@ -12,46 +18,34 @@ import setRouteWithForcedRefresh from '../../common/utils/setRouteWithForcedRefr
|
||||
* rather than the previous discussion.
|
||||
*/
|
||||
export default class History {
|
||||
constructor(defaultRoute) {
|
||||
/**
|
||||
* The stack of routes that have been navigated to.
|
||||
*
|
||||
* @type {Array}
|
||||
* @protected
|
||||
*/
|
||||
this.stack = [];
|
||||
}
|
||||
/**
|
||||
* The stack of routes that have been navigated to.
|
||||
*/
|
||||
protected stack: HistoryEntry[] = [];
|
||||
|
||||
/**
|
||||
* Get the item on the top of the stack.
|
||||
*
|
||||
* @return {Object}
|
||||
* @public
|
||||
*/
|
||||
getCurrent() {
|
||||
getCurrent(): HistoryEntry {
|
||||
return this.stack[this.stack.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the previous item on the stack.
|
||||
*
|
||||
* @return {Object}
|
||||
* @public
|
||||
*/
|
||||
getPrevious() {
|
||||
getPrevious(): HistoryEntry {
|
||||
return this.stack[this.stack.length - 2];
|
||||
}
|
||||
|
||||
/**
|
||||
* Push an item to the top of the stack.
|
||||
*
|
||||
* @param {String} name The name of the route.
|
||||
* @param {String} title The title of the route.
|
||||
* @param {String} [url] The URL of the route. The current URL will be used if
|
||||
* @param {string} name The name of the route.
|
||||
* @param {string} title The title of the route.
|
||||
* @param {string} [url] The URL of the route. The current URL will be used if
|
||||
* not provided.
|
||||
* @public
|
||||
*/
|
||||
push(name, title, url = m.route.get()) {
|
||||
push(name: string, title: string, url = m.route.get()) {
|
||||
// If we're pushing an item with the same name as second-to-top item in the
|
||||
// stack, we will assume that the user has clicked the 'back' button in
|
||||
// their browser. In this case, we don't want to push a new item, so we will
|
||||
@@ -74,18 +68,13 @@ export default class History {
|
||||
|
||||
/**
|
||||
* Check whether or not the history stack is able to be popped.
|
||||
*
|
||||
* @return {Boolean}
|
||||
* @public
|
||||
*/
|
||||
canGoBack() {
|
||||
canGoBack(): boolean {
|
||||
return this.stack.length > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Go back to the previous route in the history stack.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
back() {
|
||||
if (!this.canGoBack()) {
|
||||
@@ -99,10 +88,8 @@ export default class History {
|
||||
|
||||
/**
|
||||
* Get the URL of the previous page.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
backUrl() {
|
||||
backUrl(): string {
|
||||
const secondTop = this.stack[this.stack.length - 2];
|
||||
|
||||
return secondTop.url;
|
||||
@@ -110,8 +97,6 @@ export default class History {
|
||||
|
||||
/**
|
||||
* Go to the first route in the history stack.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
home() {
|
||||
this.stack.splice(0);
|
@@ -91,7 +91,7 @@ export default class KeyboardNavigatable {
|
||||
*/
|
||||
onRemove(callback: KeyboardEventHandler): KeyboardNavigatable {
|
||||
this.callbacks.set(8, (e) => {
|
||||
if (e.target.selectionStart === 0 && e.target.selectionEnd === 0) {
|
||||
if (e instanceof KeyboardEvent && e.target instanceof HTMLInputElement && e.target.selectionStart === 0 && e.target.selectionEnd === 0) {
|
||||
callback(e);
|
||||
e.preventDefault();
|
||||
}
|
||||
@@ -114,7 +114,7 @@ export default class KeyboardNavigatable {
|
||||
*/
|
||||
bindTo($element: JQuery) {
|
||||
// Handle navigation key events on the navigatable element.
|
||||
$element.on('keydown', this.navigate.bind(this));
|
||||
$element[0].addEventListener('keydown', this.navigate.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -4,9 +4,9 @@
|
||||
export default function isSafariMobile(): boolean {
|
||||
return (
|
||||
'ontouchstart' in window &&
|
||||
navigator.vendor &&
|
||||
navigator.vendor != null &&
|
||||
navigator.vendor.includes('Apple') &&
|
||||
navigator.userAgent &&
|
||||
navigator.userAgent != null &&
|
||||
!navigator.userAgent.includes('CriOS') &&
|
||||
!navigator.userAgent.includes('FxiOS')
|
||||
);
|
||||
|
Reference in New Issue
Block a user