1
0
mirror of https://github.com/flarum/core.git synced 2025-08-16 13:24:11 +02:00

Compare commits

..

2 Commits

Author SHA1 Message Date
Alexander Skvortsov
31a2f67462 Apply fixes from StyleCI
[ci skip] [skip ci]
2021-12-12 19:38:25 +00:00
Alexander Skvortsov
3da7d7d221 Add server metadata in writable paths errors 2021-12-12 14:38:08 -05:00
145 changed files with 1779 additions and 3068 deletions

View File

@@ -49,7 +49,7 @@ jobs:
working-directory: ./js working-directory: ./js
- name: Typecheck - name: Typecheck
run: yarn run check-typings run: yarn run check-typings || true # REMOVE THIS ONCE TYPE SAFETY REACHED
working-directory: ./js working-directory: ./js
type-coverage: type-coverage:

View File

@@ -9,6 +9,7 @@
<a href="https://github.styleci.io/repos/28257573"><img src="https://github.styleci.io/repos/28257573/shield?style=flat" alt="StyleCI"></a> <a href="https://github.styleci.io/repos/28257573"><img src="https://github.styleci.io/repos/28257573/shield?style=flat" alt="StyleCI"></a>
</p> </p>
## About Flarum ## About Flarum
**[Flarum](https://flarum.org/) is a delightfully simple discussion platform for your website.** It's fast and easy to use, with all the features you need to run a successful community. It is designed to be: **[Flarum](https://flarum.org/) is a delightfully simple discussion platform for your website.** It's fast and easy to use, with all the features you need to run a successful community. It is designed to be:
@@ -19,15 +20,13 @@
* **Powerful and extensible.** Customize, extend, and integrate Flarum to suit your community. Flarums architecture is amazingly flexible, with a powerful Extension API. * **Powerful and extensible.** Customize, extend, and integrate Flarum to suit your community. Flarums architecture is amazingly flexible, with a powerful Extension API.
![Screenshot of a Flarum instance, showing multiple discussions and tags.](https://flarum.org/assets/img/home-screenshot.png)
## Installation ## Installation
This repository contains Flarum's core code. If you want to set up a forum, visit the [Flarum skeleton repository](https://github.com/flarum/flarum). For support, refer to the [documentation](https://docs.flarum.org/), and ask questions on [Flarum Discuss](https://discuss.flarum.org/) (our community forum) or [Discord server](https://flarum.org/discord/). This repository contains Flarum's core code. If you want to set up a forum, visit the [Flarum skeleton repository](https://github.com/flarum/flarum).
## Contributing ## Contributing
Thank you for considering contributing to Flarum! Please read the **[Contributing guide](https://docs.flarum.org/contributing)** to learn how you can help. Thank you for considering contributing to Flarum! Please read the **[Contributing guide](https://flarum.org/docs/contributing.html)** to learn how you can help.
## Security Vulnerabilities ## Security Vulnerabilities

View File

@@ -21,20 +21,7 @@ declare type KeysOfType<Type extends object, Match> = {
*/ */
declare type KeyOfType<Type extends object, Match> = KeysOfType<Type, Match>[keyof Type]; declare type KeyOfType<Type extends object, Match> = KeysOfType<Type, Match>[keyof Type];
type Component<A> = import('mithril').Component<A>; declare type VnodeElementTag<Attrs = Record<string, unknown>, State = Record<string, unknown>> = string | ComponentTypes<Attrs, State>;
declare type ComponentClass<Attrs = Record<string, unknown>, C extends Component<Attrs> = Component<Attrs>> = {
new (...args: any[]): Component<Attrs>;
prototype: C;
};
/**
* Unfortunately, TypeScript only supports strings and classes for JSX tags.
* Therefore, our type definition should only allow for those two types.
*
* @see https://github.com/microsoft/TypeScript/issues/14789#issuecomment-412247771
*/
declare type VnodeElementTag<Attrs = Record<string, unknown>, C extends Component<Attrs> = Component<Attrs>> = string | ComponentClass<Attrs, C>;
/** /**
* @deprecated Please import `app` from a namespace instead of using it as a global variable. * @deprecated Please import `app` from a namespace instead of using it as a global variable.
@@ -59,17 +46,6 @@ declare const app: never;
declare const m: import('mithril').Static; declare const m: import('mithril').Static;
declare const dayjs: typeof import('dayjs'); declare const dayjs: typeof import('dayjs');
/**
* From https://github.com/lokesh/color-thief/issues/188
*/
declare module 'color-thief-browser' {
type Color = [number, number, number];
export default class ColorThief {
getColor: (img: HTMLImageElement | null) => Color;
getPalette: (img: HTMLImageElement | null) => Color[];
}
}
type ESModule = { __esModule: true; [key: string]: unknown }; type ESModule = { __esModule: true; [key: string]: unknown };
/** /**

View File

@@ -35,7 +35,7 @@ export default class AdminApplication extends Application {
history: { history: {
canGoBack: () => boolean; canGoBack: () => boolean;
getPrevious: () => void; getPrevious: () => void;
backUrl: () => string; backUrl: () => any;
back: () => void; back: () => void;
}; };
/** /**

View File

@@ -2,8 +2,8 @@ import type Mithril from 'mithril';
import Page, { IPageAttrs } from '../../common/components/Page'; import Page, { IPageAttrs } from '../../common/components/Page';
import Stream from '../../common/utils/Stream'; import Stream from '../../common/utils/Stream';
export interface AdminHeaderOptions { export interface AdminHeaderOptions {
title: Mithril.Children; title: string;
description: Mithril.Children; description: string;
icon: string; icon: string;
/** /**
* Will be used as the class for the AdminPage. * Will be used as the class for the AdminPage.

View File

@@ -1,8 +1,6 @@
/// <reference path="../../../src/common/translator-icu-rich.d.ts" /> /// <reference path="../../../src/common/translator-icu-rich.d.ts" />
import Modal, { IInternalModalAttrs } from '../../common/components/Modal'; import Modal from '../../common/components/Modal';
export interface ILoadingModalAttrs extends IInternalModalAttrs { export default class LoadingModal<ModalAttrs = {}> extends Modal<ModalAttrs> {
}
export default class LoadingModal<ModalAttrs extends ILoadingModalAttrs = ILoadingModalAttrs> extends Modal<ModalAttrs> {
/** /**
* @inheritdoc * @inheritdoc
*/ */

View File

@@ -1,18 +1,8 @@
/// <reference path="../../../src/common/translator-icu-rich.d.ts" /> export default class ReadmeModal extends Modal<import("../../common/components/Modal").IInternalModalAttrs> {
import Modal, { IInternalModalAttrs } from '../../common/components/Modal'; constructor();
import ExtensionReadme from '../models/ExtensionReadme'; name: any;
import type Mithril from 'mithril'; extName: any;
import type { Extension } from '../AdminApplication';
export interface IReadmeModalAttrs extends IInternalModalAttrs {
extension: Extension;
}
export default class ReadmeModal<CustomAttrs extends IReadmeModalAttrs = IReadmeModalAttrs> extends Modal<CustomAttrs> {
protected name: string;
protected extName: string;
protected readme: ExtensionReadme;
oninit(vnode: Mithril.Vnode<CustomAttrs, this>): void;
className(): string;
title(): import("@askvortsov/rich-icu-message-formatter").NestedStringArray;
content(): JSX.Element;
loadReadme(): Promise<void>; loadReadme(): Promise<void>;
readme: any;
} }
import Modal from "../../common/components/Modal";

View File

@@ -1,6 +1,5 @@
export default class StatusWidget extends DashboardWidget { export default class StatusWidget extends DashboardWidget {
items(): ItemList<any>; items(): ItemList<any>;
toolsItems(): ItemList<any>;
handleClearCache(e: any): void; handleClearCache(e: any): void;
} }
import DashboardWidget from "./DashboardWidget"; import DashboardWidget from "./DashboardWidget";

View File

@@ -1,5 +1,5 @@
/// <reference path="../../../src/common/translator-icu-rich.d.ts" /> /// <reference path="../../../src/common/translator-icu-rich.d.ts" />
import type Mithril from 'mithril'; /// <reference types="mithril" />
import type User from '../../common/models/User'; import type User from '../../common/models/User';
import ItemList from '../../common/utils/ItemList'; import ItemList from '../../common/utils/ItemList';
import AdminPage from './AdminPage'; import AdminPage from './AdminPage';
@@ -7,11 +7,11 @@ declare type ColumnData = {
/** /**
* Column title * Column title
*/ */
name: Mithril.Children; name: String;
/** /**
* Component(s) to show for this column. * Component(s) to show for this column.
*/ */
content: (user: User) => Mithril.Children; content: (user: User) => JSX.Element;
}; };
/** /**
* Admin page which displays a paginated list of all users on the forum. * Admin page which displays a paginated list of all users on the forum.

View File

@@ -1,4 +1,4 @@
export default class ExtensionReadme extends Model { export default class ExtensionReadme extends Model {
content: () => any; content: any;
} }
import Model from "../../common/Model"; import Model from "../../common/Model";

View File

@@ -1,6 +1,6 @@
import ItemList from './utils/ItemList'; import ItemList from './utils/ItemList';
import Translator from './Translator'; import Translator from './Translator';
import Store, { ApiPayload, ApiResponsePlural, ApiResponseSingle } from './Store'; import Store from './Store';
import Session from './Session'; import Session from './Session';
import Drawer from './utils/Drawer'; import Drawer from './utils/Drawer';
import RequestError, { InternalFlarumRequestOptions } from './utils/RequestError'; import RequestError, { InternalFlarumRequestOptions } from './utils/RequestError';
@@ -12,7 +12,6 @@ import type DefaultResolver from './resolvers/DefaultResolver';
import type Mithril from 'mithril'; import type Mithril from 'mithril';
import type Component from './Component'; import type Component from './Component';
import type { ComponentAttrs } from './Component'; import type { ComponentAttrs } from './Component';
import Model, { SavedModelData } from './Model';
export declare type FlarumScreens = 'phone' | 'tablet' | 'desktop' | 'desktop-hd'; export declare type FlarumScreens = 'phone' | 'tablet' | 'desktop' | 'desktop-hd';
export declare type FlarumGenericRoute = RouteItem<any, any, any>; export declare type FlarumGenericRoute = RouteItem<any, any, any>;
export interface FlarumRequestOptions<ResponseType> extends Omit<Mithril.RequestOptions<ResponseType>, 'extract'> { export interface FlarumRequestOptions<ResponseType> extends Omit<Mithril.RequestOptions<ResponseType>, 'extract'> {
@@ -160,10 +159,10 @@ export default class Application {
*/ */
drawer: Drawer; drawer: Drawer;
data: { data: {
apiDocument: ApiPayload | null; apiDocument: Record<string, unknown> | null;
locale: string; locale: string;
locales: Record<string, string>; locales: Record<string, string>;
resources: SavedModelData[]; resources: Record<string, unknown>[];
session: { session: {
userId: number; userId: number;
csrfToken: string; csrfToken: string;
@@ -191,8 +190,7 @@ export default class Application {
/** /**
* Get the API response document that has been preloaded into the application. * Get the API response document that has been preloaded into the application.
*/ */
preloadedApiDocument<M extends Model>(): ApiResponseSingle<M> | null; preloadedApiDocument(): Record<string, unknown> | null;
preloadedApiDocument<Ms extends Model[]>(): ApiResponsePlural<Ms[number]> | null;
/** /**
* Determine the current screen mode, based on our media queries. * Determine the current screen mode, based on our media queries.
*/ */
@@ -219,12 +217,7 @@ export default class Application {
* @param options * @param options
* @return {Promise} * @return {Promise}
*/ */
request<ResponseType>(originalOptions: FlarumRequestOptions<ResponseType>): Promise<ResponseType>; request<ResponseType>(originalOptions: FlarumRequestOptions<ResponseType>): Promise<ResponseType | string>;
/**
* By default, show an error alert, and log the error to the console.
*/
protected requestErrorCatch<ResponseType>(error: RequestError, customErrorHandler: FlarumRequestOptions<ResponseType>['errorHandler']): Promise<never>;
protected requestErrorDefaultHandler(e: unknown, isDebug: boolean, formattedErrors: string[]): void;
private showDebug; private showDebug;
/** /**
* Construct a URL to the route with the given name. * Construct a URL to the route with the given name.

View File

@@ -1,147 +1,149 @@
import { FlarumRequestOptions } from './Application';
import Store, { ApiPayloadSingle, ApiResponseSingle, MetaInformation } from './Store';
export interface ModelIdentifier {
type: string;
id: string;
}
export interface ModelAttributes {
[key: string]: unknown;
}
export interface ModelRelationships {
[relationship: string]: {
data: ModelIdentifier | ModelIdentifier[];
};
}
export interface UnsavedModelData {
type?: string;
attributes?: ModelAttributes;
relationships?: ModelRelationships;
}
export interface SavedModelData {
type: string;
id: string;
attributes?: ModelAttributes;
relationships?: ModelRelationships;
}
export declare type ModelData = UnsavedModelData | SavedModelData;
export interface SaveRelationships {
[relationship: string]: Model | Model[];
}
export interface SaveAttributes {
[key: string]: unknown;
relationships?: SaveRelationships;
}
/** /**
* The `Model` class represents a local data resource. It provides methods to * The `Model` class represents a local data resource. It provides methods to
* persist changes via the API. * persist changes via the API.
*
* @abstract
*/ */
export default abstract class Model { export default class Model {
/**
* The resource object from the API.
*/
data: ModelData;
/**
* The time at which the model's data was last updated. Watching the value
* of this property is a fast way to retain/cache a subtree if data hasn't
* changed.
*/
freshness: Date;
/**
* Whether or not the resource exists on the server.
*/
exists: boolean;
/**
* The data store that this resource should be persisted to.
*/
protected store: Store;
/**
* @param data A resource object from the API.
* @param store The data store that this model should be persisted to.
*/
constructor(data?: ModelData, store?: Store);
/**
* Get the model's ID.
*
* @final
*/
id(): string | undefined;
/**
* Get one of the model's attributes.
*
* @final
*/
attribute<T = unknown>(attribute: string): T;
/**
* Merge new data into this model locally.
*
* @param data A resource object to merge into this model
*/
pushData(data: ModelData | {
relationships?: SaveRelationships;
}): this;
/**
* Merge new attributes into this model locally.
*
* @param attributes The attributes to merge.
*/
pushAttributes(attributes: ModelAttributes): void;
/**
* Merge new attributes into this model, both locally and with persistence.
*
* @param attributes The attributes to save. If a 'relationships' key
* exists, it will be extracted and relationships will also be saved.
*/
save(attributes: SaveAttributes, options?: Omit<FlarumRequestOptions<ApiPayloadSingle>, 'url'> & {
meta?: MetaInformation;
}): Promise<ApiResponseSingle<this>>;
/**
* Send a request to delete the resource.
*
* @param body Data to send along with the DELETE request.
*/
delete(body?: FlarumRequestOptions<void>['body'], options?: Omit<FlarumRequestOptions<void>, 'url'>): Promise<void>;
/**
* Construct a path to the API endpoint for this resource.
*/
protected apiEndpoint(): string;
protected copyData(): ModelData;
protected rawRelationship<M extends Model>(relationship: string): undefined | ModelIdentifier;
protected rawRelationship<M extends Model[]>(relationship: string): undefined | ModelIdentifier[];
/** /**
* Generate a function which returns the value of the given attribute. * Generate a function which returns the value of the given attribute.
* *
* @param transform A function to transform the attribute value * @param {String} name
* @param {function} [transform] A function to transform the attribute value
* @return {*}
* @public
*/ */
static attribute<T>(name: string): () => T; public static attribute(name: string, transform?: Function | undefined): any;
static attribute<T, O = unknown>(name: string, transform: (attr: O) => T): () => T;
/** /**
* Generate a function which returns the value of the given has-one * Generate a function which returns the value of the given has-one
* relationship. * relationship.
* *
* @return false if no information about the * @param {String} name
* @return {Model|Boolean|undefined} false if no information about the
* relationship exists; undefined if the relationship exists but the model * relationship exists; undefined if the relationship exists but the model
* has not been loaded; or the model if it has been loaded. * has not been loaded; or the model if it has been loaded.
* @public
*/ */
static hasOne<M extends Model>(name: string): () => M | false; public static hasOne(name: string): Model | boolean | undefined;
static hasOne<M extends Model | null>(name: string): () => M | null | false;
/** /**
* Generate a function which returns the value of the given has-many * Generate a function which returns the value of the given has-many
* relationship. * relationship.
* *
* @return false if no information about the relationship * @param {String} name
* @return {Array|Boolean} false if no information about the relationship
* exists; an array if it does, containing models if they have been * exists; an array if it does, containing models if they have been
* loaded, and undefined for those that have not. * loaded, and undefined for those that have not.
* @public
*/ */
static hasMany<M extends Model>(name: string): () => (M | undefined)[] | false; public static hasMany(name: string): any[] | boolean;
/** /**
* Transform the given value into a Date object. * Transform the given value into a Date object.
*
* @param {String} value
* @return {Date|null}
* @public
*/ */
static transformDate(value: string): Date; public static transformDate(value: string): Date | null;
static transformDate(value: string | null): Date | null;
static transformDate(value: string | undefined): Date | undefined;
static transformDate(value: string | null | undefined): Date | null | undefined;
/** /**
* Get a resource identifier object for the given model. * Get a resource identifier object for the given model.
*
* @param {Model} model
* @return {Object}
* @protected
*/ */
protected static getIdentifier(model: Model): ModelIdentifier; protected static getIdentifier(model: Model): Object;
/**
* @param {Object} data A resource object from the API.
* @param {Store} store The data store that this model should be persisted to.
* @public
*/
constructor(data?: Object, store?: any);
/**
* The resource object from the API.
*
* @type {Object}
* @public
*/
public data: Object;
/**
* The time at which the model's data was last updated. Watching the value
* of this property is a fast way to retain/cache a subtree if data hasn't
* changed.
*
* @type {Date}
* @public
*/
public freshness: Date;
/**
* Whether or not the resource exists on the server.
*
* @type {Boolean}
* @public
*/
public exists: boolean;
/**
* The data store that this resource should be persisted to.
*
* @type {Store}
* @protected
*/
protected store: any;
/**
* Get the model's ID.
*
* @return {Integer}
* @public
* @final
*/
public id(): any;
/**
* Get one of the model's attributes.
*
* @param {String} attribute
* @return {*}
* @public
* @final
*/
public attribute(attribute: string): any;
/**
* Merge new data into this model locally.
*
* @param {Object} data A resource object to merge into this model
* @public
*/
public pushData(data: Object): void;
/**
* Merge new attributes into this model locally.
*
* @param {Object} attributes The attributes to merge.
* @public
*/
public pushAttributes(attributes: Object): void;
/**
* Merge new attributes into this model, both locally and with persistence.
*
* @param {Object} attributes The attributes to save. If a 'relationships' key
* exists, it will be extracted and relationships will also be saved.
* @param {Object} [options]
* @return {Promise}
* @public
*/
public save(attributes: Object, options?: Object | undefined): Promise<any>;
/**
* Send a request to delete the resource.
*
* @param {Object} body Data to send along with the DELETE request.
* @param {Object} [options]
* @return {Promise}
* @public
*/
public delete(body: Object, options?: Object | undefined): Promise<any>;
/**
* Construct a path to the API endpoint for this resource.
*
* @return {String}
* @protected
*/
protected apiEndpoint(): string;
copyData(): any;
} }

View File

@@ -5,8 +5,10 @@ export declare type LoginParams = {
* The username/email * The username/email
*/ */
identification: string; identification: string;
/**
* Password
*/
password: string; password: string;
remember: boolean;
}; };
/** /**
* The `Session` class defines the current user session. It stores a reference * The `Session` class defines the current user session. It stores a reference

View File

@@ -1,127 +1,97 @@
import { FlarumRequestOptions } from './Application';
import Model, { ModelData, SavedModelData } from './Model';
export interface MetaInformation {
[key: string]: any;
}
export interface ApiQueryParamsSingle {
fields?: string[];
include?: string;
bySlug?: boolean;
meta?: MetaInformation;
}
export interface ApiQueryParamsPlural {
fields?: string[];
include?: string;
filter?: {
q: string;
[key: string]: string;
};
page?: {
offset?: number;
number?: number;
limit?: number;
size?: number;
};
sort?: string;
meta?: MetaInformation;
}
export declare type ApiQueryParams = ApiQueryParamsPlural | ApiQueryParamsSingle;
export interface ApiPayloadSingle {
data: SavedModelData;
included?: SavedModelData[];
meta?: MetaInformation;
}
export interface ApiPayloadPlural {
data: SavedModelData[];
included?: SavedModelData[];
links?: {
first: string;
next?: string;
prev?: string;
};
meta?: MetaInformation;
}
export declare type ApiPayload = ApiPayloadSingle | ApiPayloadPlural;
export declare type ApiResponseSingle<M extends Model> = M & {
payload: ApiPayloadSingle;
};
export declare type ApiResponsePlural<M extends Model> = M[] & {
payload: ApiPayloadPlural;
};
export declare type ApiResponse<M extends Model> = ApiResponseSingle<M> | ApiResponsePlural<M>;
interface ApiQueryRequestOptions<ResponseType> extends Omit<FlarumRequestOptions<ResponseType>, 'url'> {
}
interface StoreData {
[type: string]: Partial<Record<string, Model>>;
}
export declare function payloadIsPlural(payload: ApiPayload): payload is ApiPayloadPlural;
/** /**
* The `Store` class defines a local data store, and provides methods to * The `Store` class defines a local data store, and provides methods to
* retrieve data from the API. * retrieve data from the API.
*/ */
export default class Store { export default class Store {
constructor(models: any);
/** /**
* The local data store. A tree of resource types to IDs, such that * The local data store. A tree of resource types to IDs, such that
* accessing data[type][id] will return the model for that type/ID. * accessing data[type][id] will return the model for that type/ID.
*
* @type {Object}
* @protected
*/ */
protected data: StoreData; protected data: Object;
/** /**
* The model registry. A map of resource types to the model class that * The model registry. A map of resource types to the model class that
* should be used to represent resources of that type. * should be used to represent resources of that type.
*
* @type {Object}
* @public
*/ */
models: Record<string, typeof Model>; public models: Object;
constructor(models: Record<string, typeof Model>);
/** /**
* Push resources contained within an API payload into the store. * Push resources contained within an API payload into the store.
* *
* @return The model(s) representing the resource(s) contained * @param {Object} payload
* @return {Model|Model[]} The model(s) representing the resource(s) contained
* within the 'data' key of the payload. * within the 'data' key of the payload.
* @public
*/ */
pushPayload<M extends Model>(payload: ApiPayloadSingle): ApiResponseSingle<M>; public pushPayload(payload: Object): any | any[];
pushPayload<Ms extends Model[]>(payload: ApiPayloadPlural): ApiResponseSingle<Ms[number]>;
/** /**
* Create a model to represent a resource object (or update an existing one), * Create a model to represent a resource object (or update an existing one),
* and push it into the store. * and push it into the store.
* *
* @param data The resource object * @param {Object} data The resource object
* @return The model, or null if no model class has been * @return {Model|null} The model, or null if no model class has been
* registered for this resource type. * registered for this resource type.
* @public
*/ */
pushObject<M extends Model>(data: SavedModelData): M | null; public pushObject(data: Object): any | null;
pushObject<M extends Model>(data: SavedModelData, allowUnregistered: false): M;
/** /**
* Make a request to the API to find record(s) of a specific type. * Make a request to the API to find record(s) of a specific type.
*
* @param {String} type The resource type.
* @param {Integer|Integer[]|Object} [id] The ID(s) of the model(s) to retrieve.
* Alternatively, if an object is passed, it will be handled as the
* `query` parameter.
* @param {Object} [query]
* @param {Object} [options]
* @return {Promise}
* @public
*/ */
find<M extends Model>(type: string, params: ApiQueryParamsSingle): Promise<ApiResponseSingle<M>>; public find(type: string, id?: any | any[] | Object, query?: Object | undefined, options?: Object | undefined): Promise<any>;
find<Ms extends Model[]>(type: string, params: ApiQueryParamsPlural): Promise<ApiResponsePlural<Ms[number]>>;
find<M extends Model>(type: string, id: string, params?: ApiQueryParamsSingle, options?: ApiQueryRequestOptions<ApiPayloadSingle>): Promise<ApiResponseSingle<M>>;
find<Ms extends Model[]>(type: string, ids: string[], params?: ApiQueryParamsPlural, options?: ApiQueryRequestOptions<ApiPayloadPlural>): Promise<ApiResponsePlural<Ms[number]>>;
/** /**
* Get a record from the store by ID. * Get a record from the store by ID.
*
* @param {String} type The resource type.
* @param {Integer} id The resource ID.
* @return {Model}
* @public
*/ */
getById<M extends Model>(type: string, id: string): M | undefined; public getById(type: string, id: any): any;
/** /**
* Get a record from the store by the value of a model attribute. * Get a record from the store by the value of a model attribute.
* *
* @param type The resource type. * @param {String} type The resource type.
* @param key The name of the method on the model. * @param {String} key The name of the method on the model.
* @param value The value of the model attribute. * @param {*} value The value of the model attribute.
* @return {Model}
* @public
*/ */
getBy<M extends Model, T = unknown>(type: string, key: keyof M, value: T): M | undefined; public getBy(type: string, key: string, value: any): any;
/** /**
* Get all loaded records of a specific type. * Get all loaded records of a specific type.
*
* @param {String} type
* @return {Model[]}
* @public
*/ */
all<M extends Model>(type: string): M[]; public all(type: string): any[];
/** /**
* Remove the given model from the store. * Remove the given model from the store.
*
* @param {Model} model
*/ */
remove(model: Model): void; remove(model: any): void;
/** /**
* Create a new record of the given type. * Create a new record of the given type.
* *
* @param type The resource type * @param {String} type The resource type
* @param data Any data to initialize the model with * @param {Object} [data] Any data to initialize the model with
* @return {Model}
* @public
*/ */
createRecord<M extends Model>(type: string, data?: ModelData): M; public createRecord(type: string, data?: Object | undefined): any;
} }
export {};

View File

@@ -1,31 +1,26 @@
/// <reference path="../../../src/common/translator-icu-rich.d.ts" /> /**
import Modal, { IInternalModalAttrs } from './Modal'; * The `EditUserModal` component displays a modal dialog with a login form.
import ItemList from '../utils/ItemList'; */
import Stream from '../utils/Stream'; export default class EditUserModal extends Modal<import("./Modal").IInternalModalAttrs> {
import type Mithril from 'mithril'; constructor();
import type User from '../models/User'; username: Stream<any> | undefined;
import type { SaveAttributes } from '../Model'; email: Stream<any> | undefined;
export interface IEditUserModalAttrs extends IInternalModalAttrs { isEmailConfirmed: Stream<any> | undefined;
user: User; setPassword: Stream<boolean> | undefined;
} password: Stream<any> | undefined;
export default class EditUserModal<CustomAttrs extends IEditUserModalAttrs = IEditUserModalAttrs> extends Modal<CustomAttrs> { groups: {} | undefined;
protected username: Stream<string>; fields(): ItemList<any>;
protected email: Stream<string>;
protected isEmailConfirmed: Stream<boolean>;
protected setPassword: Stream<boolean>;
protected password: Stream<string>;
protected groups: Record<string, Stream<boolean>>;
oninit(vnode: Mithril.Vnode<CustomAttrs, this>): void;
className(): string;
title(): import("@askvortsov/rich-icu-message-formatter").NestedStringArray;
content(): JSX.Element;
fields(): ItemList<unknown>;
activate(): void; activate(): void;
data(): SaveAttributes; data(): {
onsubmit(e: SubmitEvent): void; relationships: {};
nonAdminEditingAdmin(): boolean | null; };
nonAdminEditingAdmin(): any;
/** /**
* @internal * @internal
* @protected
*/ */
protected userIsAdmin(user: User | null): boolean | null; protected userIsAdmin(user: any): any;
} }
import Modal from "./Modal";
import Stream from "../utils/Stream";
import ItemList from "../utils/ItemList";

View File

@@ -22,8 +22,8 @@ export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IIn
/** /**
* Attributes for an alert component to show below the header. * Attributes for an alert component to show below the header.
*/ */
alertAttrs: AlertAttrs | null; alertAttrs: AlertAttrs;
oninit(vnode: Mithril.Vnode<ModalAttrs, this>): void; oninit(vnode: Mithril.VnodeDOM<ModalAttrs, this>): void;
oncreate(vnode: Mithril.VnodeDOM<ModalAttrs, this>): void; oncreate(vnode: Mithril.VnodeDOM<ModalAttrs, this>): void;
onbeforeremove(vnode: Mithril.VnodeDOM<ModalAttrs, this>): Promise<void> | void; onbeforeremove(vnode: Mithril.VnodeDOM<ModalAttrs, this>): Promise<void> | void;
/** /**
@@ -37,7 +37,7 @@ export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IIn
/** /**
* Get the title of the modal dialog. * Get the title of the modal dialog.
*/ */
abstract title(): Mithril.Children; abstract title(): string;
/** /**
* Get the content of the modal. * Get the content of the modal.
*/ */

View File

@@ -1,12 +1,4 @@
/// <reference types="mithril" /> export default class RequestErrorModal extends Modal<import("./Modal").IInternalModalAttrs> {
import type RequestError from '../utils/RequestError'; constructor();
import Modal, { IInternalModalAttrs } from './Modal';
export interface IRequestErrorModalAttrs extends IInternalModalAttrs {
error: RequestError;
formattedError: string[];
}
export default class RequestErrorModal<CustomAttrs extends IRequestErrorModalAttrs = IRequestErrorModalAttrs> extends Modal<CustomAttrs> {
className(): string;
title(): string;
content(): JSX.Element;
} }
import Modal from "./Modal";

View File

@@ -10,17 +10,3 @@
* can fix. * can fix.
*/ */
export default function fireDebugWarning(...args: Parameters<typeof console.warn>): void; export default function fireDebugWarning(...args: Parameters<typeof console.warn>): void;
/**
* Fire a Flarum deprecation warning which is shown in the JS console.
*
* These warnings are only shown when the forum is in debug mode, and the function exists to
* reduce bundle size caused by multiple warnings across our JavaScript.
*
* @param message The message to display. (Short, but sweet, please!)
* @param githubId The PR or Issue ID with more info in relation to this change.
* @param [removedFrom] The version in which this feature will be completely removed. (default: 2.0)
* @param [repo] The repo which the issue or PR is located in. (default: flarum/core)
*
* @see {@link fireDebugWarning}
*/
export declare function fireDeprecationWarning(message: string, githubId: string, removedFrom?: string, repo?: string): void;

View File

@@ -1,27 +1,16 @@
import type Mithril from 'mithril'; import type Mithril from 'mithril';
import { ComponentAttrs } from '../Component'; import Component, { ComponentAttrs } from '../Component';
declare type ModdedVnodeAttrs = { export interface ModdedVnodeAttrs {
itemClassName?: string; itemClassName?: string;
key?: string; key?: string;
}; }
declare type ModdedTag = Mithril.Vnode['tag'] & { export declare type ModdedVnode<Attrs> = Mithril.Vnode<ModdedVnodeAttrs, Component<Attrs> | {}> & {
isListItem?: boolean;
isActive?: (attrs: ComponentAttrs) => boolean;
};
declare type ModdedVnode = Mithril.Vnode<ModdedVnodeAttrs> & {
itemName?: string; itemName?: string;
itemClassName?: string; itemClassName?: string;
tag: ModdedTag; tag: Mithril.Vnode['tag'] & {
}; isListItem?: boolean;
declare type ModdedChild = ModdedVnode | string | number | boolean | null | undefined; isActive?: (attrs: ComponentAttrs) => boolean;
declare type ModdedChildArray = ModdedChildren[]; };
declare type ModdedChildren = ModdedChild | ModdedChildArray;
/**
* This type represents an element of a list returned by `ItemList.toArray()`,
* coupled with some static properties used on various components.
*/
export declare type ModdedChildrenWithItemName = ModdedChildren & {
itemName?: string;
}; };
/** /**
* The `listItems` helper wraps an array of components in the provided tag, * The `listItems` helper wraps an array of components in the provided tag,
@@ -30,5 +19,4 @@ export declare type ModdedChildrenWithItemName = ModdedChildren & {
* By default, this tag is an `<li>` tag, but this is customisable through the * By default, this tag is an `<li>` tag, but this is customisable through the
* second function parameter, `customTag`. * second function parameter, `customTag`.
*/ */
export default function listItems<Attrs extends ComponentAttrs>(rawItems: ModdedChildrenWithItemName[], customTag?: VnodeElementTag<Attrs>, attributes?: Attrs): Mithril.Vnode[]; export default function listItems<Attrs extends Record<string, unknown>>(rawItems: ModdedVnode<Attrs> | ModdedVnode<Attrs>[], customTag?: string | Component<Attrs>, attributes?: Attrs): Mithril.Vnode[];
export {};

View File

@@ -4,4 +4,4 @@ import User from '../models/User';
* The `username` helper displays a user's username in a <span class="username"> * The `username` helper displays a user's username in a <span class="username">
* tag. If the user doesn't exist, the username will be displayed as [deleted]. * tag. If the user doesn't exist, the username will be displayed as [deleted].
*/ */
export default function username(user: User | null | undefined | false): Mithril.Vnode; export default function username(user: User): Mithril.Vnode;

View File

@@ -1,48 +1,3 @@
import Model from '../Model';
import ItemList from '../utils/ItemList';
import Mithril from 'mithril';
import Post from './Post';
import User from './User';
export default class Discussion extends Model { export default class Discussion extends Model {
title(): string;
slug(): string;
createdAt(): Date | undefined;
user(): false | User | null;
firstPost(): false | Post | null;
lastPostedAt(): Date | null | undefined;
lastPostedUser(): false | User | null;
lastPost(): false | Post | null;
lastPostNumber(): number | null | undefined;
commentCount(): number | undefined;
replyCount(): Number;
posts(): false | (Post | undefined)[];
mostRelevantPost(): false | Post | null;
lastReadAt(): Date | null | undefined;
lastReadPostNumber(): number | null | undefined;
isUnread(): boolean;
isRead(): boolean;
hiddenAt(): Date | null | undefined;
hiddenUser(): false | User | null;
isHidden(): boolean;
canReply(): boolean | undefined;
canRename(): boolean | undefined;
canHide(): boolean | undefined;
canDelete(): boolean | undefined;
/**
* Remove a post from the discussion's posts relationship.
*/
removePost(id: string): void;
/**
* Get the estimated number of unread posts in this discussion for the current
* user.
*/
unreadCount(): number;
/**
* Get the Badge components that apply to this discussion.
*/
badges(): ItemList<Mithril.Children>;
/**
* Get a list of all of the post IDs in this discussion.
*/
postIds(): string[];
} }
import Model from "../Model";

View File

@@ -1,4 +1,3 @@
import Model from '../Model';
export default class Forum extends Model { export default class Forum extends Model {
apiEndpoint(): string;
} }
import Model from "../Model";

View File

@@ -1,11 +1,9 @@
import Model from '../Model'; export default Group;
export default class Group extends Model { declare class Group extends Model {
static ADMINISTRATOR_ID: string;
static GUEST_ID: string;
static MEMBER_ID: string;
nameSingular(): string;
namePlural(): string;
color(): string | null;
icon(): string | null;
isHidden(): boolean;
} }
declare namespace Group {
const ADMINISTRATOR_ID: string;
const GUEST_ID: string;
const MEMBER_ID: string;
}
import Model from "../Model";

View File

@@ -1,11 +1,3 @@
import Model from '../Model';
import User from './User';
export default class Notification extends Model { export default class Notification extends Model {
contentType(): string;
content(): string;
createdAt(): Date;
isRead(): boolean;
user(): false | User;
fromUser(): false | User | null;
subject(): false | Model | null;
} }
import Model from "../Model";

View File

@@ -1,23 +1,3 @@
import Model from '../Model';
import Discussion from './Discussion';
import User from './User';
export default class Post extends Model { export default class Post extends Model {
number(): number;
discussion(): Discussion;
createdAt(): Date;
user(): false | User;
contentType(): string | null;
content(): string | null | undefined;
contentHtml(): string | null | undefined;
renderFailed(): boolean | undefined;
contentPlain(): string | null | undefined;
editedAt(): Date | null | undefined;
editedUser(): false | User | null;
isEdited(): boolean;
hiddenAt(): Date | null | undefined;
hiddenUser(): false | User | null;
isHidden(): boolean;
canEdit(): boolean | undefined;
canHide(): boolean | undefined;
canDelete(): boolean | undefined;
} }
import Model from "../Model";

View File

@@ -1,46 +1,3 @@
import { Color } from 'color-thief-browser';
import Model from '../Model';
import ItemList from '../utils/ItemList';
import Mithril from 'mithril';
import Group from './Group';
export default class User extends Model { export default class User extends Model {
username(): string;
slug(): string;
displayName(): string;
email(): string | undefined;
isEmailConfirmed(): boolean | undefined;
password(): string | undefined;
avatarUrl(): string | null;
preferences(): Record<string, any> | null | undefined;
groups(): false | (Group | undefined)[];
joinTime(): Date | null | undefined;
lastSeenAt(): Date | null | undefined;
markedAllAsReadAt(): Date | null | undefined;
unreadNotificationCount(): number | undefined;
newNotificationCount(): number | undefined;
discussionCount(): number | undefined;
commentCount(): number | undefined;
canEdit(): boolean | undefined;
canEditCredentials(): boolean | undefined;
canEditGroups(): boolean | undefined;
canDelete(): boolean | undefined;
color(): string;
protected avatarColor: Color | null;
/**
* Check whether or not the user has been seen in the last 5 minutes.
*/
isOnline(): boolean;
/**
* Get the Badge components that apply to this user.
*/
badges(): ItemList<Mithril.Children>;
/**
* Calculate the dominant color of the user's avatar. The dominant color will
* be set to the `avatarColor` property once it has been calculated.
*/
protected calculateAvatarColor(): void;
/**
* Update the user's preferences.
*/
savePreferences(newPreferences: Record<string, unknown>): Promise<this>;
} }
import Model from "../Model";

View File

@@ -24,7 +24,7 @@ export default class AlertManagerState {
*/ */
show(children: Mithril.Children): AlertIdentifier; show(children: Mithril.Children): AlertIdentifier;
show(attrs: AlertAttrs, children: Mithril.Children): AlertIdentifier; show(attrs: AlertAttrs, children: Mithril.Children): AlertIdentifier;
show(componentClass: typeof Alert, attrs: AlertAttrs, children: Mithril.Children): AlertIdentifier; show(componentClass: Alert, attrs: AlertAttrs, children: Mithril.Children): AlertIdentifier;
/** /**
* Dismiss an alert. * Dismiss an alert.
*/ */

View File

@@ -1,16 +1,4 @@
import type Component from '../Component';
import Modal from '../components/Modal'; import Modal from '../components/Modal';
/**
* Ideally, `show` would take a higher-kinded generic, ala:
* `show<Attrs, C>(componentClass: C<Attrs>, attrs: Attrs): void`
* Unfortunately, TypeScript does not support this:
* https://github.com/Microsoft/TypeScript/issues/1213
* Therefore, we have to use this ugly, messy workaround.
*/
declare type UnsafeModalClass = ComponentClass<any, Modal> & {
isDismissible: boolean;
component: typeof Component.component;
};
/** /**
* Class used to manage modal state. * Class used to manage modal state.
* *
@@ -21,7 +9,7 @@ export default class ModalManagerState {
* @internal * @internal
*/ */
modal: null | { modal: null | {
componentClass: UnsafeModalClass; componentClass: typeof Modal;
attrs?: Record<string, unknown>; attrs?: Record<string, unknown>;
}; };
private closeTimeout?; private closeTimeout?;
@@ -37,7 +25,7 @@ export default class ModalManagerState {
* // This "hack" is needed due to quirks with nested redraws in Mithril. * // This "hack" is needed due to quirks with nested redraws in Mithril.
* setTimeout(() => app.modal.show(MyCoolModal, { attr: 'value' }), 0); * setTimeout(() => app.modal.show(MyCoolModal, { attr: 'value' }), 0);
*/ */
show(componentClass: UnsafeModalClass, attrs?: Record<string, unknown>): void; show(componentClass: typeof Modal, attrs?: Record<string, unknown>): void;
/** /**
* Closes the currently open dialog, if one is open. * Closes the currently open dialog, if one is open.
*/ */
@@ -49,4 +37,3 @@ export default class ModalManagerState {
*/ */
isModalOpen(): boolean; isModalOpen(): boolean;
} }
export {};

View File

@@ -1,5 +1,4 @@
import Model from '../Model'; import Model from '../Model';
import { ApiQueryParamsPlural, ApiResponsePlural } from '../Store';
export interface Page<TModel> { export interface Page<TModel> {
number: number; number: number;
items: TModel[]; items: TModel[];
@@ -14,9 +13,6 @@ export interface PaginationLocation {
export interface PaginatedListParams { export interface PaginatedListParams {
[key: string]: any; [key: string]: any;
} }
export interface PaginatedListRequestParams extends Omit<ApiQueryParamsPlural, 'include'> {
include?: string | string[];
}
export default abstract class PaginatedListState<T extends Model, P extends PaginatedListParams = PaginatedListParams> { export default abstract class PaginatedListState<T extends Model, P extends PaginatedListParams = PaginatedListParams> {
protected location: PaginationLocation; protected location: PaginationLocation;
protected pageSize: number; protected pageSize: number;
@@ -30,11 +26,11 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
clear(): void; clear(): void;
loadPrev(): Promise<void>; loadPrev(): Promise<void>;
loadNext(): Promise<void>; loadNext(): Promise<void>;
protected parseResults(pg: number, results: ApiResponsePlural<T>): void; protected parseResults(pg: number, results: T[]): void;
/** /**
* Load a new page of results. * Load a new page of results.
*/ */
protected loadPage(page?: number): Promise<ApiResponsePlural<T>>; protected loadPage(page?: number): Promise<T[]>;
/** /**
* Get the parameters that should be passed in the API request. * Get the parameters that should be passed in the API request.
* Do not include page offset unless subclass overrides loadPage. * Do not include page offset unless subclass overrides loadPage.
@@ -42,7 +38,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
* @abstract * @abstract
* @see loadPage * @see loadPage
*/ */
protected requestParams(): PaginatedListRequestParams; protected requestParams(): any;
/** /**
* Update the `this.params` object, calling `refresh` if they have changed. * Update the `this.params` object, calling `refresh` if they have changed.
* Use `requestParams` for converting `this.params` into API parameters * Use `requestParams` for converting `this.params` into API parameters
@@ -77,7 +73,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
/** /**
* Stored state parameters. * Stored state parameters.
*/ */
getParams(): P; getParams(): any;
protected getNextPageNumber(): number; protected getNextPageNumber(): number;
protected getPrevPageNumber(): number; protected getPrevPageNumber(): number;
protected paramsChanged(newParams: P): boolean; protected paramsChanged(newParams: P): boolean;

View File

@@ -1,6 +1,6 @@
import type Mithril from 'mithril'; import type Mithril from 'mithril';
import type { AlertAttrs } from '../components/Alert';
export declare type InternalFlarumRequestOptions<ResponseType> = Mithril.RequestOptions<ResponseType> & { export declare type InternalFlarumRequestOptions<ResponseType> = Mithril.RequestOptions<ResponseType> & {
errorHandler: (error: RequestError) => void;
url: string; url: string;
}; };
export default class RequestError<ResponseType = string> { export default class RequestError<ResponseType = string> {
@@ -16,6 +16,6 @@ export default class RequestError<ResponseType = string> {
[key: string]: unknown; [key: string]: unknown;
}[]; }[];
} | null; } | null;
alert: AlertAttrs | null; alert: any;
constructor(status: number, responseText: string | null, options: InternalFlarumRequestOptions<ResponseType>, xhr: XMLHttpRequest); constructor(status: number, responseText: string | null, options: InternalFlarumRequestOptions<ResponseType>, xhr: XMLHttpRequest);
} }

View File

@@ -1,4 +1,3 @@
import Model from '../Model';
/** /**
* The `computed` utility creates a function that will cache its output until * The `computed` utility creates a function that will cache its output until
* any of the dependent values are dirty. * any of the dependent values are dirty.
@@ -8,4 +7,4 @@ import Model from '../Model';
* dependent values. * dependent values.
* @return {Function} * @return {Function}
*/ */
export default function computed<T, M = Model>(...args: [...string[], (this: M, ...args: unknown[]) => T]): () => T; export default function computed(...dependentKeys: string[]): Function;

View File

@@ -9,7 +9,6 @@ interface StyleArgs {
scanFor: string; scanFor: string;
surroundWithNewlines: boolean; surroundWithNewlines: boolean;
orderedList: boolean; orderedList: boolean;
unorderedList: boolean;
trimFirst: boolean; trimFirst: boolean;
} }
export default function styleSelectedText(textarea: HTMLTextAreaElement, styleArgs: StyleArgs): void; export default function styleSelectedText(textarea: HTMLTextAreaElement, styleArgs: StyleArgs): void;

View File

@@ -3,7 +3,6 @@ import Page, { IPageAttrs } from '../../common/components/Page';
import ItemList from '../../common/utils/ItemList'; import ItemList from '../../common/utils/ItemList';
import PostStreamState from '../states/PostStreamState'; import PostStreamState from '../states/PostStreamState';
import Discussion from '../../common/models/Discussion'; import Discussion from '../../common/models/Discussion';
import { ApiResponseSingle } from '../../common/Store';
export interface IDiscussionPageAttrs extends IPageAttrs { export interface IDiscussionPageAttrs extends IPageAttrs {
id: string; id: string;
near?: number; near?: number;
@@ -78,7 +77,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
/** /**
* Initialize the component to display the given discussion. * Initialize the component to display the given discussion.
*/ */
show(discussion: ApiResponseSingle<Discussion>): void; show(discussion: Discussion): void;
/** /**
* Build an item list for the contents of the sidebar. * Build an item list for the contents of the sidebar.
*/ */

View File

@@ -1,25 +1,25 @@
/// <reference path="../../../src/common/translator-icu-rich.d.ts" />
import Modal, { IInternalModalAttrs } from '../../common/components/Modal';
import Stream from '../../common/utils/Stream';
import Mithril from 'mithril';
import RequestError from '../../common/utils/RequestError';
export interface IForgotPasswordModalAttrs extends IInternalModalAttrs {
email?: string;
}
/** /**
* The `ForgotPasswordModal` component displays a modal which allows the user to * The `ForgotPasswordModal` component displays a modal which allows the user to
* enter their email address and request a link to reset their password. * enter their email address and request a link to reset their password.
*
* ### Attrs
*
* - `email`
*/ */
export default class ForgotPasswordModal<CustomAttrs extends IForgotPasswordModalAttrs = IForgotPasswordModalAttrs> extends Modal<CustomAttrs> { export default class ForgotPasswordModal extends Modal<import("../../common/components/Modal").IInternalModalAttrs> {
constructor();
/** /**
* The value of the email input. * The value of the email input.
*
* @type {Function}
*/ */
email: Stream<string>; email: Function | undefined;
success: boolean; /**
oninit(vnode: Mithril.Vnode<CustomAttrs, this>): void; * Whether or not the password reset email was sent successfully.
className(): string; *
title(): import("@askvortsov/rich-icu-message-formatter").NestedStringArray; * @type {Boolean}
content(): JSX.Element; */
onsubmit(e: SubmitEvent): void; success: boolean | undefined;
onerror(error: RequestError): void; alert: any;
} }
import Modal from "../../common/components/Modal";

View File

@@ -1,45 +1,48 @@
/// <reference path="../../../src/common/translator-icu-rich.d.ts" /> /**
import Modal, { IInternalModalAttrs } from '../../common/components/Modal'; * The `LogInModal` component displays a modal dialog with a login form.
import ItemList from '../../common/utils/ItemList'; *
import Stream from '../../common/utils/Stream'; * ### Attrs
import type Mithril from 'mithril'; *
import RequestError from '../../common/utils/RequestError'; * - `identification`
export interface ILoginModalAttrs extends IInternalModalAttrs { * - `password`
identification?: string; */
password?: string; export default class LogInModal extends Modal<import("../../common/components/Modal").IInternalModalAttrs> {
remember?: boolean; constructor();
}
export default class LogInModal<CustomAttrs extends ILoginModalAttrs = ILoginModalAttrs> extends Modal<CustomAttrs> {
/** /**
* The value of the identification input. * The value of the identification input.
*
* @type {Function}
*/ */
identification: Stream<string>; identification: Function | undefined;
/** /**
* The value of the password input. * The value of the password input.
*
* @type {Function}
*/ */
password: Stream<string>; password: Function | undefined;
/** /**
* The value of the remember me input. * The value of the remember me input.
*
* @type {Function}
*/ */
remember: Stream<boolean>; remember: Function | undefined;
oninit(vnode: Mithril.Vnode<CustomAttrs, this>): void;
className(): string;
title(): import("@askvortsov/rich-icu-message-formatter").NestedStringArray;
content(): JSX.Element[];
body(): JSX.Element[]; body(): JSX.Element[];
fields(): ItemList<unknown>; fields(): ItemList<any>;
footer(): (string | JSX.Element)[]; footer(): (string | JSX.Element)[];
/** /**
* Open the forgot password modal, prefilling it with an email if the user has * Open the forgot password modal, prefilling it with an email if the user has
* entered one. * entered one.
*
* @public
*/ */
forgotPassword(): void; public forgotPassword(): void;
/** /**
* Open the sign up modal, prefilling it with an email/username/password if * Open the sign up modal, prefilling it with an email/username/password if
* the user has entered one. * the user has entered one.
*
* @public
*/ */
signUp(): void; public signUp(): void;
onready(): void;
onsubmit(e: SubmitEvent): void;
onerror(error: RequestError): void;
} }
import Modal from "../../common/components/Modal";
import ItemList from "../../common/utils/ItemList";

View File

@@ -4,7 +4,6 @@
*/ */
export default class NotificationList extends Component<import("../../common/Component").ComponentAttrs, undefined> { export default class NotificationList extends Component<import("../../common/Component").ComponentAttrs, undefined> {
constructor(); constructor();
controlItems(): ItemList<any>;
content(state: any): any; content(state: any): any;
$notifications: JQuery<HTMLElement> | undefined; $notifications: JQuery<HTMLElement> | undefined;
$scrollParent: JQuery<HTMLElement> | JQuery<Window & typeof globalThis> | undefined; $scrollParent: JQuery<HTMLElement> | JQuery<Window & typeof globalThis> | undefined;
@@ -17,4 +16,3 @@ export default class NotificationList extends Component<import("../../common/Com
inPanel(): boolean; inPanel(): boolean;
} }
import Component from "../../common/Component"; import Component from "../../common/Component";
import ItemList from "../../common/utils/ItemList";

View File

@@ -1,8 +1,8 @@
export default class NotificationsDropdown extends Dropdown { export default class NotificationsDropdown extends Dropdown {
onclick(): void; onclick(): void;
goToRoute(): void; goToRoute(): void;
getUnreadCount(): number | undefined; getUnreadCount(): any;
getNewCount(): number | undefined; getNewCount(): any;
menuClick(e: any): void; menuClick(e: any): void;
} }
import Dropdown from "../../common/components/Dropdown"; import Dropdown from "../../common/components/Dropdown";

View File

@@ -42,22 +42,12 @@ export interface SearchAttrs extends ComponentAttrs {
* *
* - state: SearchState instance. * - state: SearchState instance.
*/ */
export default class Search<T extends SearchAttrs = SearchAttrs> extends Component<T, SearchState> { export default class Search<T extends SearchAttrs = SearchAttrs> extends Component<T> {
/** /**
* The minimum query length before sources are searched. * The minimum query length before sources are searched.
*/ */
protected static MIN_SEARCH_LEN: number; protected static MIN_SEARCH_LEN: number;
/**
* The instance of `SearchState` for this component.
*/
protected searchState: SearchState; protected searchState: SearchState;
/**
* The instance of `SearchState` for this component.
*
* @deprecated Replace with`this.searchState` instead.
*/
protected get state(): SearchState;
protected set state(state: SearchState);
/** /**
* Whether or not the search input has focus. * Whether or not the search input has focus.
*/ */

View File

@@ -1,43 +1,36 @@
/// <reference path="../../../src/common/translator-icu-rich.d.ts" /> /**
import Modal, { IInternalModalAttrs } from '../../common/components/Modal'; * The `SignUpModal` component displays a modal dialog with a singup form.
import ItemList from '../../common/utils/ItemList'; *
import Stream from '../../common/utils/Stream'; * ### Attrs
import type Mithril from 'mithril'; *
export interface ISignupModalAttrs extends IInternalModalAttrs { * - `username`
username?: string; * - `email`
email?: string; * - `password`
password?: string; * - `token` An email token to sign up with.
token?: string; */
provided?: string[]; export default class SignUpModal extends Modal<import("../../common/components/Modal").IInternalModalAttrs> {
} constructor();
export declare type SignupBody = {
username: string;
email: string;
} & ({
token: string;
} | {
password: string;
});
export default class SignUpModal<CustomAttrs extends ISignupModalAttrs = ISignupModalAttrs> extends Modal<CustomAttrs> {
/** /**
* The value of the username input. * The value of the username input.
*
* @type {Function}
*/ */
username: Stream<string>; username: Function | undefined;
/** /**
* The value of the email input. * The value of the email input.
*
* @type {Function}
*/ */
email: Stream<string>; email: Function | undefined;
/** /**
* The value of the password input. * The value of the password input.
*
* @type {Function}
*/ */
password: Stream<string>; password: Function | undefined;
oninit(vnode: Mithril.Vnode<CustomAttrs, this>): void; isProvided(field: any): any;
className(): string; body(): (string | JSX.Element)[];
title(): import("@askvortsov/rich-icu-message-formatter").NestedStringArray; fields(): ItemList<any>;
content(): JSX.Element[];
isProvided(field: string): boolean;
body(): (false | JSX.Element)[];
fields(): ItemList<unknown>;
footer(): JSX.Element[]; footer(): JSX.Element[];
/** /**
* Open the log in modal, prefilling it with an email/username/password if * Open the log in modal, prefilling it with an email/username/password if
@@ -45,11 +38,14 @@ export default class SignUpModal<CustomAttrs extends ISignupModalAttrs = ISignup
* *
* @public * @public
*/ */
logIn(): void; public logIn(): void;
onready(): void;
onsubmit(e: SubmitEvent): void;
/** /**
* Get the data that should be submitted in the sign-up request. * Get the data that should be submitted in the sign-up request.
*
* @return {Object}
* @protected
*/ */
submitData(): SignupBody; protected submitData(): Object;
} }
import Modal from "../../common/components/Modal";
import ItemList from "../../common/utils/ItemList";

View File

@@ -1,26 +1,13 @@
import Component from '../../common/Component';
import type Mithril from 'mithril';
export interface IWelcomeHeroAttrs {
}
/** /**
* The `WelcomeHero` component displays a hero that welcomes the user to the * The `WelcomeHero` component displays a hero that welcomes the user to the
* forum. * forum.
*/ */
export default class WelcomeHero extends Component<IWelcomeHeroAttrs> { export default class WelcomeHero extends Component<import("../../common/Component").ComponentAttrs, undefined> {
/** constructor();
* @deprecated Extend the `isHidden` method instead. hidden: string | boolean | null | undefined;
*/
hidden: boolean;
oninit(vnode: Mithril.Vnode<IWelcomeHeroAttrs, this>): void;
view(vnode: Mithril.Vnode<IWelcomeHeroAttrs, this>): JSX.Element | null;
/** /**
* Hide the welcome hero. * Hide the welcome hero.
*/ */
hide(): void; hide(): void;
/**
* Determines whether the welcome hero should be hidden.
*
* @returns if the welcome hero is hidden.
*/
isHidden(): boolean;
} }
import Component from "../../common/Component";

View File

@@ -1,6 +1,10 @@
import PaginatedListState, { Page, PaginatedListParams, PaginatedListRequestParams } from '../../common/states/PaginatedListState'; import PaginatedListState, { Page, PaginatedListParams } from '../../common/states/PaginatedListState';
import Discussion from '../../common/models/Discussion'; import Discussion from '../../common/models/Discussion';
import { ApiResponsePlural } from '../../common/Store'; export interface IRequestParams {
include: string[];
filter: Record<string, string>;
sort?: string;
}
export interface DiscussionListParams extends PaginatedListParams { export interface DiscussionListParams extends PaginatedListParams {
sort?: string; sort?: string;
} }
@@ -8,8 +12,8 @@ export default class DiscussionListState<P extends DiscussionListParams = Discus
protected extraDiscussions: Discussion[]; protected extraDiscussions: Discussion[];
constructor(params: P, page?: number); constructor(params: P, page?: number);
get type(): string; get type(): string;
requestParams(): PaginatedListRequestParams; requestParams(): IRequestParams;
protected loadPage(page?: number): Promise<ApiResponsePlural<Discussion>>; protected loadPage(page?: number): Promise<Discussion[]>;
clear(): void; clear(): void;
/** /**
* Get a map of sort keys (which appear in the URL, and are used for * Get a map of sort keys (which appear in the URL, and are used for

2
js/dist/admin.js generated vendored

File diff suppressed because one or more lines are too long

29
js/dist/admin.js.LICENSE.txt generated vendored
View File

@@ -9,30 +9,6 @@
* Date: 2021-02-16 * Date: 2021-02-16
*/ */
/*!
* Block below copied from Protovis: http://mbostock.github.com/protovis/
* Copyright 2010 Stanford Visualization Group
* Licensed under the BSD License: http://www.opensource.org/licenses/bsd-license.php
*/
/*!
* Color Thief v2.0
* by Lokesh Dhakar - http://www.lokeshdhakar.com
*
* Thanks
* ------
* Nick Rabinowitz - For creating quantize.js.
* John Schulz - For clean up and optimization. @JFSIII
* Nathan Spady - For adding drag and drop support to the demo page.
*
* License
* -------
* Copyright 2011, 2015 Lokesh Dhakar
* Released under the MIT license
* https://raw.githubusercontent.com/lokesh/color-thief/master/LICENSE
*
*/
/*! /*!
* jQuery JavaScript Library v3.6.0 * jQuery JavaScript Library v3.6.0
* https://jquery.com/ * https://jquery.com/
@@ -52,11 +28,6 @@
* @license MIT, https://github.com/focus-trap/focus-trap/blob/master/LICENSE * @license MIT, https://github.com/focus-trap/focus-trap/blob/master/LICENSE
*/ */
/*!
* quantize.js Copyright 2008 Nick Rabinowitz.
* Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php
*/
/*! /*!
* tabbable 5.2.1 * tabbable 5.2.1
* @license MIT, https://github.com/focus-trap/tabbable/blob/master/LICENSE * @license MIT, https://github.com/focus-trap/tabbable/blob/master/LICENSE

2
js/dist/admin.js.map generated vendored

File diff suppressed because one or more lines are too long

2
js/dist/forum.js generated vendored

File diff suppressed because one or more lines are too long

2
js/dist/forum.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -21,20 +21,7 @@ declare type KeysOfType<Type extends object, Match> = {
*/ */
declare type KeyOfType<Type extends object, Match> = KeysOfType<Type, Match>[keyof Type]; declare type KeyOfType<Type extends object, Match> = KeysOfType<Type, Match>[keyof Type];
type Component<A> = import('mithril').Component<A>; declare type VnodeElementTag<Attrs = Record<string, unknown>, State = Record<string, unknown>> = string | ComponentTypes<Attrs, State>;
declare type ComponentClass<Attrs = Record<string, unknown>, C extends Component<Attrs> = Component<Attrs>> = {
new (...args: any[]): Component<Attrs>;
prototype: C;
};
/**
* Unfortunately, TypeScript only supports strings and classes for JSX tags.
* Therefore, our type definition should only allow for those two types.
*
* @see https://github.com/microsoft/TypeScript/issues/14789#issuecomment-412247771
*/
declare type VnodeElementTag<Attrs = Record<string, unknown>, C extends Component<Attrs> = Component<Attrs>> = string | ComponentClass<Attrs, C>;
/** /**
* @deprecated Please import `app` from a namespace instead of using it as a global variable. * @deprecated Please import `app` from a namespace instead of using it as a global variable.
@@ -59,17 +46,6 @@ declare const app: never;
declare const m: import('mithril').Static; declare const m: import('mithril').Static;
declare const dayjs: typeof import('dayjs'); declare const dayjs: typeof import('dayjs');
/**
* From https://github.com/lokesh/color-thief/issues/188
*/
declare module 'color-thief-browser' {
type Color = [number, number, number];
export default class ColorThief {
getColor: (img: HTMLImageElement | null) => Color;
getPalette: (img: HTMLImageElement | null) => Color[];
}
}
type ESModule = { __esModule: true; [key: string]: unknown }; type ESModule = { __esModule: true; [key: string]: unknown };
/** /**

View File

@@ -44,9 +44,9 @@ export default class AdminApplication extends Application {
history = { history = {
canGoBack: () => true, canGoBack: () => true,
getPrevious: () => {}, getPrevious: () => {},
backUrl: () => this.forum.attribute<string>('baseUrl'), backUrl: () => this.forum.attribute('baseUrl'),
back: function () { back: function () {
window.location.assign(this.backUrl()); window.location = this.backUrl();
}, },
}; };

View File

@@ -13,8 +13,8 @@ import generateElementId from '../utils/generateElementId';
import ColorPreviewInput from '../../common/components/ColorPreviewInput'; import ColorPreviewInput from '../../common/components/ColorPreviewInput';
export interface AdminHeaderOptions { export interface AdminHeaderOptions {
title: Mithril.Children; title: string;
description: Mithril.Children; description: string;
icon: string; icon: string;
/** /**
* Will be used as the class for the AdminPage. * Will be used as the class for the AdminPage.

View File

@@ -16,7 +16,6 @@ import RequestError from '../../common/utils/RequestError';
import { Extension } from '../AdminApplication'; import { Extension } from '../AdminApplication';
import { IPageAttrs } from '../../common/components/Page'; import { IPageAttrs } from '../../common/components/Page';
import type Mithril from 'mithril'; import type Mithril from 'mithril';
import extractText from '../../common/utils/extractText';
export interface ExtensionPageAttrs extends IPageAttrs { export interface ExtensionPageAttrs extends IPageAttrs {
id: string; id: string;
@@ -157,7 +156,7 @@ export default class ExtensionPage<Attrs extends ExtensionPageAttrs = ExtensionP
if (!this.isEnabled()) { if (!this.isEnabled()) {
const purge = () => { const purge = () => {
if (confirm(extractText(app.translator.trans('core.admin.extension.confirm_purge')))) { if (confirm(app.translator.trans('core.admin.extension.confirm_purge'))) {
app app
.request({ .request({
url: app.forum.attribute('apiUrl') + '/extensions/' + this.extension.id, url: app.forum.attribute('apiUrl') + '/extensions/' + this.extension.id,

View File

@@ -1,9 +1,7 @@
import app from '../../admin/app'; import app from '../../admin/app';
import Modal, { IInternalModalAttrs } from '../../common/components/Modal'; import Modal from '../../common/components/Modal';
export interface ILoadingModalAttrs extends IInternalModalAttrs {} export default class LoadingModal<ModalAttrs = {}> extends Modal<ModalAttrs> {
export default class LoadingModal<ModalAttrs extends ILoadingModalAttrs = ILoadingModalAttrs> extends Modal<ModalAttrs> {
/** /**
* @inheritdoc * @inheritdoc
*/ */

View File

@@ -38,6 +38,7 @@ export default class PermissionDropdown extends Dropdown {
attrs.className = 'PermissionDropdown'; attrs.className = 'PermissionDropdown';
attrs.buttonClassName = 'Button Button--text'; attrs.buttonClassName = 'Button Button--text';
attrs.lazyDraw = true;
} }
view(vnode) { view(vnode) {

View File

@@ -38,13 +38,11 @@ export default class PermissionGrid<CustomAttrs extends IPermissionGridAttrs = I
const permissionCells = (permission: PermissionGridEntry | { children: PermissionGridEntry[] }) => { const permissionCells = (permission: PermissionGridEntry | { children: PermissionGridEntry[] }) => {
return scopes.map((scope) => { return scopes.map((scope) => {
// This indicates the "permission" is a permission category,
// in which case we return an empty table cell.
if ('children' in permission) { if ('children' in permission) {
return <td></td>; return <td></td>;
} }
return <td>{scope.render(permission)}</td>; return scope.render(permission);
}); });
}; };
@@ -418,7 +416,7 @@ export default class PermissionGrid<CustomAttrs extends IPermissionGridAttrs = I
}); });
} }
return null; return '';
}, },
}, },
100 100

View File

@@ -1,21 +1,11 @@
import app from '../../admin/app'; import app from '../../admin/app';
import Modal, { IInternalModalAttrs } from '../../common/components/Modal'; import Modal from '../../common/components/Modal';
import LoadingIndicator from '../../common/components/LoadingIndicator'; import LoadingIndicator from '../../common/components/LoadingIndicator';
import Placeholder from '../../common/components/Placeholder'; import Placeholder from '../../common/components/Placeholder';
import ExtensionReadme from '../models/ExtensionReadme'; import ExtensionReadme from '../models/ExtensionReadme';
import type Mithril from 'mithril';
import type { Extension } from '../AdminApplication';
export interface IReadmeModalAttrs extends IInternalModalAttrs { export default class ReadmeModal extends Modal {
extension: Extension; oninit(vnode) {
}
export default class ReadmeModal<CustomAttrs extends IReadmeModalAttrs = IReadmeModalAttrs> extends Modal<CustomAttrs> {
protected name!: string;
protected extName!: string;
protected readme!: ExtensionReadme;
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode); super.oninit(vnode);
app.store.models['extension-readmes'] = ExtensionReadme; app.store.models['extension-readmes'] = ExtensionReadme;

View File

@@ -1,4 +1,4 @@
import app from '../app'; import app from '../../admin/app';
import SelectDropdown from '../../common/components/SelectDropdown'; import SelectDropdown from '../../common/components/SelectDropdown';
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
import saveSettings from '../utils/saveSettings'; import saveSettings from '../utils/saveSettings';
@@ -11,23 +11,18 @@ export default class SettingDropdown extends SelectDropdown {
attrs.buttonClassName = 'Button Button--text'; attrs.buttonClassName = 'Button Button--text';
attrs.caretIcon = 'fas fa-caret-down'; attrs.caretIcon = 'fas fa-caret-down';
attrs.defaultLabel = 'Custom'; attrs.defaultLabel = 'Custom';
if ('key' in attrs) {
attrs.setting = attrs.key;
delete attrs.key;
}
} }
view(vnode) { view(vnode) {
return super.view({ return super.view({
...vnode, ...vnode,
children: this.attrs.options.map(({ value, label }) => { children: this.attrs.options.map(({ value, label }) => {
const active = app.data.settings[this.attrs.setting] === value; const active = app.data.settings[this.attrs.key] === value;
return Button.component( return Button.component(
{ {
icon: active ? 'fas fa-check' : true, icon: active ? 'fas fa-check' : true,
onclick: saveSettings.bind(this, { [this.attrs.setting]: value }), onclick: saveSettings.bind(this, { [this.attrs.key]: value }),
active, active,
}, },
label label

View File

@@ -26,7 +26,7 @@ export default class StatusWidget extends DashboardWidget {
buttonClassName="Button" buttonClassName="Button"
menuClassName="Dropdown-menu--right" menuClassName="Dropdown-menu--right"
> >
{this.toolsItems().toArray()} <Button onclick={this.handleClearCache.bind(this)}>{app.translator.trans('core.admin.dashboard.clear_cache_button')}</Button>
</Dropdown> </Dropdown>
); );
@@ -37,17 +37,6 @@ export default class StatusWidget extends DashboardWidget {
return items; return items;
} }
toolsItems() {
const items = new ItemList();
items.add(
'clearCache',
<Button onclick={this.handleClearCache.bind(this)}>{app.translator.trans('core.admin.dashboard.clear_cache_button')}</Button>
);
return items;
}
handleClearCache(e) { handleClearCache(e) {
app.modal.show(LoadingModal); app.modal.show(LoadingModal);

View File

@@ -1,5 +1,3 @@
import type Mithril from 'mithril';
import app from '../../admin/app'; import app from '../../admin/app';
import EditUserModal from '../../common/components/EditUserModal'; import EditUserModal from '../../common/components/EditUserModal';
@@ -16,18 +14,30 @@ import classList from '../../common/utils/classList';
import extractText from '../../common/utils/extractText'; import extractText from '../../common/utils/extractText';
import AdminPage from './AdminPage'; import AdminPage from './AdminPage';
import Mithril from 'mithril';
type ColumnData = { type ColumnData = {
/** /**
* Column title * Column title
*/ */
name: Mithril.Children; name: String;
/** /**
* Component(s) to show for this column. * Component(s) to show for this column.
*/ */
content: (user: User) => Mithril.Children; content: (user: User) => JSX.Element;
}; };
type ApiPayload = {
data: Record<string, unknown>[];
included: Record<string, unknown>[];
links: {
first: string;
next?: string;
};
};
type UsersApiResponse = User[] & { payload: ApiPayload };
/** /**
* Admin page which displays a paginated list of all users on the forum. * Admin page which displays a paginated list of all users on the forum.
*/ */
@@ -175,7 +185,7 @@ export default class UserListPage extends AdminPage {
'id', 'id',
{ {
name: app.translator.trans('core.admin.users.grid.columns.user_id.title'), name: app.translator.trans('core.admin.users.grid.columns.user_id.title'),
content: (user: User) => user.id() ?? '', content: (user: User) => user.id(),
}, },
100 100
); );
@@ -338,15 +348,15 @@ export default class UserListPage extends AdminPage {
if (pageNumber < 0) pageNumber = 0; if (pageNumber < 0) pageNumber = 0;
app.store app.store
.find<User[]>('users', { .find('users', {
page: { page: {
limit: this.numPerPage, limit: this.numPerPage,
offset: pageNumber * this.numPerPage, offset: pageNumber * this.numPerPage,
}, },
}) })
.then((apiData) => { .then((apiData: UsersApiResponse) => {
// Next link won't be present if there's no more data // Next link won't be present if there's no more data
this.moreData = !!apiData.payload?.links?.next; this.moreData = !!apiData.payload.links.next;
let data = apiData; let data = apiData;

View File

@@ -6,7 +6,7 @@ import ModalManager from './components/ModalManager';
import AlertManager from './components/AlertManager'; import AlertManager from './components/AlertManager';
import RequestErrorModal from './components/RequestErrorModal'; import RequestErrorModal from './components/RequestErrorModal';
import Translator from './Translator'; import Translator from './Translator';
import Store, { ApiPayload, ApiResponse, ApiResponsePlural, ApiResponseSingle, payloadIsPlural } from './Store'; import Store from './Store';
import Session from './Session'; import Session from './Session';
import extract from './utils/extract'; import extract from './utils/extract';
import Drawer from './utils/Drawer'; import Drawer from './utils/Drawer';
@@ -31,7 +31,6 @@ import type DefaultResolver from './resolvers/DefaultResolver';
import type Mithril from 'mithril'; import type Mithril from 'mithril';
import type Component from './Component'; import type Component from './Component';
import type { ComponentAttrs } from './Component'; import type { ComponentAttrs } from './Component';
import Model, { SavedModelData } from './Model';
export type FlarumScreens = 'phone' | 'tablet' | 'desktop' | 'desktop-hd'; export type FlarumScreens = 'phone' | 'tablet' | 'desktop' | 'desktop-hd';
@@ -211,10 +210,10 @@ export default class Application {
drawer!: Drawer; drawer!: Drawer;
data!: { data!: {
apiDocument: ApiPayload | null; apiDocument: Record<string, unknown> | null;
locale: string; locale: string;
locales: Record<string, string>; locales: Record<string, string>;
resources: SavedModelData[]; resources: Record<string, unknown>[];
session: { userId: number; csrfToken: string }; session: { userId: number; csrfToken: string };
[key: string]: unknown; [key: string]: unknown;
}; };
@@ -256,9 +255,9 @@ export default class Application {
this.store.pushPayload({ data: this.data.resources }); this.store.pushPayload({ data: this.data.resources });
this.forum = this.store.getById('forums', '1')!; this.forum = this.store.getById('forums', 1);
this.session = new Session(this.store.getById<User>('users', String(this.data.session.userId)) ?? null, this.data.session.csrfToken); this.session = new Session(this.store.getById('users', this.data.session.userId), this.data.session.csrfToken);
this.mount(); this.mount();
@@ -318,14 +317,10 @@ export default class Application {
/** /**
* Get the API response document that has been preloaded into the application. * Get the API response document that has been preloaded into the application.
*/ */
preloadedApiDocument<M extends Model>(): ApiResponseSingle<M> | null; preloadedApiDocument(): Record<string, unknown> | null {
preloadedApiDocument<Ms extends Model[]>(): ApiResponsePlural<Ms[number]> | null;
preloadedApiDocument<M extends Model | Model[]>(): ApiResponse<FlatArray<M, 1>> | null {
// If the URL has changed, the preloaded Api document is invalid. // If the URL has changed, the preloaded Api document is invalid.
if (this.data.apiDocument && window.location.href === this.initialRoute) { if (this.data.apiDocument && window.location.href === this.initialRoute) {
const results = payloadIsPlural(this.data.apiDocument) const results = this.store.pushPayload(this.data.apiDocument);
? this.store.pushPayload<FlatArray<M, 1>[]>(this.data.apiDocument)
: this.store.pushPayload<FlatArray<M, 1>>(this.data.apiDocument);
this.data.apiDocument = null; this.data.apiDocument = null;
@@ -371,7 +366,7 @@ export default class Application {
} }
protected transformRequestOptions<ResponseType>(flarumOptions: FlarumRequestOptions<ResponseType>): InternalFlarumRequestOptions<ResponseType> { protected transformRequestOptions<ResponseType>(flarumOptions: FlarumRequestOptions<ResponseType>): InternalFlarumRequestOptions<ResponseType> {
const { background, deserialize, extract, modifyText, ...tmpOptions } = { ...flarumOptions }; const { background, deserialize, errorHandler, extract, modifyText, ...tmpOptions } = { ...flarumOptions };
// Unless specified otherwise, requests should run asynchronously in the // Unless specified otherwise, requests should run asynchronously in the
// background, so that they don't prevent redraws from occurring. // background, so that they don't prevent redraws from occurring.
@@ -385,6 +380,10 @@ export default class Application {
// so it errors due to Mithril's typings // so it errors due to Mithril's typings
const defaultDeserialize = (response: string) => response as ResponseType; 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 // 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 // response code and show an error message to the user if something's gone
// awry. // awry.
@@ -393,6 +392,7 @@ export default class Application {
const options: InternalFlarumRequestOptions<ResponseType> = { const options: InternalFlarumRequestOptions<ResponseType> = {
background: background ?? defaultBackground, background: background ?? defaultBackground,
deserialize: deserialize ?? defaultDeserialize, deserialize: deserialize ?? defaultDeserialize,
errorHandler: errorHandler ?? defaultErrorHandler,
...tmpOptions, ...tmpOptions,
}; };
@@ -455,95 +455,91 @@ export default class Application {
* @param options * @param options
* @return {Promise} * @return {Promise}
*/ */
request<ResponseType>(originalOptions: FlarumRequestOptions<ResponseType>): Promise<ResponseType> { request<ResponseType>(originalOptions: FlarumRequestOptions<ResponseType>): Promise<ResponseType | string> {
const options = this.transformRequestOptions(originalOptions); const options = this.transformRequestOptions(originalOptions);
if (this.requestErrorAlert) this.alerts.dismiss(this.requestErrorAlert); if (this.requestErrorAlert) this.alerts.dismiss(this.requestErrorAlert);
return m.request(options).catch((e) => this.requestErrorCatch(e, originalOptions.errorHandler)); // 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: RequestError) => {
let content;
/** switch (error.status) {
* By default, show an error alert, and log the error to the console. case 422:
*/ content = ((error.response?.errors ?? {}) as Record<string, unknown>[])
protected requestErrorCatch<ResponseType>(error: RequestError, customErrorHandler: FlarumRequestOptions<ResponseType>['errorHandler']) { .map((error) => [error.detail, <br />])
// the details property is decoded to transform escaped characters such as '\n' .flat()
const formattedErrors = error.response?.errors?.map((e) => decodeURI(e.detail ?? '')) ?? []; .slice(0, -1);
break;
let content; case 401:
switch (error.status) { case 403:
case 422: content = app.translator.trans('core.lib.error.permission_denied_message');
content = formattedErrors break;
.map((detail) => [detail, <br />])
.flat()
.slice(0, -1);
break;
case 401: case 404:
case 403: case 410:
content = app.translator.trans('core.lib.error.permission_denied_message'); content = app.translator.trans('core.lib.error.not_found_message');
break; break;
case 404: case 413:
case 410: content = app.translator.trans('core.lib.error.payload_too_large_message');
content = app.translator.trans('core.lib.error.not_found_message'); break;
break;
case 413: case 429:
content = app.translator.trans('core.lib.error.payload_too_large_message'); content = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
break; break;
case 429: default:
content = app.translator.trans('core.lib.error.rate_limit_exceeded_message'); content = app.translator.trans('core.lib.error.generic_message');
break; }
default: const isDebug = app.forum.attribute('debug');
content = app.translator.trans('core.lib.error.generic_message'); // 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]?.detail && errors.map((e) => decodeURI(e.detail ?? ''))) || undefined;
const isDebug: boolean = app.forum.attribute('debug'); error.alert = {
type: 'error',
content,
controls: isDebug && [
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error, formattedError)}>
{app.translator.trans('core.lib.debug_button')}
</Button>,
],
};
error.alert = { try {
type: 'error', options.errorHandler(error);
content, } catch (error) {
controls: isDebug && [ if (error instanceof RequestError) {
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error, formattedErrors)}> if (isDebug && error.xhr) {
{app.translator.trans('core.lib.debug_button')} const { method, url } = error.options;
</Button>, const { status = '' } = error.xhr;
],
};
if (customErrorHandler) { console.group(`${method} ${url} ${status}`);
customErrorHandler(error);
} else {
this.requestErrorDefaultHandler(error, isDebug, formattedErrors);
}
return Promise.reject(error); console.error(...(formattedError || [error]));
}
protected requestErrorDefaultHandler(e: unknown, isDebug: boolean, formattedErrors: string[]): void { console.groupEnd();
if (e instanceof RequestError) { }
if (isDebug && e.xhr) {
const { method, url } = e.options;
const { status = '' } = e.xhr;
console.group(`${method} ${url} ${status}`); this.requestErrorAlert = this.alerts.show(error.alert, error.alert.content);
} else {
throw error;
}
}
console.error(...(formattedErrors || [e])); return Promise.reject(error);
console.groupEnd();
} }
);
if (e.alert) {
this.requestErrorAlert = this.alerts.show(e.alert, e.alert.content);
}
} else {
throw e;
}
} }
private showDebug(error: RequestError, formattedError: string[]) { private showDebug(error: RequestError, formattedError?: string[]) {
if (this.requestErrorAlert !== null) this.alerts.dismiss(this.requestErrorAlert); if (this.requestErrorAlert !== null) this.alerts.dismiss(this.requestErrorAlert);
this.modal.show(RequestErrorModal, { error, formattedError }); this.modal.show(RequestErrorModal, { error, formattedError });

323
js/src/common/Model.js Normal file
View File

@@ -0,0 +1,323 @@
import app from '../common/app';
/**
* The `Model` class represents a local data resource. It provides methods to
* persist changes via the API.
*
* @abstract
*/
export default class Model {
/**
* @param {Object} data A resource object from the API.
* @param {Store} store The data store that this model should be persisted to.
* @public
*/
constructor(data = {}, store = null) {
/**
* The resource object from the API.
*
* @type {Object}
* @public
*/
this.data = data;
/**
* The time at which the model's data was last updated. Watching the value
* of this property is a fast way to retain/cache a subtree if data hasn't
* changed.
*
* @type {Date}
* @public
*/
this.freshness = new Date();
/**
* Whether or not the resource exists on the server.
*
* @type {Boolean}
* @public
*/
this.exists = false;
/**
* The data store that this resource should be persisted to.
*
* @type {Store}
* @protected
*/
this.store = store;
}
/**
* Get the model's ID.
*
* @return {Integer}
* @public
* @final
*/
id() {
return this.data.id;
}
/**
* Get one of the model's attributes.
*
* @param {String} attribute
* @return {*}
* @public
* @final
*/
attribute(attribute) {
return this.data.attributes[attribute];
}
/**
* Merge new data into this model locally.
*
* @param {Object} data A resource object to merge into this model
* @public
*/
pushData(data) {
// Since most of the top-level items in a resource object are objects
// (e.g. relationships, attributes), we'll need to check and perform the
// merge at the second level if that's the case.
for (const key in data) {
if (typeof data[key] === 'object') {
this.data[key] = this.data[key] || {};
// For every item in a second-level object, we want to check if we've
// been handed a Model instance. If so, we will convert it to a
// relationship data object.
for (const innerKey in data[key]) {
if (data[key][innerKey] instanceof Model) {
data[key][innerKey] = { data: Model.getIdentifier(data[key][innerKey]) };
}
this.data[key][innerKey] = data[key][innerKey];
}
} else {
this.data[key] = data[key];
}
}
// Now that we've updated the data, we can say that the model is fresh.
// This is an easy way to invalidate retained subtrees etc.
this.freshness = new Date();
}
/**
* Merge new attributes into this model locally.
*
* @param {Object} attributes The attributes to merge.
* @public
*/
pushAttributes(attributes) {
this.pushData({ attributes });
}
/**
* Merge new attributes into this model, both locally and with persistence.
*
* @param {Object} attributes The attributes to save. If a 'relationships' key
* exists, it will be extracted and relationships will also be saved.
* @param {Object} [options]
* @return {Promise}
* @public
*/
save(attributes, options = {}) {
const data = {
type: this.data.type,
id: this.data.id,
attributes,
};
// If a 'relationships' key exists, extract it from the attributes hash and
// set it on the top-level data object instead. We will be sending this data
// object to the API for persistence.
if (attributes.relationships) {
data.relationships = {};
for (const key in attributes.relationships) {
const model = attributes.relationships[key];
data.relationships[key] = {
data: model instanceof Array ? model.map(Model.getIdentifier) : Model.getIdentifier(model),
};
}
delete attributes.relationships;
}
// Before we update the model's data, we should make a copy of the model's
// old data so that we can revert back to it if something goes awry during
// persistence.
const oldData = this.copyData();
this.pushData(data);
const request = { data };
if (options.meta) request.meta = options.meta;
return app
.request(
Object.assign(
{
method: this.exists ? 'PATCH' : 'POST',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
body: request,
},
options
)
)
.then(
// If everything went well, we'll make sure the store knows that this
// model exists now (if it didn't already), and we'll push the data that
// the API returned into the store.
(payload) => {
this.store.data[payload.data.type] = this.store.data[payload.data.type] || {};
this.store.data[payload.data.type][payload.data.id] = this;
return this.store.pushPayload(payload);
},
// If something went wrong, though... good thing we backed up our model's
// old data! We'll revert to that and let others handle the error.
(response) => {
this.pushData(oldData);
m.redraw();
throw response;
}
);
}
/**
* Send a request to delete the resource.
*
* @param {Object} body Data to send along with the DELETE request.
* @param {Object} [options]
* @return {Promise}
* @public
*/
delete(body, options = {}) {
if (!this.exists) return Promise.resolve();
return app
.request(
Object.assign(
{
method: 'DELETE',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
body,
},
options
)
)
.then(() => {
this.exists = false;
this.store.remove(this);
});
}
/**
* Construct a path to the API endpoint for this resource.
*
* @return {String}
* @protected
*/
apiEndpoint() {
return '/' + this.data.type + (this.exists ? '/' + this.data.id : '');
}
copyData() {
return JSON.parse(JSON.stringify(this.data));
}
/**
* Generate a function which returns the value of the given attribute.
*
* @param {String} name
* @param {function} [transform] A function to transform the attribute value
* @return {*}
* @public
*/
static attribute(name, transform) {
return function () {
const value = this.data.attributes && this.data.attributes[name];
return transform ? transform(value) : value;
};
}
/**
* Generate a function which returns the value of the given has-one
* relationship.
*
* @param {String} name
* @return {Model|Boolean|undefined} false if no information about the
* relationship exists; undefined if the relationship exists but the model
* has not been loaded; or the model if it has been loaded.
* @public
*/
static hasOne(name) {
return function () {
if (this.data.relationships) {
const relationship = this.data.relationships[name];
if (relationship) {
return app.store.getById(relationship.data.type, relationship.data.id);
}
}
return false;
};
}
/**
* Generate a function which returns the value of the given has-many
* relationship.
*
* @param {String} name
* @return {Array|Boolean} false if no information about the relationship
* exists; an array if it does, containing models if they have been
* loaded, and undefined for those that have not.
* @public
*/
static hasMany(name) {
return function () {
if (this.data.relationships) {
const relationship = this.data.relationships[name];
if (relationship) {
return relationship.data.map((data) => app.store.getById(data.type, data.id));
}
}
return false;
};
}
/**
* Transform the given value into a Date object.
*
* @param {String} value
* @return {Date|null}
* @public
*/
static transformDate(value) {
return value ? new Date(value) : null;
}
/**
* Get a resource identifier object for the given model.
*
* @param {Model} model
* @return {Object}
* @protected
*/
static getIdentifier(model) {
if (!model) return model;
return {
type: model.data.type,
id: model.data.id,
};
}
}

View File

@@ -1,360 +0,0 @@
import app from '../common/app';
import { FlarumRequestOptions } from './Application';
import Store, { ApiPayloadSingle, ApiResponseSingle, MetaInformation } from './Store';
export interface ModelIdentifier {
type: string;
id: string;
}
export interface ModelAttributes {
[key: string]: unknown;
}
export interface ModelRelationships {
[relationship: string]: {
data: ModelIdentifier | ModelIdentifier[];
};
}
export interface UnsavedModelData {
type?: string;
attributes?: ModelAttributes;
relationships?: ModelRelationships;
}
export interface SavedModelData {
type: string;
id: string;
attributes?: ModelAttributes;
relationships?: ModelRelationships;
}
export type ModelData = UnsavedModelData | SavedModelData;
export interface SaveRelationships {
[relationship: string]: Model | Model[];
}
export interface SaveAttributes {
[key: string]: unknown;
relationships?: SaveRelationships;
}
/**
* The `Model` class represents a local data resource. It provides methods to
* persist changes via the API.
*/
export default abstract class Model {
/**
* The resource object from the API.
*/
data: ModelData = {};
/**
* The time at which the model's data was last updated. Watching the value
* of this property is a fast way to retain/cache a subtree if data hasn't
* changed.
*/
freshness: Date = new Date();
/**
* Whether or not the resource exists on the server.
*/
exists: boolean = false;
/**
* The data store that this resource should be persisted to.
*/
protected store: Store;
/**
* @param data A resource object from the API.
* @param store The data store that this model should be persisted to.
*/
constructor(data: ModelData = {}, store = app.store) {
this.data = data;
this.store = store;
}
/**
* Get the model's ID.
*
* @final
*/
id(): string | undefined {
return 'id' in this.data ? this.data.id : undefined;
}
/**
* Get one of the model's attributes.
*
* @final
*/
attribute<T = unknown>(attribute: string): T {
return this.data?.attributes?.[attribute] as T;
}
/**
* Merge new data into this model locally.
*
* @param data A resource object to merge into this model
*/
pushData(data: ModelData | { relationships?: SaveRelationships }): this {
if ('id' in data) {
(this.data as SavedModelData).id = data.id;
}
if ('type' in data) {
this.data.type = data.type;
}
if ('attributes' in data) {
this.data.attributes ||= {};
Object.assign(this.data.attributes, data.attributes);
}
if ('relationships' in data) {
const relationships = this.data.relationships ?? {};
// For every relationship field, we need to check if we've
// been handed a Model instance. If so, we will convert it to a
// relationship data object.
for (const r in data.relationships) {
const relationship = data.relationships[r];
let identifier: ModelRelationships[string];
if (relationship instanceof Model) {
identifier = { data: Model.getIdentifier(relationship) };
} else if (relationship instanceof Array) {
identifier = { data: relationship.map(Model.getIdentifier) };
} else {
identifier = relationship;
}
data.relationships[r] = identifier;
relationships[r] = identifier;
}
this.data.relationships = relationships;
}
// Now that we've updated the data, we can say that the model is fresh.
// This is an easy way to invalidate retained subtrees etc.
this.freshness = new Date();
return this;
}
/**
* Merge new attributes into this model locally.
*
* @param attributes The attributes to merge.
*/
pushAttributes(attributes: ModelAttributes) {
this.pushData({ attributes });
}
/**
* Merge new attributes into this model, both locally and with persistence.
*
* @param attributes The attributes to save. If a 'relationships' key
* exists, it will be extracted and relationships will also be saved.
*/
save(
attributes: SaveAttributes,
options: Omit<FlarumRequestOptions<ApiPayloadSingle>, 'url'> & { meta?: MetaInformation } = {}
): Promise<ApiResponseSingle<this>> {
const data: ModelData & { id?: string } = {
type: this.data.type,
attributes,
};
if ('id' in this.data) {
data.id = this.data.id;
}
// If a 'relationships' key exists, extract it from the attributes hash and
// set it on the top-level data object instead. We will be sending this data
// object to the API for persistence.
if (attributes.relationships) {
data.relationships = {};
for (const key in attributes.relationships) {
const model = attributes.relationships[key];
data.relationships[key] = {
data: model instanceof Array ? model.map(Model.getIdentifier) : Model.getIdentifier(model),
};
}
delete attributes.relationships;
}
// Before we update the model's data, we should make a copy of the model's
// old data so that we can revert back to it if something goes awry during
// persistence.
const oldData = this.copyData();
this.pushData(data);
const request = {
data,
meta: options.meta || undefined,
};
return app
.request<ApiPayloadSingle>({
method: this.exists ? 'PATCH' : 'POST',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
body: request,
...options,
})
.then(
// If everything went well, we'll make sure the store knows that this
// model exists now (if it didn't already), and we'll push the data that
// the API returned into the store.
(payload) => {
return this.store.pushPayload<this>(payload);
},
// If something went wrong, though... good thing we backed up our model's
// old data! We'll revert to that and let others handle the error.
(err: Error) => {
this.pushData(oldData);
m.redraw();
throw err;
}
);
}
/**
* Send a request to delete the resource.
*
* @param body Data to send along with the DELETE request.
*/
delete(body: FlarumRequestOptions<void>['body'] = {}, options: Omit<FlarumRequestOptions<void>, 'url'> = {}): Promise<void> {
if (!this.exists) return Promise.resolve();
return app
.request({
method: 'DELETE',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
body,
...options,
})
.then(() => {
this.exists = false;
this.store.remove(this);
});
}
/**
* Construct a path to the API endpoint for this resource.
*/
protected apiEndpoint(): string {
return '/' + this.data.type + ('id' in this.data ? '/' + this.data.id : '');
}
protected copyData(): ModelData {
return JSON.parse(JSON.stringify(this.data));
}
protected rawRelationship<M extends Model>(relationship: string): undefined | ModelIdentifier;
protected rawRelationship<M extends Model[]>(relationship: string): undefined | ModelIdentifier[];
protected rawRelationship<_M extends Model | Model[]>(relationship: string): undefined | ModelIdentifier | ModelIdentifier[] {
return this.data.relationships?.[relationship]?.data;
}
/**
* Generate a function which returns the value of the given attribute.
*
* @param transform A function to transform the attribute value
*/
static attribute<T>(name: string): () => T;
static attribute<T, O = unknown>(name: string, transform: (attr: O) => T): () => T;
static attribute<T, O = unknown>(name: string, transform?: (attr: O) => T): () => T {
return function (this: Model) {
if (transform) {
return transform(this.attribute(name));
}
return this.attribute(name);
};
}
/**
* Generate a function which returns the value of the given has-one
* relationship.
*
* @return false if no information about the
* relationship exists; undefined if the relationship exists but the model
* has not been loaded; or the model if it has been loaded.
*/
static hasOne<M extends Model>(name: string): () => M | false;
static hasOne<M extends Model | null>(name: string): () => M | null | false;
static hasOne<M extends Model>(name: string): () => M | false {
return function (this: Model) {
const relationshipData = this.data.relationships?.[name]?.data;
if (relationshipData && relationshipData instanceof Array) {
throw new Error(`Relationship ${name} on model ${this.data.type} is plural, so the hasOne method cannot be used to access it.`);
}
if (relationshipData) {
return this.store.getById<M>(relationshipData.type, relationshipData.id) as M;
}
return false;
};
}
/**
* Generate a function which returns the value of the given has-many
* relationship.
*
* @return false if no information about the relationship
* exists; an array if it does, containing models if they have been
* loaded, and undefined for those that have not.
*/
static hasMany<M extends Model>(name: string): () => (M | undefined)[] | false {
return function (this: Model) {
const relationshipData = this.data.relationships?.[name]?.data;
if (relationshipData && !(relationshipData instanceof Array)) {
throw new Error(`Relationship ${name} on model ${this.data.type} is singular, so the hasMany method cannot be used to access it.`);
}
if (relationshipData) {
return relationshipData.map((data) => this.store.getById<M>(data.type, data.id));
}
return false;
};
}
/**
* Transform the given value into a Date object.
*/
static transformDate(value: string): Date;
static transformDate(value: string | null): Date | null;
static transformDate(value: string | undefined): Date | undefined;
static transformDate(value: string | null | undefined): Date | null | undefined;
static transformDate(value: string | null | undefined): Date | null | undefined {
return value != null ? new Date(value) : value;
}
/**
* Get a resource identifier object for the given model.
*/
protected static getIdentifier(model: Model): ModelIdentifier;
protected static getIdentifier(model?: Model): ModelIdentifier | null {
if (!model || !('id' in model.data)) return null;
return {
type: model.data.type,
id: model.data.id,
};
}
}

View File

@@ -7,8 +7,11 @@ export type LoginParams = {
* The username/email * The username/email
*/ */
identification: string; identification: string;
/**
* Password
*/
password: string; password: string;
remember: boolean;
}; };
/** /**

171
js/src/common/Store.js Normal file
View File

@@ -0,0 +1,171 @@
import app from '../common/app';
/**
* The `Store` class defines a local data store, and provides methods to
* retrieve data from the API.
*/
export default class Store {
constructor(models) {
/**
* The local data store. A tree of resource types to IDs, such that
* accessing data[type][id] will return the model for that type/ID.
*
* @type {Object}
* @protected
*/
this.data = {};
/**
* The model registry. A map of resource types to the model class that
* should be used to represent resources of that type.
*
* @type {Object}
* @public
*/
this.models = models;
}
/**
* Push resources contained within an API payload into the store.
*
* @param {Object} payload
* @return {Model|Model[]} The model(s) representing the resource(s) contained
* within the 'data' key of the payload.
* @public
*/
pushPayload(payload) {
if (payload.included) payload.included.map(this.pushObject.bind(this));
const result = payload.data instanceof Array ? payload.data.map(this.pushObject.bind(this)) : this.pushObject(payload.data);
// Attach the original payload to the model that we give back. This is
// useful to consumers as it allows them to access meta information
// associated with their request.
result.payload = payload;
return result;
}
/**
* Create a model to represent a resource object (or update an existing one),
* and push it into the store.
*
* @param {Object} data The resource object
* @return {Model|null} The model, or null if no model class has been
* registered for this resource type.
* @public
*/
pushObject(data) {
if (!this.models[data.type]) return null;
const type = (this.data[data.type] = this.data[data.type] || {});
if (type[data.id]) {
type[data.id].pushData(data);
} else {
type[data.id] = this.createRecord(data.type, data);
}
type[data.id].exists = true;
return type[data.id];
}
/**
* Make a request to the API to find record(s) of a specific type.
*
* @param {String} type The resource type.
* @param {Integer|Integer[]|Object} [id] The ID(s) of the model(s) to retrieve.
* Alternatively, if an object is passed, it will be handled as the
* `query` parameter.
* @param {Object} [query]
* @param {Object} [options]
* @return {Promise}
* @public
*/
find(type, id, query = {}, options = {}) {
let params = query;
let url = app.forum.attribute('apiUrl') + '/' + type;
if (id instanceof Array) {
url += '?filter[id]=' + id.join(',');
} else if (typeof id === 'object') {
params = id;
} else if (id) {
url += '/' + id;
}
return app
.request(
Object.assign(
{
method: 'GET',
url,
params,
},
options
)
)
.then(this.pushPayload.bind(this));
}
/**
* Get a record from the store by ID.
*
* @param {String} type The resource type.
* @param {Integer} id The resource ID.
* @return {Model}
* @public
*/
getById(type, id) {
return this.data[type] && this.data[type][id];
}
/**
* Get a record from the store by the value of a model attribute.
*
* @param {String} type The resource type.
* @param {String} key The name of the method on the model.
* @param {*} value The value of the model attribute.
* @return {Model}
* @public
*/
getBy(type, key, value) {
return this.all(type).filter((model) => model[key]() === value)[0];
}
/**
* Get all loaded records of a specific type.
*
* @param {String} type
* @return {Model[]}
* @public
*/
all(type) {
const records = this.data[type];
return records ? Object.keys(records).map((id) => records[id]) : [];
}
/**
* Remove the given model from the store.
*
* @param {Model} model
*/
remove(model) {
delete this.data[model.data.type][model.id()];
}
/**
* Create a new record of the given type.
*
* @param {String} type The resource type
* @param {Object} [data] Any data to initialize the model with
* @return {Model}
* @public
*/
createRecord(type, data = {}) {
data.type = data.type || type;
return new this.models[type](data, this);
}
}

View File

@@ -1,244 +0,0 @@
import app from '../common/app';
import { FlarumRequestOptions } from './Application';
import { fireDeprecationWarning } from './helpers/fireDebugWarning';
import Model, { ModelData, SavedModelData } from './Model';
export interface MetaInformation {
[key: string]: any;
}
export interface ApiQueryParamsSingle {
fields?: string[];
include?: string;
bySlug?: boolean;
meta?: MetaInformation;
}
export interface ApiQueryParamsPlural {
fields?: string[];
include?: string;
filter?: {
q: string;
[key: string]: string;
};
page?: {
offset?: number;
number?: number;
limit?: number;
size?: number;
};
sort?: string;
meta?: MetaInformation;
}
export type ApiQueryParams = ApiQueryParamsPlural | ApiQueryParamsSingle;
export interface ApiPayloadSingle {
data: SavedModelData;
included?: SavedModelData[];
meta?: MetaInformation;
}
export interface ApiPayloadPlural {
data: SavedModelData[];
included?: SavedModelData[];
links?: {
first: string;
next?: string;
prev?: string;
};
meta?: MetaInformation;
}
export type ApiPayload = ApiPayloadSingle | ApiPayloadPlural;
export type ApiResponseSingle<M extends Model> = M & { payload: ApiPayloadSingle };
export type ApiResponsePlural<M extends Model> = M[] & { payload: ApiPayloadPlural };
export type ApiResponse<M extends Model> = ApiResponseSingle<M> | ApiResponsePlural<M>;
interface ApiQueryRequestOptions<ResponseType> extends Omit<FlarumRequestOptions<ResponseType>, 'url'> {}
interface StoreData {
[type: string]: Partial<Record<string, Model>>;
}
export function payloadIsPlural(payload: ApiPayload): payload is ApiPayloadPlural {
return Array.isArray((payload as ApiPayloadPlural).data);
}
/**
* The `Store` class defines a local data store, and provides methods to
* retrieve data from the API.
*/
export default class Store {
/**
* The local data store. A tree of resource types to IDs, such that
* accessing data[type][id] will return the model for that type/ID.
*/
protected data: StoreData = {};
/**
* The model registry. A map of resource types to the model class that
* should be used to represent resources of that type.
*/
models: Record<string, typeof Model>;
constructor(models: Record<string, typeof Model>) {
this.models = models;
}
/**
* Push resources contained within an API payload into the store.
*
* @return The model(s) representing the resource(s) contained
* within the 'data' key of the payload.
*/
pushPayload<M extends Model>(payload: ApiPayloadSingle): ApiResponseSingle<M>;
pushPayload<Ms extends Model[]>(payload: ApiPayloadPlural): ApiResponseSingle<Ms[number]>;
pushPayload<M extends Model | Model[]>(payload: ApiPayload): ApiResponse<FlatArray<M, 1>> {
if (payload.included) payload.included.map(this.pushObject.bind(this));
const models = payload.data instanceof Array ? payload.data.map((o) => this.pushObject(o, false)) : this.pushObject(payload.data, false);
const result = models as ApiResponse<FlatArray<M, 1>>;
// Attach the original payload to the model that we give back. This is
// useful to consumers as it allows them to access meta information
// associated with their request.
result.payload = payload;
return result;
}
/**
* Create a model to represent a resource object (or update an existing one),
* and push it into the store.
*
* @param data The resource object
* @return The model, or null if no model class has been
* registered for this resource type.
*/
pushObject<M extends Model>(data: SavedModelData): M | null;
pushObject<M extends Model>(data: SavedModelData, allowUnregistered: false): M;
pushObject<M extends Model>(data: SavedModelData, allowUnregistered = true): M | null {
if (!this.models[data.type]) {
if (!allowUnregistered) {
setTimeout(() =>
fireDeprecationWarning(`Pushing object of type \`${data.type}\` not allowed, as type not yet registered in the store.`, '3206')
);
}
return null;
}
const type = (this.data[data.type] = this.data[data.type] || {});
// Necessary for TS to narrow correctly.
const curr = type[data.id] as M;
const instance = curr ? curr.pushData(data) : this.createRecord<M>(data.type, data);
type[data.id] = instance;
instance.exists = true;
return instance;
}
/**
* Make a request to the API to find record(s) of a specific type.
*/
async find<M extends Model>(type: string, params: ApiQueryParamsSingle): Promise<ApiResponseSingle<M>>;
async find<Ms extends Model[]>(type: string, params: ApiQueryParamsPlural): Promise<ApiResponsePlural<Ms[number]>>;
async find<M extends Model>(
type: string,
id: string,
params?: ApiQueryParamsSingle,
options?: ApiQueryRequestOptions<ApiPayloadSingle>
): Promise<ApiResponseSingle<M>>;
async find<Ms extends Model[]>(
type: string,
ids: string[],
params?: ApiQueryParamsPlural,
options?: ApiQueryRequestOptions<ApiPayloadPlural>
): Promise<ApiResponsePlural<Ms[number]>>;
async find<M extends Model | Model[]>(
type: string,
idOrParams: string | string[] | ApiQueryParams,
query: ApiQueryParams = {},
options: ApiQueryRequestOptions<M extends Array<infer _T> ? ApiPayloadPlural : ApiPayloadSingle> = {}
): Promise<ApiResponse<FlatArray<M, 1>>> {
let params = query;
let url = app.forum.attribute('apiUrl') + '/' + type;
if (idOrParams instanceof Array) {
url += '?filter[id]=' + idOrParams.join(',');
} else if (typeof idOrParams === 'object') {
params = idOrParams;
} else if (idOrParams) {
url += '/' + idOrParams;
}
return app
.request<M extends Array<infer _T> ? ApiPayloadPlural : ApiPayloadSingle>({
method: 'GET',
url,
params,
...options,
})
.then((payload) => {
if (payloadIsPlural(payload)) {
return this.pushPayload<FlatArray<M, 1>[]>(payload);
} else {
return this.pushPayload<FlatArray<M, 1>>(payload);
}
});
}
/**
* Get a record from the store by ID.
*/
getById<M extends Model>(type: string, id: string): M | undefined {
return this.data?.[type]?.[id] as M;
}
/**
* Get a record from the store by the value of a model attribute.
*
* @param type The resource type.
* @param key The name of the method on the model.
* @param value The value of the model attribute.
*/
getBy<M extends Model, T = unknown>(type: string, key: keyof M, value: T): M | undefined {
// @ts-expect-error No way to do this safely, unfortunately.
return this.all(type).filter((model) => model[key]() === value)[0] as M;
}
/**
* Get all loaded records of a specific type.
*/
all<M extends Model>(type: string): M[] {
const records = this.data[type];
return records ? (Object.values(records) as M[]) : [];
}
/**
* Remove the given model from the store.
*/
remove(model: Model): void {
delete this.data[model.data.type as string][model.id() as string];
}
/**
* Create a new record of the given type.
*
* @param type The resource type
* @param data Any data to initialize the model with
*/
createRecord<M extends Model>(type: string, data: ModelData = {}): M {
data.type = data.type || type;
// @ts-expect-error this will complain about initializing abstract models,
// but we can safely assume that all models registered with the store are
// not abstract.
return new this.models[type](data, this);
}
}

View File

@@ -1,28 +1,17 @@
import app from '../../common/app'; import app from '../../common/app';
import Modal, { IInternalModalAttrs } from './Modal'; import Modal from './Modal';
import Button from './Button'; import Button from './Button';
import GroupBadge from './GroupBadge'; import GroupBadge from './GroupBadge';
import Group from '../models/Group'; import Group from '../models/Group';
import extractText from '../utils/extractText'; import extractText from '../utils/extractText';
import ItemList from '../utils/ItemList'; import ItemList from '../utils/ItemList';
import Stream from '../utils/Stream'; import Stream from '../utils/Stream';
import type Mithril from 'mithril';
import type User from '../models/User';
import type { SaveAttributes, SaveRelationships } from '../Model';
export interface IEditUserModalAttrs extends IInternalModalAttrs { /**
user: User; * The `EditUserModal` component displays a modal dialog with a login form.
} */
export default class EditUserModal extends Modal {
export default class EditUserModal<CustomAttrs extends IEditUserModalAttrs = IEditUserModalAttrs> extends Modal<CustomAttrs> { oninit(vnode) {
protected username!: Stream<string>;
protected email!: Stream<string>;
protected isEmailConfirmed!: Stream<boolean>;
protected setPassword!: Stream<boolean>;
protected password!: Stream<string>;
protected groups: Record<string, Stream<boolean>> = {};
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode); super.oninit(vnode);
const user = this.attrs.user; const user = this.attrs.user;
@@ -30,15 +19,14 @@ export default class EditUserModal<CustomAttrs extends IEditUserModalAttrs = IEd
this.username = Stream(user.username() || ''); this.username = Stream(user.username() || '');
this.email = Stream(user.email() || ''); this.email = Stream(user.email() || '');
this.isEmailConfirmed = Stream(user.isEmailConfirmed() || false); this.isEmailConfirmed = Stream(user.isEmailConfirmed() || false);
this.setPassword = Stream(false as boolean); this.setPassword = Stream(false);
this.password = Stream(user.password() || ''); this.password = Stream(user.password() || '');
this.groups = {};
const userGroups = user.groups() || [];
app.store app.store
.all<Group>('groups') .all('groups')
.filter((group) => ![Group.GUEST_ID, Group.MEMBER_ID].includes(group.id()!)) .filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
.forEach((group) => (this.groups[group.id()!] = Stream(userGroups.includes(group)))); .forEach((group) => (this.groups[group.id()] = Stream(user.groups().indexOf(group) !== -1)));
} }
className() { className() {
@@ -61,7 +49,7 @@ export default class EditUserModal<CustomAttrs extends IEditUserModalAttrs = IEd
fields() { fields() {
const items = new ItemList(); const items = new ItemList();
if (app.session.user?.canEditCredentials()) { if (app.session.user.canEditCredentials()) {
items.add( items.add(
'username', 'username',
<div className="Form-group"> <div className="Form-group">
@@ -115,11 +103,10 @@ export default class EditUserModal<CustomAttrs extends IEditUserModalAttrs = IEd
<label className="checkbox"> <label className="checkbox">
<input <input
type="checkbox" type="checkbox"
onchange={(e: KeyboardEvent) => { onchange={(e) => {
const target = e.target as HTMLInputElement; this.setPassword(e.target.checked);
this.setPassword(target.checked);
m.redraw.sync(); m.redraw.sync();
if (target.checked) this.$('[name=password]').select(); if (e.target.checked) this.$('[name=password]').select();
e.redraw = false; e.redraw = false;
}} }}
disabled={this.nonAdminEditingAdmin()} disabled={this.nonAdminEditingAdmin()}
@@ -145,31 +132,24 @@ export default class EditUserModal<CustomAttrs extends IEditUserModalAttrs = IEd
} }
} }
if (app.session.user?.canEditGroups()) { if (app.session.user.canEditGroups()) {
items.add( items.add(
'groups', 'groups',
<div className="Form-group EditUserModal-groups"> <div className="Form-group EditUserModal-groups">
<label>{app.translator.trans('core.lib.edit_user.groups_heading')}</label> <label>{app.translator.trans('core.lib.edit_user.groups_heading')}</label>
<div> <div>
{Object.keys(this.groups) {Object.keys(this.groups)
.map((id) => app.store.getById<Group>('groups', id)) .map((id) => app.store.getById('groups', id))
.filter(Boolean) .map((group) => (
.map( <label className="checkbox">
(group) => <input
// Necessary because filter(Boolean) doesn't narrow out falsy values. type="checkbox"
group && ( bidi={this.groups[group.id()]}
<label className="checkbox"> disabled={group.id() === Group.ADMINISTRATOR_ID && (this.attrs.user === app.session.user || !this.userIsAdmin(app.session.user))}
<input />
type="checkbox" {GroupBadge.component({ group, label: '' })} {group.nameSingular()}
bidi={this.groups[group.id()!]} </label>
disabled={ ))}
group.id() === Group.ADMINISTRATOR_ID && (this.attrs.user === app.session.user || !this.userIsAdmin(app.session.user))
}
/>
{GroupBadge.component({ group, label: '' })} {group.nameSingular()}
</label>
)
)}
</div> </div>
</div>, </div>,
10 10
@@ -214,8 +194,9 @@ export default class EditUserModal<CustomAttrs extends IEditUserModalAttrs = IEd
} }
data() { data() {
const data: SaveAttributes = {}; const data = {
const relationships: SaveRelationships = {}; relationships: {},
};
if (this.attrs.user.canEditCredentials() && !this.nonAdminEditingAdmin()) { if (this.attrs.user.canEditCredentials() && !this.nonAdminEditingAdmin()) {
data.username = this.username(); data.username = this.username();
@@ -230,18 +211,15 @@ export default class EditUserModal<CustomAttrs extends IEditUserModalAttrs = IEd
} }
if (this.attrs.user.canEditGroups()) { if (this.attrs.user.canEditGroups()) {
relationships.groups = Object.keys(this.groups) data.relationships.groups = Object.keys(this.groups)
.filter((id) => this.groups[id]()) .filter((id) => this.groups[id]())
.map((id) => app.store.getById<Group>('groups', id)) .map((id) => app.store.getById('groups', id));
.filter((g): g is Group => g instanceof Group);
} }
data.relationships = relationships;
return data; return data;
} }
onsubmit(e: SubmitEvent) { onsubmit(e) {
e.preventDefault(); e.preventDefault();
this.loading = true; this.loading = true;
@@ -261,8 +239,9 @@ export default class EditUserModal<CustomAttrs extends IEditUserModalAttrs = IEd
/** /**
* @internal * @internal
* @protected
*/ */
protected userIsAdmin(user: User | null) { userIsAdmin(user) {
return user && (user.groups() || []).some((g) => g?.id() === Group.ADMINISTRATOR_ID); return user.groups().some((g) => g.id() === Group.ADMINISTRATOR_ID);
} }
} }

View File

@@ -30,9 +30,9 @@ export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IIn
/** /**
* Attributes for an alert component to show below the header. * Attributes for an alert component to show below the header.
*/ */
alertAttrs: AlertAttrs | null = null; alertAttrs!: AlertAttrs;
oninit(vnode: Mithril.Vnode<ModalAttrs, this>) { oninit(vnode: Mithril.VnodeDOM<ModalAttrs, this>) {
super.oninit(vnode); super.oninit(vnode);
// TODO: [Flarum 2.0] Remove the code below. // TODO: [Flarum 2.0] Remove the code below.
@@ -122,7 +122,7 @@ export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IIn
/** /**
* Get the title of the modal dialog. * Get the title of the modal dialog.
*/ */
abstract title(): Mithril.Children; abstract title(): string;
/** /**
* Get the content of the modal. * Get the content of the modal.

View File

@@ -1,12 +1,6 @@
import type RequestError from '../utils/RequestError'; import Modal from './Modal';
import Modal, { IInternalModalAttrs } from './Modal';
export interface IRequestErrorModalAttrs extends IInternalModalAttrs { export default class RequestErrorModal extends Modal {
error: RequestError;
formattedError: string[];
}
export default class RequestErrorModal<CustomAttrs extends IRequestErrorModalAttrs = IRequestErrorModalAttrs> extends Modal<CustomAttrs> {
className() { className() {
return 'RequestErrorModal Modal--large'; return 'RequestErrorModal Modal--large';
} }
@@ -24,10 +18,14 @@ export default class RequestErrorModal<CustomAttrs extends IRequestErrorModalAtt
// else try to parse it as JSON and stringify it with indentation // else try to parse it as JSON and stringify it with indentation
if (formattedError) { if (formattedError) {
responseText = formattedError.join('\n\n'); responseText = formattedError.join('\n\n');
} else if (error.response) {
responseText = JSON.stringify(error.response, null, 2);
} else { } else {
responseText = error.responseText; try {
const json = error.response || JSON.parse(error.responseText);
responseText = JSON.stringify(json, null, 2);
} catch (e) {
responseText = error.responseText;
}
} }
return ( return (

View File

@@ -24,8 +24,8 @@ export default function avatar(user: User, attrs: ComponentAttrs = {}): Mithril.
// uploaded image, or the first letter of their username if they haven't // uploaded image, or the first letter of their username if they haven't
// uploaded one. // uploaded one.
if (user) { if (user) {
const username = user.displayName() || '?'; const username: string = user.displayName() || '?';
const avatarUrl = user.avatarUrl(); const avatarUrl: string = user.avatarUrl();
if (hasTitle) attrs.title = attrs.title || username; if (hasTitle) attrs.title = attrs.title || username;

View File

@@ -16,21 +16,3 @@ export default function fireDebugWarning(...args: Parameters<typeof console.warn
console.warn(...args); console.warn(...args);
} }
/**
* Fire a Flarum deprecation warning which is shown in the JS console.
*
* These warnings are only shown when the forum is in debug mode, and the function exists to
* reduce bundle size caused by multiple warnings across our JavaScript.
*
* @param message The message to display. (Short, but sweet, please!)
* @param githubId The PR or Issue ID with more info in relation to this change.
* @param [removedFrom] The version in which this feature will be completely removed. (default: 2.0)
* @param [repo] The repo which the issue or PR is located in. (default: flarum/core)
*
* @see {@link fireDebugWarning}
*/
export function fireDeprecationWarning(message: string, githubId: string, removedFrom: string = '2.0', repo: string = 'flarum/core'): void {
// GitHub auto-redirects between `/pull` and `/issues` for us, so using `/pull` saves 2 bytes!
fireDebugWarning(`[Flarum ${removedFrom} Deprecation] ${message}\n\nSee: https://github.com/${repo}/pull/${githubId}`);
}

View File

@@ -3,41 +3,29 @@ import Component, { ComponentAttrs } from '../Component';
import Separator from '../components/Separator'; import Separator from '../components/Separator';
import classList from '../utils/classList'; import classList from '../utils/classList';
type ModdedVnodeAttrs = { export interface ModdedVnodeAttrs {
itemClassName?: string; itemClassName?: string;
key?: string; key?: string;
};
type ModdedTag = Mithril.Vnode['tag'] & {
isListItem?: boolean;
isActive?: (attrs: ComponentAttrs) => boolean;
};
type ModdedVnode = Mithril.Vnode<ModdedVnodeAttrs> & { itemName?: string; itemClassName?: string; tag: ModdedTag };
type ModdedChild = ModdedVnode | string | number | boolean | null | undefined;
type ModdedChildArray = ModdedChildren[];
type ModdedChildren = ModdedChild | ModdedChildArray;
/**
* This type represents an element of a list returned by `ItemList.toArray()`,
* coupled with some static properties used on various components.
*/
export type ModdedChildrenWithItemName = ModdedChildren & { itemName?: string };
function isVnode(item: ModdedChildren): item is Mithril.Vnode {
return typeof item === 'object' && item !== null && 'tag' in item;
} }
function isSeparator(item: ModdedChildren): boolean { export type ModdedVnode<Attrs> = Mithril.Vnode<ModdedVnodeAttrs, Component<Attrs> | {}> & {
return isVnode(item) && item.tag === Separator; 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: ModdedChildrenWithItemName[]): ModdedChildrenWithItemName[] { function withoutUnnecessarySeparators<Attrs>(items: ModdedVnode<Attrs>[]): ModdedVnode<Attrs>[] {
const newItems: ModdedChildrenWithItemName[] = []; const newItems: ModdedVnode<Attrs>[] = [];
let prevItem: ModdedChildren; let prevItem: ModdedVnode<Attrs>;
items.filter(Boolean).forEach((item, i: number) => { items.filter(Boolean).forEach((item: Mithril.Vnode, i: number) => {
if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) { if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) {
prevItem = item; prevItem = item;
newItems.push(item); newItems.push(item);
@@ -54,39 +42,38 @@ function withoutUnnecessarySeparators(items: ModdedChildrenWithItemName[]): Modd
* By default, this tag is an `<li>` tag, but this is customisable through the * By default, this tag is an `<li>` tag, but this is customisable through the
* second function parameter, `customTag`. * second function parameter, `customTag`.
*/ */
export default function listItems<Attrs extends ComponentAttrs>( export default function listItems<Attrs extends Record<string, unknown>>(
rawItems: ModdedChildrenWithItemName[], rawItems: ModdedVnode<Attrs> | ModdedVnode<Attrs>[],
customTag: VnodeElementTag<Attrs> = 'li', customTag: string | Component<Attrs> = 'li',
attributes: Attrs = {} as Attrs attributes: Attrs = {} as Attrs
): Mithril.Vnode[] { ): Mithril.Vnode[] {
const items = rawItems instanceof Array ? rawItems : [rawItems]; const items = rawItems instanceof Array ? rawItems : [rawItems];
const Tag = customTag; const Tag = customTag;
return withoutUnnecessarySeparators(items).map((item) => { return withoutUnnecessarySeparators(items).map((item: ModdedVnode<Attrs>) => {
const classes = [item.itemName && `item-${item.itemName}`]; const isListItem = item.tag?.isListItem;
const active = item.tag?.isActive?.(item.attrs);
const className = item.attrs?.itemClassName || item.itemClassName;
if (isVnode(item) && item.tag.isListItem) { if (isListItem) {
item.attrs = item.attrs || {}; item.attrs = item.attrs || {};
item.attrs.key = item.attrs.key || item.itemName; item.attrs.key = item.attrs.key || item.itemName;
item.key = item.attrs.key; item.key = item.attrs.key;
return item;
} }
if (isVnode(item)) { const node: Mithril.Vnode = isListItem ? (
classes.push(item.attrs?.itemClassName || item.itemClassName); item
) : (
if (item.tag.isActive?.(item.attrs)) { // @ts-expect-error `Component` does not have any construct or call signatures
classes.push('active'); <Tag
} className={classList([className, item.itemName && `item-${item.itemName}`, active && 'active'])}
} key={item?.attrs?.key || item.itemName}
{...attributes}
const key = (isVnode(item) && item?.attrs?.key) || item.itemName; >
return (
<Tag className={classList(classes)} key={key} {...attributes}>
{item} {item}
</Tag> </Tag>
); );
return node;
}); });
} }

View File

@@ -6,7 +6,7 @@ import User from '../models/User';
* The `username` helper displays a user's username in a <span class="username"> * The `username` helper displays a user's username in a <span class="username">
* tag. If the user doesn't exist, the username will be displayed as [deleted]. * tag. If the user doesn't exist, the username will be displayed as [deleted].
*/ */
export default function username(user: User | null | undefined | false): Mithril.Vnode { export default function username(user: User): Mithril.Vnode {
const name = (user && user.displayName()) || app.translator.trans('core.lib.username.deleted_text'); const name = (user && user.displayName()) || app.translator.trans('core.lib.username.deleted_text');
return <span className="username">{name}</span>; return <span className="username">{name}</span>;

View File

@@ -0,0 +1,108 @@
import app from '../../common/app';
import Model from '../Model';
import computed from '../utils/computed';
import ItemList from '../utils/ItemList';
import Badge from '../components/Badge';
export default class Discussion extends Model {}
Object.assign(Discussion.prototype, {
title: Model.attribute('title'),
slug: Model.attribute('slug'),
createdAt: Model.attribute('createdAt', Model.transformDate),
user: Model.hasOne('user'),
firstPost: Model.hasOne('firstPost'),
lastPostedAt: Model.attribute('lastPostedAt', Model.transformDate),
lastPostedUser: Model.hasOne('lastPostedUser'),
lastPost: Model.hasOne('lastPost'),
lastPostNumber: Model.attribute('lastPostNumber'),
commentCount: Model.attribute('commentCount'),
replyCount: computed('commentCount', (commentCount) => Math.max(0, commentCount - 1)),
posts: Model.hasMany('posts'),
mostRelevantPost: Model.hasOne('mostRelevantPost'),
lastReadAt: Model.attribute('lastReadAt', Model.transformDate),
lastReadPostNumber: Model.attribute('lastReadPostNumber'),
isUnread: computed('unreadCount', (unreadCount) => !!unreadCount),
isRead: computed('unreadCount', (unreadCount) => app.session.user && !unreadCount),
hiddenAt: Model.attribute('hiddenAt', Model.transformDate),
hiddenUser: Model.hasOne('hiddenUser'),
isHidden: computed('hiddenAt', (hiddenAt) => !!hiddenAt),
canReply: Model.attribute('canReply'),
canRename: Model.attribute('canRename'),
canHide: Model.attribute('canHide'),
canDelete: Model.attribute('canDelete'),
/**
* Remove a post from the discussion's posts relationship.
*
* @param {Integer} id The ID of the post to remove.
* @public
*/
removePost(id) {
const relationships = this.data.relationships;
const posts = relationships && relationships.posts;
if (posts) {
posts.data.some((data, i) => {
if (id === data.id) {
posts.data.splice(i, 1);
return true;
}
});
}
},
/**
* Get the estimated number of unread posts in this discussion for the current
* user.
*
* @return {Integer}
* @public
*/
unreadCount() {
const user = app.session.user;
if (user && user.markedAllAsReadAt() < this.lastPostedAt()) {
const unreadCount = Math.max(0, this.lastPostNumber() - (this.lastReadPostNumber() || 0));
// If posts have been deleted, it's possible that the unread count could exceed the
// actual post count. As such, we take the min of the two to ensure this isn't an issue.
return Math.min(unreadCount, this.commentCount());
}
return 0;
},
/**
* Get the Badge components that apply to this discussion.
*
* @return {ItemList}
* @public
*/
badges() {
const items = new ItemList();
if (this.isHidden()) {
items.add('hidden', <Badge type="hidden" icon="fas fa-trash" label={app.translator.trans('core.lib.badge.hidden_tooltip')} />);
}
return items;
},
/**
* Get a list of all of the post IDs in this discussion.
*
* @return {Array}
* @public
*/
postIds() {
const posts = this.data.relationships.posts;
return posts ? posts.data.map((link) => link.id) : [];
},
});

View File

@@ -1,146 +0,0 @@
import app from '../../common/app';
import Model from '../Model';
import computed from '../utils/computed';
import ItemList from '../utils/ItemList';
import Badge from '../components/Badge';
import Mithril from 'mithril';
import Post from './Post';
import User from './User';
export default class Discussion extends Model {
title() {
return Model.attribute<string>('title').call(this);
}
slug() {
return Model.attribute<string>('slug').call(this);
}
createdAt() {
return Model.attribute<Date | undefined, string | undefined>('createdAt', Model.transformDate).call(this);
}
user() {
return Model.hasOne<User | null>('user').call(this);
}
firstPost() {
return Model.hasOne<Post | null>('firstPost').call(this);
}
lastPostedAt() {
return Model.attribute('lastPostedAt', Model.transformDate).call(this);
}
lastPostedUser() {
return Model.hasOne<User | null>('lastPostedUser').call(this);
}
lastPost() {
return Model.hasOne<Post | null>('lastPost').call(this);
}
lastPostNumber() {
return Model.attribute<number | null | undefined>('lastPostNumber').call(this);
}
commentCount() {
return Model.attribute<number | undefined>('commentCount').call(this);
}
replyCount() {
return computed<Number, this>('commentCount', (commentCount) => Math.max(0, ((commentCount as number) ?? 0) - 1)).call(this);
}
posts() {
return Model.hasMany<Post>('posts').call(this);
}
mostRelevantPost() {
return Model.hasOne<Post | null>('mostRelevantPost').call(this);
}
lastReadAt() {
return Model.attribute('lastReadAt', Model.transformDate).call(this);
}
lastReadPostNumber() {
return Model.attribute<number | null | undefined>('lastReadPostNumber').call(this);
}
isUnread() {
return computed<boolean, this>('unreadCount', (unreadCount) => !!unreadCount).call(this);
}
isRead() {
return computed<boolean, this>('unreadCount', (unreadCount) => !!(app.session.user && !unreadCount)).call(this);
}
hiddenAt() {
return Model.attribute('hiddenAt', Model.transformDate).call(this);
}
hiddenUser() {
return Model.hasOne<User | null>('hiddenUser').call(this);
}
isHidden() {
return computed<boolean, this>('hiddenAt', (hiddenAt) => !!hiddenAt).call(this);
}
canReply() {
return Model.attribute<boolean | undefined>('canReply').call(this);
}
canRename() {
return Model.attribute<boolean | undefined>('canRename').call(this);
}
canHide() {
return Model.attribute<boolean | undefined>('canHide').call(this);
}
canDelete() {
return Model.attribute<boolean | undefined>('canDelete').call(this);
}
/**
* Remove a post from the discussion's posts relationship.
*/
removePost(id: string): void {
const posts = this.rawRelationship<Post[]>('posts');
if (!posts) {
return;
}
posts.some((data, i) => {
if (id === data.id) {
posts.splice(i, 1);
return true;
}
return false;
});
}
/**
* Get the estimated number of unread posts in this discussion for the current
* user.
*/
unreadCount(): number {
const user = app.session.user;
if (user && (user.markedAllAsReadAt()?.getTime() ?? 0) < this.lastPostedAt()?.getTime()!) {
const unreadCount = Math.max(0, (this.lastPostNumber() ?? 0) - (this.lastReadPostNumber() || 0));
// If posts have been deleted, it's possible that the unread count could exceed the
// actual post count. As such, we take the min of the two to ensure this isn't an issue.
return Math.min(unreadCount, this.commentCount() ?? 0);
}
return 0;
}
/**
* Get the Badge components that apply to this discussion.
*/
badges(): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
if (this.isHidden()) {
items.add('hidden', <Badge type="hidden" icon="fas fa-trash" label={app.translator.trans('core.lib.badge.hidden_tooltip')} />);
}
return items;
}
/**
* Get a list of all of the post IDs in this discussion.
*/
postIds(): string[] {
return this.rawRelationship<Post[]>('posts')?.map((link) => link.id) ?? [];
}
}

View File

@@ -0,0 +1,17 @@
import Model from '../Model';
class Group extends Model {}
Object.assign(Group.prototype, {
nameSingular: Model.attribute('nameSingular'),
namePlural: Model.attribute('namePlural'),
color: Model.attribute('color'),
icon: Model.attribute('icon'),
isHidden: Model.attribute('isHidden'),
});
Group.ADMINISTRATOR_ID = '1';
Group.GUEST_ID = '2';
Group.MEMBER_ID = '3';
export default Group;

View File

@@ -1,25 +0,0 @@
import Model from '../Model';
export default class Group extends Model {
static ADMINISTRATOR_ID = '1';
static GUEST_ID = '2';
static MEMBER_ID = '3';
nameSingular() {
return Model.attribute<string>('nameSingular').call(this);
}
namePlural() {
return Model.attribute<string>('namePlural').call(this);
}
color() {
return Model.attribute<string | null>('color').call(this);
}
icon() {
return Model.attribute<string | null>('icon').call(this);
}
isHidden() {
return Model.attribute<boolean>('isHidden').call(this);
}
}

View File

@@ -0,0 +1,15 @@
import Model from '../Model';
export default class Notification extends Model {}
Object.assign(Notification.prototype, {
contentType: Model.attribute('contentType'),
content: Model.attribute('content'),
createdAt: Model.attribute('createdAt', Model.transformDate),
isRead: Model.attribute('isRead'),
user: Model.hasOne('user'),
fromUser: Model.hasOne('fromUser'),
subject: Model.hasOne('subject'),
});

View File

@@ -1,28 +0,0 @@
import Model from '../Model';
import User from './User';
export default class Notification extends Model {
contentType() {
return Model.attribute<string>('contentType').call(this);
}
content() {
return Model.attribute<string>('content').call(this);
}
createdAt() {
return Model.attribute<Date, string>('createdAt', Model.transformDate).call(this);
}
isRead() {
return Model.attribute<boolean>('isRead').call(this);
}
user() {
return Model.hasOne<User>('user').call(this);
}
fromUser() {
return Model.hasOne<User | null>('fromUser').call(this);
}
subject() {
return Model.hasOne<Model | null>('subject').call(this);
}
}

View File

@@ -0,0 +1,31 @@
import Model from '../Model';
import computed from '../utils/computed';
import { getPlainContent } from '../utils/string';
export default class Post extends Model {}
Object.assign(Post.prototype, {
number: Model.attribute('number'),
discussion: Model.hasOne('discussion'),
createdAt: Model.attribute('createdAt', Model.transformDate),
user: Model.hasOne('user'),
contentType: Model.attribute('contentType'),
content: Model.attribute('content'),
contentHtml: Model.attribute('contentHtml'),
renderFailed: Model.attribute('renderFailed'),
contentPlain: computed('contentHtml', getPlainContent),
editedAt: Model.attribute('editedAt', Model.transformDate),
editedUser: Model.hasOne('editedUser'),
isEdited: computed('editedAt', (editedAt) => !!editedAt),
hiddenAt: Model.attribute('hiddenAt', Model.transformDate),
hiddenUser: Model.hasOne('hiddenUser'),
isHidden: computed('hiddenAt', (hiddenAt) => !!hiddenAt),
canEdit: Model.attribute('canEdit'),
canHide: Model.attribute('canHide'),
canDelete: Model.attribute('canDelete'),
});

View File

@@ -1,73 +0,0 @@
import Model from '../Model';
import computed from '../utils/computed';
import { getPlainContent } from '../utils/string';
import Discussion from './Discussion';
import User from './User';
export default class Post extends Model {
number() {
return Model.attribute<number>('number').call(this);
}
discussion() {
return Model.hasOne<Discussion>('discussion').call(this) as Discussion;
}
createdAt() {
return Model.attribute<Date, string>('createdAt', Model.transformDate).call(this);
}
user() {
return Model.hasOne<User>('user').call(this);
}
contentType() {
return Model.attribute<string | null>('contentType').call(this);
}
content() {
return Model.attribute<string | null | undefined>('content').call(this);
}
contentHtml() {
return Model.attribute<string | null | undefined>('contentHtml').call(this);
}
renderFailed() {
return Model.attribute<boolean | undefined>('renderFailed').call(this);
}
contentPlain() {
return computed<string | null | undefined>('contentHtml', (content) => {
if (typeof content === 'string') {
return getPlainContent(content);
}
return content as null | undefined;
}).call(this);
}
editedAt() {
return Model.attribute('editedAt', Model.transformDate).call(this);
}
editedUser() {
return Model.hasOne<User | null>('editedUser').call(this);
}
isEdited() {
return computed<boolean>('editedAt', (editedAt) => !!editedAt).call(this);
}
hiddenAt() {
return Model.attribute('hiddenAt', Model.transformDate).call(this);
}
hiddenUser() {
return Model.hasOne<User | null>('hiddenUser').call(this);
}
isHidden() {
return computed<boolean>('hiddenAt', (hiddenAt) => !!hiddenAt).call(this);
}
canEdit() {
return Model.attribute<boolean | undefined>('canEdit').call(this);
}
canHide() {
return Model.attribute<boolean | undefined>('canHide').call(this);
}
canDelete() {
return Model.attribute<boolean | undefined>('canDelete').call(this);
}
}

View File

@@ -0,0 +1,124 @@
/*global ColorThief*/
import Model from '../Model';
import stringToColor from '../utils/stringToColor';
import ItemList from '../utils/ItemList';
import computed from '../utils/computed';
import GroupBadge from '../components/GroupBadge';
export default class User extends Model {}
Object.assign(User.prototype, {
username: Model.attribute('username'),
slug: Model.attribute('slug'),
displayName: Model.attribute('displayName'),
email: Model.attribute('email'),
isEmailConfirmed: Model.attribute('isEmailConfirmed'),
password: Model.attribute('password'),
avatarUrl: Model.attribute('avatarUrl'),
preferences: Model.attribute('preferences'),
groups: Model.hasMany('groups'),
joinTime: Model.attribute('joinTime', Model.transformDate),
lastSeenAt: Model.attribute('lastSeenAt', Model.transformDate),
markedAllAsReadAt: Model.attribute('markedAllAsReadAt', Model.transformDate),
unreadNotificationCount: Model.attribute('unreadNotificationCount'),
newNotificationCount: Model.attribute('newNotificationCount'),
discussionCount: Model.attribute('discussionCount'),
commentCount: Model.attribute('commentCount'),
canEdit: Model.attribute('canEdit'),
canEditCredentials: Model.attribute('canEditCredentials'),
canEditGroups: Model.attribute('canEditGroups'),
canDelete: Model.attribute('canDelete'),
avatarColor: null,
color: computed('displayName', 'avatarUrl', 'avatarColor', function (displayName, avatarUrl, avatarColor) {
// If we've already calculated and cached the dominant color of the user's
// avatar, then we can return that in RGB format. If we haven't, we'll want
// to calculate it. Unless the user doesn't have an avatar, in which case
// we generate a color from their display name.
if (avatarColor) {
return 'rgb(' + avatarColor.join(', ') + ')';
} else if (avatarUrl) {
this.calculateAvatarColor();
return '';
}
return '#' + stringToColor(displayName);
}),
/**
* Check whether or not the user has been seen in the last 5 minutes.
*
* @return {Boolean}
* @public
*/
isOnline() {
return dayjs().subtract(5, 'minutes').isBefore(this.lastSeenAt());
},
/**
* Get the Badge components that apply to this user.
*
* @return {ItemList}
*/
badges() {
const items = new ItemList();
const groups = this.groups();
if (groups) {
groups.forEach((group) => {
items.add('group' + group.id(), GroupBadge.component({ group }));
});
}
return items;
},
/**
* Calculate the dominant color of the user's avatar. The dominant color will
* be set to the `avatarColor` property once it has been calculated.
*
* @protected
*/
calculateAvatarColor() {
const image = new Image();
const user = this;
image.onload = function () {
try {
const colorThief = new ColorThief();
user.avatarColor = colorThief.getColor(this);
} catch (e) {
// Completely white avatars throw errors due to a glitch in color thief
// See https://github.com/lokesh/color-thief/issues/40
if (e instanceof TypeError) {
user.avatarColor = [255, 255, 255];
} else {
throw e;
}
}
user.freshness = new Date();
m.redraw();
};
image.crossOrigin = 'anonymous';
image.src = this.avatarUrl();
},
/**
* Update the user's preferences.
*
* @param {Object} newPreferences
* @return {Promise}
*/
savePreferences(newPreferences) {
const preferences = this.preferences();
Object.assign(preferences, newPreferences);
return this.save({ preferences });
},
});

View File

@@ -1,164 +0,0 @@
import ColorThief, { Color } from 'color-thief-browser';
import Model from '../Model';
import stringToColor from '../utils/stringToColor';
import ItemList from '../utils/ItemList';
import computed from '../utils/computed';
import GroupBadge from '../components/GroupBadge';
import Mithril from 'mithril';
import Group from './Group';
export default class User extends Model {
username() {
return Model.attribute<string>('username').call(this);
}
slug() {
return Model.attribute<string>('slug').call(this);
}
displayName() {
return Model.attribute<string>('displayName').call(this);
}
email() {
return Model.attribute<string | undefined>('email').call(this);
}
isEmailConfirmed() {
return Model.attribute<boolean | undefined>('isEmailConfirmed').call(this);
}
password() {
return Model.attribute<string | undefined>('password').call(this);
}
avatarUrl() {
return Model.attribute<string | null>('avatarUrl').call(this);
}
preferences() {
return Model.attribute<Record<string, any> | null | undefined>('preferences').call(this);
}
groups() {
return Model.hasMany<Group>('groups').call(this);
}
joinTime() {
return Model.attribute('joinTime', Model.transformDate).call(this);
}
lastSeenAt() {
return Model.attribute('lastSeenAt', Model.transformDate).call(this);
}
markedAllAsReadAt() {
return Model.attribute('markedAllAsReadAt', Model.transformDate).call(this);
}
unreadNotificationCount() {
return Model.attribute<number | undefined>('unreadNotificationCount').call(this);
}
newNotificationCount() {
return Model.attribute<number | undefined>('newNotificationCount').call(this);
}
discussionCount() {
return Model.attribute<number | undefined>('discussionCount').call(this);
}
commentCount() {
return Model.attribute<number | undefined>('commentCount').call(this);
}
canEdit() {
return Model.attribute<boolean | undefined>('canEdit').call(this);
}
canEditCredentials() {
return Model.attribute<boolean | undefined>('canEditCredentials').call(this);
}
canEditGroups() {
return Model.attribute<boolean | undefined>('canEditGroups').call(this);
}
canDelete() {
return Model.attribute<boolean | undefined>('canDelete').call(this);
}
color() {
return computed<string, User>('displayName', 'avatarUrl', 'avatarColor', (displayName, avatarUrl, avatarColor) => {
// If we've already calculated and cached the dominant color of the user's
// avatar, then we can return that in RGB format. If we haven't, we'll want
// to calculate it. Unless the user doesn't have an avatar, in which case
// we generate a color from their display name.
if (avatarColor) {
return `rgb(${(avatarColor as Color).join(', ')})`;
} else if (avatarUrl) {
this.calculateAvatarColor();
return '';
}
return '#' + stringToColor(displayName as string);
}).call(this);
}
protected avatarColor: Color | null = null;
/**
* Check whether or not the user has been seen in the last 5 minutes.
*/
isOnline(): boolean {
return dayjs().subtract(5, 'minutes').isBefore(this.lastSeenAt());
}
/**
* Get the Badge components that apply to this user.
*/
badges(): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
const groups = this.groups();
if (groups) {
groups.forEach((group) => {
items.add(`group${group?.id()}`, <GroupBadge group={group} />);
});
}
return items;
}
/**
* Calculate the dominant color of the user's avatar. The dominant color will
* be set to the `avatarColor` property once it has been calculated.
*/
protected calculateAvatarColor() {
const image = new Image();
const user = this;
image.addEventListener('load', function (this: HTMLImageElement) {
try {
const colorThief = new ColorThief();
user.avatarColor = colorThief.getColor(this);
} catch (e) {
// Completely white avatars throw errors due to a glitch in color thief
// See https://github.com/lokesh/color-thief/issues/40
if (e instanceof TypeError) {
user.avatarColor = [255, 255, 255];
} else {
throw e;
}
}
user.freshness = new Date();
m.redraw();
});
image.crossOrigin = 'anonymous';
image.src = this.avatarUrl() ?? '';
}
/**
* Update the user's preferences.
*/
savePreferences(newPreferences: Record<string, unknown>): Promise<this> {
const preferences = this.preferences();
Object.assign(preferences, newPreferences);
return this.save({ preferences });
}
}

View File

@@ -27,7 +27,7 @@ export default class AlertManagerState {
*/ */
show(children: Mithril.Children): AlertIdentifier; show(children: Mithril.Children): AlertIdentifier;
show(attrs: AlertAttrs, children: Mithril.Children): AlertIdentifier; show(attrs: AlertAttrs, children: Mithril.Children): AlertIdentifier;
show(componentClass: typeof Alert, attrs: AlertAttrs, children: Mithril.Children): AlertIdentifier; show(componentClass: Alert, attrs: AlertAttrs, children: Mithril.Children): AlertIdentifier;
show(arg1: any, arg2?: any, arg3?: any) { show(arg1: any, arg2?: any, arg3?: any) {
// Assigns variables as per the above signatures // Assigns variables as per the above signatures

View File

@@ -1,15 +1,5 @@
import type Component from '../Component';
import Modal from '../components/Modal'; import Modal from '../components/Modal';
/**
* Ideally, `show` would take a higher-kinded generic, ala:
* `show<Attrs, C>(componentClass: C<Attrs>, attrs: Attrs): void`
* Unfortunately, TypeScript does not support this:
* https://github.com/Microsoft/TypeScript/issues/1213
* Therefore, we have to use this ugly, messy workaround.
*/
type UnsafeModalClass = ComponentClass<any, Modal> & { isDismissible: boolean; component: typeof Component.component };
/** /**
* Class used to manage modal state. * Class used to manage modal state.
* *
@@ -20,7 +10,7 @@ export default class ModalManagerState {
* @internal * @internal
*/ */
modal: null | { modal: null | {
componentClass: UnsafeModalClass; componentClass: typeof Modal;
attrs?: Record<string, unknown>; attrs?: Record<string, unknown>;
} = null; } = null;
@@ -38,7 +28,7 @@ export default class ModalManagerState {
* // This "hack" is needed due to quirks with nested redraws in Mithril. * // This "hack" is needed due to quirks with nested redraws in Mithril.
* setTimeout(() => app.modal.show(MyCoolModal, { attr: 'value' }), 0); * setTimeout(() => app.modal.show(MyCoolModal, { attr: 'value' }), 0);
*/ */
show(componentClass: UnsafeModalClass, attrs: Record<string, unknown> = {}): void { show(componentClass: typeof Modal, attrs: Record<string, unknown> = {}): void {
if (!(componentClass.prototype instanceof Modal)) { if (!(componentClass.prototype instanceof Modal)) {
// This is duplicated so that if the error is caught, an error message still shows up in the debug console. // 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.'; const invalidModalWarning = 'The ModalManager can only show Modals.';

View File

@@ -1,6 +1,5 @@
import app from '../../common/app'; import app from '../../common/app';
import Model from '../Model'; import Model from '../Model';
import { ApiQueryParamsPlural, ApiResponsePlural } from '../Store';
export interface Page<TModel> { export interface Page<TModel> {
number: number; number: number;
@@ -20,10 +19,6 @@ export interface PaginatedListParams {
[key: string]: any; [key: string]: any;
} }
export interface PaginatedListRequestParams extends Omit<ApiQueryParamsPlural, 'include'> {
include?: string | string[];
}
export default abstract class PaginatedListState<T extends Model, P extends PaginatedListParams = PaginatedListParams> { export default abstract class PaginatedListState<T extends Model, P extends PaginatedListParams = PaginatedListParams> {
protected location!: PaginationLocation; protected location!: PaginationLocation;
protected pageSize: number; protected pageSize: number;
@@ -44,7 +39,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
abstract get type(): string; abstract get type(): string;
public clear(): void { public clear() {
this.pages = []; this.pages = [];
m.redraw(); m.redraw();
@@ -74,15 +69,15 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
.finally(() => (this.loadingNext = false)); .finally(() => (this.loadingNext = false));
} }
protected parseResults(pg: number, results: ApiResponsePlural<T>): void { protected parseResults(pg: number, results: T[]) {
const pageNum = Number(pg); const pageNum = Number(pg);
const links = results.payload?.links; const links = results.payload?.links || {};
const page = { const page = {
number: pageNum, number: pageNum,
items: results, items: results,
hasNext: !!links?.next, hasNext: !!links.next,
hasPrev: !!links?.prev, hasPrev: !!links.prev,
}; };
if (this.isEmpty() || pageNum > this.getNextPageNumber() - 1) { if (this.isEmpty() || pageNum > this.getNextPageNumber() - 1) {
@@ -99,21 +94,18 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
/** /**
* Load a new page of results. * Load a new page of results.
*/ */
protected loadPage(page = 1): Promise<ApiResponsePlural<T>> { protected loadPage(page = 1): Promise<T[]> {
const reqParams = this.requestParams(); const params = this.requestParams();
params.page = {
const include = Array.isArray(reqParams.include) ? reqParams.include.join(',') : reqParams.include; ...params.page,
offset: this.pageSize * (page - 1),
const params: ApiQueryParamsPlural = {
...reqParams,
page: {
...reqParams.page,
offset: this.pageSize * (page - 1),
},
include,
}; };
return app.store.find<T[]>(this.type, params); if (Array.isArray(params.include)) {
params.include = params.include.join(',');
}
return app.store.find(this.type, params);
} }
/** /**
@@ -123,7 +115,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
* @abstract * @abstract
* @see loadPage * @see loadPage
*/ */
protected requestParams(): PaginatedListRequestParams { protected requestParams(): any {
return this.params; return this.params;
} }
@@ -145,7 +137,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
return Promise.resolve(); return Promise.resolve();
} }
public refresh(page: number = 1): Promise<void> { public refresh(page: number = 1) {
this.initialLoading = true; this.initialLoading = true;
this.loadingPrev = false; this.loadingPrev = false;
this.loadingNext = false; this.loadingNext = false;
@@ -155,14 +147,14 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
this.location = { page }; this.location = { page };
return this.loadPage() return this.loadPage()
.then((results) => { .then((results: T[]) => {
this.pages = []; this.pages = [];
this.parseResults(this.location.page, results); this.parseResults(this.location.page, results);
}) })
.finally(() => (this.initialLoading = false)); .finally(() => (this.initialLoading = false));
} }
public getPages(): Page<T>[] { public getPages() {
return this.pages; return this.pages;
} }
public getLocation(): PaginationLocation { public getLocation(): PaginationLocation {
@@ -211,7 +203,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
/** /**
* Stored state parameters. * Stored state parameters.
*/ */
public getParams(): P { public getParams(): any {
return this.params; return this.params;
} }

View File

@@ -1,7 +1,7 @@
import type Mithril from 'mithril'; import type Mithril from 'mithril';
import type { AlertAttrs } from '../components/Alert';
export type InternalFlarumRequestOptions<ResponseType> = Mithril.RequestOptions<ResponseType> & { export type InternalFlarumRequestOptions<ResponseType> = Mithril.RequestOptions<ResponseType> & {
errorHandler: (error: RequestError) => void;
url: string; url: string;
}; };
@@ -20,7 +20,7 @@ export default class RequestError<ResponseType = string> {
}[]; }[];
} | null; } | null;
alert: AlertAttrs | null; alert: any;
constructor(status: number, responseText: string | null, options: InternalFlarumRequestOptions<ResponseType>, xhr: XMLHttpRequest) { constructor(status: number, responseText: string | null, options: InternalFlarumRequestOptions<ResponseType>, xhr: XMLHttpRequest) {
this.status = status; this.status = status;

View File

@@ -1,5 +1,4 @@
import app from '../../common/app'; import app from '../../common/app';
import extractText from './extractText';
/** /**
* The `abbreviateNumber` utility converts a number to a shorter localized form. * The `abbreviateNumber` utility converts a number to a shorter localized form.
@@ -11,9 +10,9 @@ import extractText from './extractText';
export default function abbreviateNumber(number: number): string { export default function abbreviateNumber(number: number): string {
// TODO: translation // TODO: translation
if (number >= 1000000) { if (number >= 1000000) {
return Math.floor(number / 1000000) + extractText(app.translator.trans('core.lib.number_suffix.mega_text')); return Math.floor(number / 1000000) + app.translator.trans('core.lib.number_suffix.mega_text');
} else if (number >= 1000) { } else if (number >= 1000) {
return (number / 1000).toFixed(1) + extractText(app.translator.trans('core.lib.number_suffix.kilo_text')); return (number / 1000).toFixed(1) + app.translator.trans('core.lib.number_suffix.kilo_text');
} else { } else {
return number.toString(); return number.toString();
} }

View File

@@ -1,5 +1,3 @@
import Model from '../Model';
/** /**
* The `computed` utility creates a function that will cache its output until * The `computed` utility creates a function that will cache its output until
* any of the dependent values are dirty. * any of the dependent values are dirty.
@@ -9,21 +7,20 @@ import Model from '../Model';
* dependent values. * dependent values.
* @return {Function} * @return {Function}
*/ */
export default function computed<T, M = Model>(...args: [...string[], (this: M, ...args: unknown[]) => T]): () => T { export default function computed(...dependentKeys) {
const keys = args.slice(0, -1) as string[]; const keys = dependentKeys.slice(0, -1);
const compute = args.slice(-1)[0] as (this: M, ...args: unknown[]) => T; const compute = dependentKeys.slice(-1)[0];
const dependentValues: Record<string, unknown> = {}; const dependentValues = {};
let computedValue: T; let computedValue;
return function (this: M) { return function () {
let recompute = false; let recompute = false;
// Read all of the dependent values. If any of them have changed since last // Read all of the dependent values. If any of them have changed since last
// time, then we'll want to recompute our output. // time, then we'll want to recompute our output.
keys.forEach((key) => { keys.forEach((key) => {
const attr = (this as Record<string, unknown | (() => unknown)>)[key]; const value = typeof this[key] === 'function' ? this[key]() : this[key];
const value = typeof attr === 'function' ? attr.call(this) : attr;
if (dependentValues[key] !== value) { if (dependentValues[key] !== value) {
recompute = true; recompute = true;

View File

@@ -1,14 +1,15 @@
import { fireDeprecationWarning } from '../helpers/fireDebugWarning';
const deprecatedNotice = 'The `evented` util is deprecated and no longer supported.';
const deprecationIssueId = '2547';
/** /**
* The `evented` mixin provides methods allowing an object to trigger events, * The `evented` mixin provides methods allowing an object to trigger events,
* running externally registered event handlers. * running externally registered event handlers.
* *
* @deprecated v1.2, to be removed in v2.0 * @deprecated v1.2, to be removed in v2.0
*/ */
import fireDebugWarning from '../helpers/fireDebugWarning';
const deprecatedNotice =
'The `evented` util is deprecated and will be removed in Flarum 2.0. For more info, please see https://github.com/flarum/core/issues/2547';
export default { export default {
/** /**
* Arrays of registered event handlers, grouped by the event name. * Arrays of registered event handlers, grouped by the event name.
@@ -26,7 +27,7 @@ export default {
* @protected * @protected
*/ */
getHandlers(event) { getHandlers(event) {
fireDeprecationWarning(deprecatedNotice, deprecationIssueId); fireDebugWarning(deprecatedNotice);
this.handlers = this.handlers || {}; this.handlers = this.handlers || {};
@@ -43,7 +44,7 @@ export default {
* @public * @public
*/ */
trigger(event, ...args) { trigger(event, ...args) {
fireDeprecationWarning(deprecatedNotice, deprecationIssueId); fireDebugWarning(deprecatedNotice);
this.getHandlers(event).forEach((handler) => handler.apply(this, args)); this.getHandlers(event).forEach((handler) => handler.apply(this, args));
}, },
@@ -55,7 +56,7 @@ export default {
* @param {function} handler The function to handle the event. * @param {function} handler The function to handle the event.
*/ */
on(event, handler) { on(event, handler) {
fireDeprecationWarning(deprecatedNotice, deprecationIssueId); fireDebugWarning(deprecatedNotice);
this.getHandlers(event).push(handler); this.getHandlers(event).push(handler);
}, },
@@ -68,7 +69,7 @@ export default {
* @param {function} handler The function to handle the event. * @param {function} handler The function to handle the event.
*/ */
one(event, handler) { one(event, handler) {
fireDeprecationWarning(deprecatedNotice, deprecationIssueId); fireDebugWarning(deprecatedNotice);
const wrapper = function () { const wrapper = function () {
handler.apply(this, arguments); handler.apply(this, arguments);
@@ -86,7 +87,7 @@ export default {
* @param {function} handler The function that handles the event. * @param {function} handler The function that handles the event.
*/ */
off(event, handler) { off(event, handler) {
fireDeprecationWarning(deprecatedNotice, deprecationIssueId); fireDebugWarning(deprecatedNotice);
const handlers = this.getHandlers(event); const handlers = this.getHandlers(event);
const index = handlers.indexOf(handler); const index = handlers.indexOf(handler);

View File

@@ -28,16 +28,11 @@ export function slug(string: string): string {
export function getPlainContent(string: string): string { export function getPlainContent(string: string): string {
const html = string.replace(/(<\/p>|<br>)/g, '$1 &nbsp;').replace(/<img\b[^>]*>/gi, ' '); const html = string.replace(/(<\/p>|<br>)/g, '$1 &nbsp;').replace(/<img\b[^>]*>/gi, ' ');
const element = new DOMParser().parseFromString(html, 'text/html').documentElement; const dom = $('<div/>').html(html);
getPlainContent.removeSelectors.forEach((selector) => { dom.find(getPlainContent.removeSelectors.join(',')).remove();
const el = element.querySelectorAll(selector);
el.forEach((e) => {
e.remove();
});
});
return element.innerText.replace(/\s+/g, ' ').trim(); return dom.text().replace(/\s+/g, ' ').trim();
} }
/** /**

View File

@@ -16,7 +16,6 @@ interface StyleArgs {
scanFor: string; scanFor: string;
surroundWithNewlines: boolean; surroundWithNewlines: boolean;
orderedList: boolean; orderedList: boolean;
unorderedList: boolean;
trimFirst: boolean; trimFirst: boolean;
} }
@@ -31,7 +30,6 @@ const defaults: StyleArgs = {
scanFor: '', scanFor: '',
surroundWithNewlines: false, surroundWithNewlines: false,
orderedList: false, orderedList: false,
unorderedList: false,
trimFirst: false, trimFirst: false,
}; };
@@ -43,8 +41,8 @@ export default function styleSelectedText(textarea: HTMLTextAreaElement, styleAr
const text = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd); const text = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
let result; let result;
if (styleArgs.orderedList || styleArgs.unorderedList) { if (styleArgs.orderedList) {
result = listStyle(textarea, styleArgs); result = orderedList(textarea);
} else if (styleArgs.multiline && isMultipleLines(text)) { } else if (styleArgs.multiline && isMultipleLines(text)) {
result = multilineStyle(textarea, styleArgs); result = multilineStyle(textarea, styleArgs);
} else { } else {
@@ -79,21 +77,6 @@ function wordSelectionEnd(text: string, i: number, multiline: boolean): number {
return index; return index;
} }
function expandSelectionToLine(textarea: HTMLTextAreaElement) {
const lines = textarea.value.split('\n');
let counter = 0;
for (let index = 0; index < lines.length; index++) {
const lineLength = lines[index].length + 1;
if (textarea.selectionStart >= counter && textarea.selectionStart < counter + lineLength) {
textarea.selectionStart = counter;
}
if (textarea.selectionEnd >= counter && textarea.selectionEnd < counter + lineLength) {
textarea.selectionEnd = counter + lineLength - 1;
}
counter += lineLength;
}
}
function expandSelectedText(textarea: HTMLTextAreaElement, prefixToUse: string, suffixToUse: string, multiline = false): string { function expandSelectedText(textarea: HTMLTextAreaElement, prefixToUse: string, suffixToUse: string, multiline = false): string {
if (textarea.selectionStart === textarea.selectionEnd) { if (textarea.selectionStart === textarea.selectionEnd) {
textarea.selectionStart = wordSelectionStart(textarea.value, textarea.selectionStart); textarea.selectionStart = wordSelectionStart(textarea.value, textarea.selectionStart);
@@ -157,9 +140,7 @@ function blockStyle(textarea: HTMLTextAreaElement, arg: StyleArgs): SelectionRan
let selectedText = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd); let selectedText = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
let prefixToUse = isMultipleLines(selectedText) && blockPrefix.length > 0 ? `${blockPrefix}\n` : prefix; let prefixToUse = isMultipleLines(selectedText) && blockPrefix.length > 0 ? `${blockPrefix}\n` : prefix;
// CHANGED let suffixToUse = isMultipleLines(selectedText) && blockSuffix.length > 0 ? `\n${blockSuffix}` : suffix;
let suffixToUse = isMultipleLines(selectedText) && blockSuffix.length > 0 ? `\n${blockSuffix}` : prefixToUse === prefix ? suffix : '';
// END CHANGED
if (prefixSpace) { if (prefixSpace) {
const beforeSelection = textarea.value[textarea.selectionStart - 1]; const beforeSelection = textarea.value[textarea.selectionStart - 1];
@@ -217,25 +198,19 @@ function blockStyle(textarea: HTMLTextAreaElement, arg: StyleArgs): SelectionRan
} }
function multilineStyle(textarea: HTMLTextAreaElement, arg: StyleArgs) { function multilineStyle(textarea: HTMLTextAreaElement, arg: StyleArgs) {
const { prefix, suffix, blockPrefix, blockSuffix, surroundWithNewlines } = arg; const { prefix, suffix, surroundWithNewlines } = arg;
let text = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd); let text = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
let selectionStart = textarea.selectionStart; let selectionStart = textarea.selectionStart;
let selectionEnd = textarea.selectionEnd; let selectionEnd = textarea.selectionEnd;
const lines = text.split('\n'); const lines = text.split('\n');
// CHANGED const undoStyle = lines.every((line) => line.startsWith(prefix) && line.endsWith(suffix));
let prefixToUse = blockPrefix.length > 0 ? blockPrefix : prefix;
let suffixToUse = blockSuffix.length > 0 ? blockSuffix : prefixToUse == prefix ? suffix : '';
const undoStyle = lines.every((line) => line.startsWith(prefixToUse) && line.endsWith(suffixToUse));
// END CHANGED
if (undoStyle) { if (undoStyle) {
text = lines.map((line) => line.slice(prefixToUse.length, line.length - suffixToUse.length)).join('\n'); text = lines.map((line) => line.slice(prefix.length, line.length - suffix.length)).join('\n');
selectionEnd = selectionStart + text.length; selectionEnd = selectionStart + text.length;
} else { } else {
// CHANGED text = lines.map((line) => prefix + line + suffix).join('\n');
text = lines.map((line) => prefixToUse + line + suffixToUse).join('\n'); if (surroundWithNewlines) {
if (surroundWithNewlines || suffixToUse === '') {
// END CHANGED
const { newlinesToAppend, newlinesToPrepend } = newlinesToSurroundSelectedText(textarea); const { newlinesToAppend, newlinesToPrepend } = newlinesToSurroundSelectedText(textarea);
selectionStart += newlinesToAppend.length; selectionStart += newlinesToAppend.length;
selectionEnd = selectionStart + text.length; selectionEnd = selectionStart + text.length;
@@ -246,116 +221,54 @@ function multilineStyle(textarea: HTMLTextAreaElement, arg: StyleArgs) {
return { text, selectionStart, selectionEnd }; return { text, selectionStart, selectionEnd };
} }
interface UndoResult { function orderedList(textarea: HTMLTextAreaElement): SelectionRange {
text: string;
processed: boolean;
}
function undoOrderedListStyle(text: string): UndoResult {
const lines = text.split('\n');
const orderedListRegex = /^\d+\.\s+/; const orderedListRegex = /^\d+\.\s+/;
const shouldUndoOrderedList = lines.every((line) => orderedListRegex.test(line));
let result = lines;
if (shouldUndoOrderedList) {
result = lines.map((line) => line.replace(orderedListRegex, ''));
}
return {
text: result.join('\n'),
processed: shouldUndoOrderedList,
};
}
function undoUnorderedListStyle(text: string): UndoResult {
const lines = text.split('\n');
const unorderedListPrefix = '- ';
const shouldUndoUnorderedList = lines.every((line) => line.startsWith(unorderedListPrefix));
let result = lines;
if (shouldUndoUnorderedList) {
result = lines.map((line) => line.slice(unorderedListPrefix.length, line.length));
}
return {
text: result.join('\n'),
processed: shouldUndoUnorderedList,
};
}
function makePrefix(index: number, unorderedList: boolean): string {
if (unorderedList) {
return '- ';
} else {
return `${index + 1}. `;
}
}
function clearExistingListStyle(style: StyleArgs, selectedText: string): [UndoResult, UndoResult, string] {
let undoResultOpositeList: UndoResult;
let undoResult: UndoResult;
let pristineText;
if (style.orderedList) {
undoResult = undoOrderedListStyle(selectedText);
undoResultOpositeList = undoUnorderedListStyle(undoResult.text);
pristineText = undoResultOpositeList.text;
} else {
undoResult = undoUnorderedListStyle(selectedText);
undoResultOpositeList = undoOrderedListStyle(undoResult.text);
pristineText = undoResultOpositeList.text;
}
return [undoResult, undoResultOpositeList, pristineText];
}
function listStyle(textarea: HTMLTextAreaElement, style: StyleArgs): SelectionRange {
const noInitialSelection = textarea.selectionStart === textarea.selectionEnd; const noInitialSelection = textarea.selectionStart === textarea.selectionEnd;
let selectionStart = textarea.selectionStart; let selectionEnd;
let selectionEnd = textarea.selectionEnd; let selectionStart;
let text = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
// Select whole line let textToUnstyle = text;
expandSelectionToLine(textarea); let lines = text.split('\n');
let startOfLine, endOfLine;
const selectedText = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
// If the user intent was to do an undo, we will stop after this.
// Otherwise, we will still undo to other list type to prevent list stacking
const [undoResult, undoResultOpositeList, pristineText] = clearExistingListStyle(style, selectedText);
const prefixedLines = pristineText.split('\n').map((value, index) => {
return `${makePrefix(index, style.unorderedList)}${value}`;
});
const totalPrefixLength = prefixedLines.reduce((previousValue, _currentValue, currentIndex) => {
return previousValue + makePrefix(currentIndex, style.unorderedList).length;
}, 0);
const totalPrefixLengthOpositeList = prefixedLines.reduce((previousValue, _currentValue, currentIndex) => {
return previousValue + makePrefix(currentIndex, !style.unorderedList).length;
}, 0);
if (undoResult.processed) {
if (noInitialSelection) {
selectionStart = Math.max(selectionStart - makePrefix(0, style.unorderedList).length, 0);
selectionEnd = selectionStart;
} else {
selectionStart = textarea.selectionStart;
selectionEnd = textarea.selectionEnd - totalPrefixLength;
}
return { text: pristineText, selectionStart, selectionEnd };
}
const { newlinesToAppend, newlinesToPrepend } = newlinesToSurroundSelectedText(textarea);
const text = newlinesToAppend + prefixedLines.join('\n') + newlinesToPrepend;
if (noInitialSelection) { if (noInitialSelection) {
selectionStart = Math.max(selectionStart + makePrefix(0, style.unorderedList).length + newlinesToAppend.length, 0); const linesBefore = textarea.value.slice(0, textarea.selectionStart).split(/\n/);
selectionEnd = selectionStart; startOfLine = textarea.selectionStart - linesBefore[linesBefore.length - 1].length;
} else { endOfLine = wordSelectionEnd(textarea.value, textarea.selectionStart, true);
if (undoResultOpositeList.processed) { textToUnstyle = textarea.value.slice(startOfLine, endOfLine);
selectionStart = Math.max(textarea.selectionStart + newlinesToAppend.length, 0); }
selectionEnd = textarea.selectionEnd + newlinesToAppend.length + totalPrefixLength - totalPrefixLengthOpositeList; const linesToUnstyle = textToUnstyle.split('\n');
} else { const undoStyling = linesToUnstyle.every((line) => orderedListRegex.test(line));
selectionStart = Math.max(textarea.selectionStart + newlinesToAppend.length, 0);
selectionEnd = textarea.selectionEnd + newlinesToAppend.length + totalPrefixLength; if (undoStyling) {
lines = linesToUnstyle.map((line) => line.replace(orderedListRegex, ''));
text = lines.join('\n');
if (noInitialSelection && startOfLine && endOfLine) {
const lengthDiff = linesToUnstyle[0].length - lines[0].length;
selectionStart = selectionEnd = textarea.selectionStart - lengthDiff;
textarea.selectionStart = startOfLine;
textarea.selectionEnd = endOfLine;
} }
} else {
lines = numberedLines(lines);
text = lines.join('\n');
const { newlinesToAppend, newlinesToPrepend } = newlinesToSurroundSelectedText(textarea);
selectionStart = textarea.selectionStart + newlinesToAppend.length;
selectionEnd = selectionStart + text.length;
if (noInitialSelection) selectionStart = selectionEnd;
text = newlinesToAppend + text + newlinesToPrepend;
} }
return { text, selectionStart, selectionEnd }; return { text, selectionStart, selectionEnd };
} }
function numberedLines(lines: string[]) {
let i;
let len;
let index;
const results = [];
for (index = i = 0, len = lines.length; i < len; index = ++i) {
const line = lines[index];
results.push(`${index + 1}. ${line}`);
}
return results;
}

View File

@@ -23,7 +23,6 @@ import isSafariMobile from './utils/isSafariMobile';
import type Notification from './components/Notification'; import type Notification from './components/Notification';
import type Post from './components/Post'; import type Post from './components/Post';
import Discussion from '../common/models/Discussion'; import Discussion from '../common/models/Discussion';
import extractText from '../common/utils/extractText';
export default class ForumApplication extends Application { export default class ForumApplication extends Application {
/** /**
@@ -100,7 +99,7 @@ export default class ForumApplication extends Application {
} }
this.routes[defaultAction].path = '/'; this.routes[defaultAction].path = '/';
this.history.push(defaultAction, extractText(this.translator.trans('core.forum.header.back_to_index_tooltip')), '/'); this.history.push(defaultAction, this.translator.trans('core.forum.header.back_to_index_tooltip'), '/');
this.pane = new Pane(document.getElementById('app')); this.pane = new Pane(document.getElementById('app'));
@@ -125,9 +124,8 @@ export default class ForumApplication extends Application {
app.history.home(); app.history.home();
// Reload the current user so that their unread notification count is refreshed. // Reload the current user so that their unread notification count is refreshed.
const userId = app.session.user?.id(); if (app.session.user) {
if (userId) { app.store.find('users', app.session.user.id());
app.store.find('users', userId);
m.redraw(); m.redraw();
} }
}); });

View File

@@ -14,7 +14,6 @@ import DiscussionControls from '../utils/DiscussionControls';
import PostStreamState from '../states/PostStreamState'; import PostStreamState from '../states/PostStreamState';
import Discussion from '../../common/models/Discussion'; import Discussion from '../../common/models/Discussion';
import Post from '../../common/models/Post'; import Post from '../../common/models/Post';
import { ApiResponseSingle } from '../../common/Store';
export interface IDiscussionPageAttrs extends IPageAttrs { export interface IDiscussionPageAttrs extends IPageAttrs {
id: string; id: string;
@@ -164,7 +163,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
* Load the discussion from the API or use the preloaded one. * Load the discussion from the API or use the preloaded one.
*/ */
load() { load() {
const preloadedDiscussion = app.preloadedApiDocument<Discussion>(); const preloadedDiscussion = app.preloadedApiDocument() as Discussion | null;
if (preloadedDiscussion) { if (preloadedDiscussion) {
// We must wrap this in a setTimeout because if we are mounting this // 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 // component for the first time on page load, then any calls to m.redraw
@@ -174,7 +173,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
} else { } else {
const params = this.requestParams(); const params = this.requestParams();
app.store.find<Discussion>('discussions', m.route.param('id'), params).then(this.show.bind(this)); app.store.find('discussions', m.route.param('id'), params).then(this.show.bind(this));
} }
m.redraw(); m.redraw();
@@ -196,7 +195,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
/** /**
* Initialize the component to display the given discussion. * Initialize the component to display the given discussion.
*/ */
show(discussion: ApiResponseSingle<Discussion>) { show(discussion: Discussion) {
app.history.push('discussion', discussion.title()); app.history.push('discussion', discussion.title());
app.setTitle(discussion.title()); app.setTitle(discussion.title());
app.setTitleCount(0); app.setTitleCount(0);
@@ -208,7 +207,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
// extensions. We need to distinguish the two so we don't end up displaying // extensions. We need to distinguish the two so we don't end up displaying
// the wrong posts. We do so by filtering out the posts that don't have // the wrong posts. We do so by filtering out the posts that don't have
// the 'discussion' relationship linked, then sorting and splicing. // the 'discussion' relationship linked, then sorting and splicing.
let includedPosts: (Post | undefined)[] = []; let includedPosts = [];
if (discussion.payload && discussion.payload.included) { if (discussion.payload && discussion.payload.included) {
const discussionId = discussion.id(); const discussionId = discussion.id();
@@ -218,11 +217,10 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
record.type === 'posts' && record.type === 'posts' &&
record.relationships && record.relationships &&
record.relationships.discussion && record.relationships.discussion &&
!Array.isArray(record.relationships.discussion.data) &&
record.relationships.discussion.data.id === discussionId record.relationships.discussion.data.id === discussionId
) )
.map((record) => app.store.getById<Post>('posts', record.id)) .map((record) => app.store.getById('posts', record.id))
.sort((a?: Post, b?: Post) => (a?.createdAt()?.getTime() ?? 0) - (b?.createdAt()?.getTime() ?? 0)) .sort((a: Post, b: Post) => a.createdAt() - b.createdAt())
.slice(0, 20); .slice(0, 20);
} }
@@ -230,7 +228,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
// posts we want to display. Tell the stream to scroll down and highlight // posts we want to display. Tell the stream to scroll down and highlight
// the specific post that was routed to. // the specific post that was routed to.
this.stream = new PostStreamState(discussion, includedPosts); this.stream = new PostStreamState(discussion, includedPosts);
this.stream.goToNumber(m.route.param('near') || (includedPosts[0]?.number() ?? 0), true).then(() => { this.stream.goToNumber(m.route.param('near') || (includedPosts[0] && includedPosts[0].number()), true).then(() => {
this.discussion = discussion; this.discussion = discussion;
app.current.set('discussion', discussion); app.current.set('discussion', discussion);

View File

@@ -24,7 +24,7 @@ export default class DiscussionsSearchSource implements SearchSource {
include: 'mostRelevantPost', include: 'mostRelevantPost',
}; };
return app.store.find<Discussion[]>('discussions', params).then((results) => { return app.store.find('discussions', params).then((results) => {
this.results.set(query, results); this.results.set(query, results);
m.redraw(); m.redraw();
}); });
@@ -38,13 +38,9 @@ export default class DiscussionsSearchSource implements SearchSource {
return ( return (
<li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()}> <li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()}>
<Link href={app.route.discussion(discussion, (mostRelevantPost && mostRelevantPost.number()) || 0)}> <Link href={app.route.discussion(discussion, mostRelevantPost && mostRelevantPost.number())}>
<div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div> <div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div>
{mostRelevantPost ? ( {mostRelevantPost ? <div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain(), query, 100)}</div> : ''}
<div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain() ?? '', query, 100)}</div>
) : (
''
)}
</Link> </Link>
</li> </li>
); );

View File

@@ -1,31 +1,34 @@
import app from '../../forum/app'; import app from '../../forum/app';
import Modal, { IInternalModalAttrs } from '../../common/components/Modal'; import Modal from '../../common/components/Modal';
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
import extractText from '../../common/utils/extractText'; import extractText from '../../common/utils/extractText';
import Stream from '../../common/utils/Stream'; import Stream from '../../common/utils/Stream';
import Mithril from 'mithril';
import RequestError from '../../common/utils/RequestError';
export interface IForgotPasswordModalAttrs extends IInternalModalAttrs {
email?: string;
}
/** /**
* The `ForgotPasswordModal` component displays a modal which allows the user to * The `ForgotPasswordModal` component displays a modal which allows the user to
* enter their email address and request a link to reset their password. * enter their email address and request a link to reset their password.
*
* ### Attrs
*
* - `email`
*/ */
export default class ForgotPasswordModal<CustomAttrs extends IForgotPasswordModalAttrs = IForgotPasswordModalAttrs> extends Modal<CustomAttrs> { export default class ForgotPasswordModal extends Modal {
/** oninit(vnode) {
* The value of the email input.
*/
email!: Stream<string>;
success: boolean = false;
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode); super.oninit(vnode);
/**
* The value of the email input.
*
* @type {Function}
*/
this.email = Stream(this.attrs.email || ''); this.email = Stream(this.attrs.email || '');
/**
* Whether or not the password reset email was sent successfully.
*
* @type {Boolean}
*/
this.success = false;
} }
className() { className() {
@@ -52,8 +55,6 @@ export default class ForgotPasswordModal<CustomAttrs extends IForgotPasswordModa
); );
} }
const emailLabel = extractText(app.translator.trans('core.forum.forgot_password.email_placeholder'));
return ( return (
<div className="Modal-body"> <div className="Modal-body">
<div className="Form Form--centered"> <div className="Form Form--centered">
@@ -63,8 +64,7 @@ export default class ForgotPasswordModal<CustomAttrs extends IForgotPasswordModa
className="FormControl" className="FormControl"
name="email" name="email"
type="email" type="email"
placeholder={emailLabel} placeholder={extractText(app.translator.trans('core.forum.forgot_password.email_placeholder'))}
aria-label={emailLabel}
bidi={this.email} bidi={this.email}
disabled={this.loading} disabled={this.loading}
/> />
@@ -84,7 +84,7 @@ export default class ForgotPasswordModal<CustomAttrs extends IForgotPasswordModa
); );
} }
onsubmit(e: SubmitEvent) { onsubmit(e) {
e.preventDefault(); e.preventDefault();
this.loading = true; this.loading = true;
@@ -98,14 +98,14 @@ export default class ForgotPasswordModal<CustomAttrs extends IForgotPasswordModa
}) })
.then(() => { .then(() => {
this.success = true; this.success = true;
this.alertAttrs = null; this.alert = null;
}) })
.catch(() => {}) .catch(() => {})
.then(this.loaded.bind(this)); .then(this.loaded.bind(this));
} }
onerror(error: RequestError) { onerror(error) {
if (error.status === 404 && error.alert) { if (error.status === 404) {
error.alert.content = app.translator.trans('core.forum.forgot_password.not_found_message'); error.alert.content = app.translator.trans('core.forum.forgot_password.not_found_message');
} }

View File

@@ -1,5 +1,5 @@
import app from '../../forum/app'; import app from '../../forum/app';
import Modal, { IInternalModalAttrs } from '../../common/components/Modal'; import Modal from '../../common/components/Modal';
import ForgotPasswordModal from './ForgotPasswordModal'; import ForgotPasswordModal from './ForgotPasswordModal';
import SignUpModal from './SignUpModal'; import SignUpModal from './SignUpModal';
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
@@ -7,34 +7,38 @@ import LogInButtons from './LogInButtons';
import extractText from '../../common/utils/extractText'; import extractText from '../../common/utils/extractText';
import ItemList from '../../common/utils/ItemList'; import ItemList from '../../common/utils/ItemList';
import Stream from '../../common/utils/Stream'; import Stream from '../../common/utils/Stream';
import type Mithril from 'mithril';
import RequestError from '../../common/utils/RequestError';
export interface ILoginModalAttrs extends IInternalModalAttrs { /**
identification?: string; * The `LogInModal` component displays a modal dialog with a login form.
password?: string; *
remember?: boolean; * ### Attrs
} *
* - `identification`
export default class LogInModal<CustomAttrs extends ILoginModalAttrs = ILoginModalAttrs> extends Modal<CustomAttrs> { * - `password`
/** */
* The value of the identification input. export default class LogInModal extends Modal {
*/ oninit(vnode) {
identification!: Stream<string>;
/**
* The value of the password input.
*/
password!: Stream<string>;
/**
* The value of the remember me input.
*/
remember!: Stream<boolean>;
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode); super.oninit(vnode);
/**
* The value of the identification input.
*
* @type {Function}
*/
this.identification = Stream(this.attrs.identification || ''); this.identification = Stream(this.attrs.identification || '');
/**
* The value of the password input.
*
* @type {Function}
*/
this.password = Stream(this.attrs.password || ''); this.password = Stream(this.attrs.password || '');
/**
* The value of the remember me input.
*
* @type {Function}
*/
this.remember = Stream(!!this.attrs.remember); this.remember = Stream(!!this.attrs.remember);
} }
@@ -57,9 +61,6 @@ export default class LogInModal<CustomAttrs extends ILoginModalAttrs = ILoginMod
fields() { fields() {
const items = new ItemList(); const items = new ItemList();
const identificationLabel = extractText(app.translator.trans('core.forum.log_in.username_or_email_placeholder'));
const passwordLabel = extractText(app.translator.trans('core.forum.log_in.password_placeholder'));
items.add( items.add(
'identification', 'identification',
<div className="Form-group"> <div className="Form-group">
@@ -67,8 +68,7 @@ export default class LogInModal<CustomAttrs extends ILoginModalAttrs = ILoginMod
className="FormControl" className="FormControl"
name="identification" name="identification"
type="text" type="text"
placeholder={identificationLabel} placeholder={extractText(app.translator.trans('core.forum.log_in.username_or_email_placeholder'))}
aria-label={identificationLabel}
bidi={this.identification} bidi={this.identification}
disabled={this.loading} disabled={this.loading}
/> />
@@ -84,8 +84,7 @@ export default class LogInModal<CustomAttrs extends ILoginModalAttrs = ILoginMod
name="password" name="password"
type="password" type="password"
autocomplete="current-password" autocomplete="current-password"
placeholder={passwordLabel} placeholder={extractText(app.translator.trans('core.forum.log_in.password_placeholder'))}
aria-label={passwordLabel}
bidi={this.password} bidi={this.password}
disabled={this.loading} disabled={this.loading}
/> />
@@ -141,10 +140,12 @@ export default class LogInModal<CustomAttrs extends ILoginModalAttrs = ILoginMod
/** /**
* Open the forgot password modal, prefilling it with an email if the user has * Open the forgot password modal, prefilling it with an email if the user has
* entered one. * entered one.
*
* @public
*/ */
forgotPassword() { forgotPassword() {
const email = this.identification(); const email = this.identification();
const attrs = email.includes('@') ? { email } : undefined; const attrs = email.indexOf('@') !== -1 ? { email } : undefined;
app.modal.show(ForgotPasswordModal, attrs); app.modal.show(ForgotPasswordModal, attrs);
} }
@@ -152,14 +153,13 @@ export default class LogInModal<CustomAttrs extends ILoginModalAttrs = ILoginMod
/** /**
* Open the sign up modal, prefilling it with an email/username/password if * Open the sign up modal, prefilling it with an email/username/password if
* the user has entered one. * the user has entered one.
*
* @public
*/ */
signUp() { signUp() {
const attrs = { password: this.password() };
const identification = this.identification(); const identification = this.identification();
attrs[identification.indexOf('@') !== -1 ? 'email' : 'username'] = identification;
const attrs = {
[identification.includes('@') ? 'email' : 'username']: identification,
password: this.password(),
};
app.modal.show(SignUpModal, attrs); app.modal.show(SignUpModal, attrs);
} }
@@ -168,7 +168,7 @@ export default class LogInModal<CustomAttrs extends ILoginModalAttrs = ILoginMod
this.$('[name=' + (this.identification() ? 'password' : 'identification') + ']').select(); this.$('[name=' + (this.identification() ? 'password' : 'identification') + ']').select();
} }
onsubmit(e: SubmitEvent) { onsubmit(e) {
e.preventDefault(); e.preventDefault();
this.loading = true; this.loading = true;
@@ -182,8 +182,8 @@ export default class LogInModal<CustomAttrs extends ILoginModalAttrs = ILoginMod
.then(() => window.location.reload(), this.loaded.bind(this)); .then(() => window.location.reload(), this.loaded.bind(this));
} }
onerror(error: RequestError) { onerror(error) {
if (error.status === 401 && error.alert) { if (error.status === 401) {
error.alert.content = app.translator.trans('core.forum.log_in.invalid_login_message'); error.alert.content = app.translator.trans('core.forum.log_in.invalid_login_message');
} }

View File

@@ -5,7 +5,6 @@ import Button from '../../common/components/Button';
import Link from '../../common/components/Link'; import Link from '../../common/components/Link';
import LoadingIndicator from '../../common/components/LoadingIndicator'; import LoadingIndicator from '../../common/components/LoadingIndicator';
import Discussion from '../../common/models/Discussion'; import Discussion from '../../common/models/Discussion';
import ItemList from '../../common/utils/ItemList';
/** /**
* The `NotificationList` component displays a list of the logged-in user's * The `NotificationList` component displays a list of the logged-in user's
@@ -20,7 +19,14 @@ export default class NotificationList extends Component {
<div className="NotificationList-header"> <div className="NotificationList-header">
<h4 className="App-titleControl App-titleControl--text">{app.translator.trans('core.forum.notifications.title')}</h4> <h4 className="App-titleControl App-titleControl--text">{app.translator.trans('core.forum.notifications.title')}</h4>
<div className="App-primaryControl">{this.controlItems().toArray()}</div> <div className="App-primaryControl">
<Button
className="Button Button--link"
icon="fas fa-check"
title={app.translator.trans('core.forum.notifications.mark_all_as_read_tooltip')}
onclick={state.markAllAsRead.bind(state)}
/>
</div>
</div> </div>
<div className="NotificationList-content">{this.content(state)}</div> <div className="NotificationList-content">{this.content(state)}</div>
@@ -28,24 +34,6 @@ export default class NotificationList extends Component {
); );
} }
controlItems() {
const items = new ItemList();
const state = this.attrs.state;
items.add(
'mark_all_as_read',
<Button
className="Button Button--link"
icon="fas fa-check"
title={app.translator.trans('core.forum.notifications.mark_all_as_read_tooltip')}
onclick={state.markAllAsRead.bind(state)}
/>,
70
);
return items;
}
content(state) { content(state) {
if (state.isLoading()) { if (state.isLoading()) {
return <LoadingIndicator className="LoadingIndicator--block" />; return <LoadingIndicator className="LoadingIndicator--block" />;

View File

@@ -66,8 +66,8 @@ export default class Post extends Component {
buttonClassName="Button Button--icon Button--flat" buttonClassName="Button Button--icon Button--flat"
menuClassName="Dropdown-menu--right" menuClassName="Dropdown-menu--right"
icon="fas fa-ellipsis-h" icon="fas fa-ellipsis-h"
onshow={() => this.$('.Post-controls').addClass('open')} onshow={() => this.$('.Post-actions').addClass('open')}
onhide={() => this.$('.Post-controls').removeClass('open')} onhide={() => this.$('.Post-actions').removeClass('open')}
accessibleToggleLabel={app.translator.trans('core.forum.post_controls.toggle_dropdown_accessible_label')} accessibleToggleLabel={app.translator.trans('core.forum.post_controls.toggle_dropdown_accessible_label')}
> >
{controls} {controls}

View File

@@ -55,6 +55,6 @@ export default class PostMeta extends Component {
* @returns {String} * @returns {String}
*/ */
getPermalink(post) { getPermalink(post) {
return app.forum.attribute('baseUrl') + app.route.post(post); return window.location.origin + app.route.post(post);
} }
} }

View File

@@ -9,8 +9,8 @@ import icon from '../../common/helpers/icon';
import SearchState from '../states/SearchState'; import SearchState from '../states/SearchState';
import DiscussionsSearchSource from './DiscussionsSearchSource'; import DiscussionsSearchSource from './DiscussionsSearchSource';
import UsersSearchSource from './UsersSearchSource'; import UsersSearchSource from './UsersSearchSource';
import { fireDeprecationWarning } from '../../common/helpers/fireDebugWarning';
import type Mithril from 'mithril'; import type Mithril from 'mithril';
import Model from '../../common/Model';
/** /**
* The `SearchSource` interface defines a section of search results in the * The `SearchSource` interface defines a section of search results in the
@@ -53,33 +53,14 @@ export interface SearchAttrs extends ComponentAttrs {
* *
* - state: SearchState instance. * - state: SearchState instance.
*/ */
export default class Search<T extends SearchAttrs = SearchAttrs> extends Component<T, SearchState> { export default class Search<T extends SearchAttrs = SearchAttrs> extends Component<T> {
/** /**
* The minimum query length before sources are searched. * The minimum query length before sources are searched.
*/ */
protected static MIN_SEARCH_LEN = 3; protected static MIN_SEARCH_LEN = 3;
/**
* The instance of `SearchState` for this component.
*/
protected searchState!: SearchState; protected searchState!: SearchState;
/**
* The instance of `SearchState` for this component.
*
* @deprecated Replace with`this.searchState` instead.
*/
// TODO: [Flarum 2.0] Remove this.
// @ts-expect-error This is a get accessor, while superclass defines this as a property. This is needed to prevent breaking changes, however.
protected get state() {
fireDeprecationWarning('`state` property of the Search component is deprecated', '3212');
return this.searchState;
}
protected set state(state: SearchState) {
fireDeprecationWarning('`state` property of the Search component is deprecated', '3212');
this.searchState = state;
}
/** /**
* Whether or not the search input has focus. * Whether or not the search input has focus.
*/ */
@@ -182,7 +163,7 @@ export default class Search<T extends SearchAttrs = SearchAttrs> extends Compone
const maxHeight = const maxHeight =
window.innerHeight - this.element.querySelector('.Search-input>.FormControl')!.getBoundingClientRect().bottom - resultsElementMargin; window.innerHeight - this.element.querySelector('.Search-input>.FormControl')!.getBoundingClientRect().bottom - resultsElementMargin;
this.element.querySelector<HTMLElement>('.Search-results')?.style?.setProperty('max-height', `${maxHeight}px`); this.element.querySelector('.Search-results')?.setAttribute('style', `max-height: ${maxHeight}px`);
} }
onupdate(vnode: Mithril.VnodeDOM<T, this>) { onupdate(vnode: Mithril.VnodeDOM<T, this>) {

View File

@@ -1,47 +1,45 @@
import app from '../../forum/app'; import app from '../../forum/app';
import Modal, { IInternalModalAttrs } from '../../common/components/Modal'; import Modal from '../../common/components/Modal';
import LogInModal from './LogInModal'; import LogInModal from './LogInModal';
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
import LogInButtons from './LogInButtons'; import LogInButtons from './LogInButtons';
import extractText from '../../common/utils/extractText'; import extractText from '../../common/utils/extractText';
import ItemList from '../../common/utils/ItemList'; import ItemList from '../../common/utils/ItemList';
import Stream from '../../common/utils/Stream'; import Stream from '../../common/utils/Stream';
import type Mithril from 'mithril';
export interface ISignupModalAttrs extends IInternalModalAttrs { /**
username?: string; * The `SignUpModal` component displays a modal dialog with a singup form.
email?: string; *
password?: string; * ### Attrs
token?: string; *
provided?: string[]; * - `username`
} * - `email`
* - `password`
export type SignupBody = { * - `token` An email token to sign up with.
username: string; */
email: string; export default class SignUpModal extends Modal {
} & ({ token: string } | { password: string }); oninit(vnode) {
export default class SignUpModal<CustomAttrs extends ISignupModalAttrs = ISignupModalAttrs> extends Modal<CustomAttrs> {
/**
* The value of the username input.
*/
username!: Stream<string>;
/**
* The value of the email input.
*/
email!: Stream<string>;
/**
* The value of the password input.
*/
password!: Stream<string>;
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode); super.oninit(vnode);
/**
* The value of the username input.
*
* @type {Function}
*/
this.username = Stream(this.attrs.username || ''); this.username = Stream(this.attrs.username || '');
/**
* The value of the email input.
*
* @type {Function}
*/
this.email = Stream(this.attrs.email || ''); this.email = Stream(this.attrs.email || '');
/**
* The value of the password input.
*
* @type {Function}
*/
this.password = Stream(this.attrs.password || ''); this.password = Stream(this.attrs.password || '');
} }
@@ -57,21 +55,17 @@ export default class SignUpModal<CustomAttrs extends ISignupModalAttrs = ISignup
return [<div className="Modal-body">{this.body()}</div>, <div className="Modal-footer">{this.footer()}</div>]; return [<div className="Modal-body">{this.body()}</div>, <div className="Modal-footer">{this.footer()}</div>];
} }
isProvided(field: string): boolean { isProvided(field) {
return this.attrs.provided?.includes(field) ?? false; return this.attrs.provided && this.attrs.provided.indexOf(field) !== -1;
} }
body() { body() {
return [!this.attrs.token && <LogInButtons />, <div className="Form Form--centered">{this.fields().toArray()}</div>]; return [this.attrs.token ? '' : <LogInButtons />, <div className="Form Form--centered">{this.fields().toArray()}</div>];
} }
fields() { fields() {
const items = new ItemList(); const items = new ItemList();
const usernameLabel = extractText(app.translator.trans('core.forum.sign_up.username_placeholder'));
const emailLabel = extractText(app.translator.trans('core.forum.sign_up.email_placeholder'));
const passwordLabel = extractText(app.translator.trans('core.forum.sign_up.password_placeholder'));
items.add( items.add(
'username', 'username',
<div className="Form-group"> <div className="Form-group">
@@ -79,8 +73,7 @@ export default class SignUpModal<CustomAttrs extends ISignupModalAttrs = ISignup
className="FormControl" className="FormControl"
name="username" name="username"
type="text" type="text"
placeholder={usernameLabel} placeholder={extractText(app.translator.trans('core.forum.sign_up.username_placeholder'))}
aria-label={usernameLabel}
bidi={this.username} bidi={this.username}
disabled={this.loading || this.isProvided('username')} disabled={this.loading || this.isProvided('username')}
/> />
@@ -95,8 +88,7 @@ export default class SignUpModal<CustomAttrs extends ISignupModalAttrs = ISignup
className="FormControl" className="FormControl"
name="email" name="email"
type="email" type="email"
placeholder={emailLabel} placeholder={extractText(app.translator.trans('core.forum.sign_up.email_placeholder'))}
aria-label={emailLabel}
bidi={this.email} bidi={this.email}
disabled={this.loading || this.isProvided('email')} disabled={this.loading || this.isProvided('email')}
/> />
@@ -113,8 +105,7 @@ export default class SignUpModal<CustomAttrs extends ISignupModalAttrs = ISignup
name="password" name="password"
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
placeholder={passwordLabel} placeholder={extractText(app.translator.trans('core.forum.sign_up.password_placeholder'))}
aria-label={passwordLabel}
bidi={this.password} bidi={this.password}
disabled={this.loading} disabled={this.loading}
/> />
@@ -165,7 +156,7 @@ export default class SignUpModal<CustomAttrs extends ISignupModalAttrs = ISignup
} }
} }
onsubmit(e: SubmitEvent) { onsubmit(e) {
e.preventDefault(); e.preventDefault();
this.loading = true; this.loading = true;
@@ -184,16 +175,22 @@ export default class SignUpModal<CustomAttrs extends ISignupModalAttrs = ISignup
/** /**
* Get the data that should be submitted in the sign-up request. * Get the data that should be submitted in the sign-up request.
*
* @return {Object}
* @protected
*/ */
submitData(): SignupBody { submitData() {
const authData = this.attrs.token ? { token: this.attrs.token } : { password: this.password() };
const data = { const data = {
username: this.username(), username: this.username(),
email: this.email(), email: this.email(),
...authData,
}; };
if (this.attrs.token) {
data.token = this.attrs.token;
} else {
data.password = this.password();
}
return data; return data;
} }
} }

Some files were not shown because too many files have changed in this diff Show More