mirror of
https://github.com/flarum/core.git
synced 2025-08-13 11:54:32 +02:00
Compare commits
85 Commits
as/writabl
...
dw/remove-
Author | SHA1 | Date | |
---|---|---|---|
|
7de9afdcb9 | ||
|
140a6e4477 | ||
|
ec0c233d15 | ||
|
dc661bf144 | ||
|
7a27f494c6 | ||
|
edde6be301 | ||
|
96fdaac3ef | ||
|
e57655553f | ||
|
a1cc456f3a | ||
|
4ad961c972 | ||
|
7d9fe8e06b | ||
|
a36f98d1fc | ||
|
5befaa6886 | ||
|
726661fe8c | ||
|
b8b9f69820 | ||
|
c9a8543554 | ||
|
6136ce8d8c | ||
|
11fd012f70 | ||
|
25dc26bac6 | ||
|
10c6694087 | ||
|
c88a3e7e89 | ||
|
f7e4413d96 | ||
|
e54c5b0924 | ||
|
cbbb574e37 | ||
|
4444357563 | ||
|
55dd8f17f3 | ||
|
718e01165a | ||
|
311e858c2b | ||
|
b13bc70339 | ||
|
a90140928c | ||
|
162c60e763 | ||
|
174345cf22 | ||
|
fe9cec0787 | ||
|
1bdfe0112c | ||
|
cc69211977 | ||
|
02a57bfa8e | ||
|
0d45f2a881 | ||
|
6c46fc4228 | ||
|
1567ab0cb8 | ||
|
2554b9e774 | ||
|
b77f13b7c6 | ||
|
080442d085 | ||
|
fb82afa97f | ||
|
508be96f15 | ||
|
364575b3f0 | ||
|
c7791b63f7 | ||
|
dc48e2327b | ||
|
4ade45e67a | ||
|
46893a9749 | ||
|
6b7dfaa598 | ||
|
d0c160923d | ||
|
c2ec848744 | ||
|
187b5c6f0b | ||
|
bd8ebb00a0 | ||
|
f26ad3e32d | ||
|
4759395186 | ||
|
a2c8407dd4 | ||
|
306b3a9e8b | ||
|
4444e7c788 | ||
|
4bd5bc87ee | ||
|
528c964d94 | ||
|
3bca30121b | ||
|
53180a38ac | ||
|
d82073c3a9 | ||
|
44efff342d | ||
|
0bdb018ad4 | ||
|
b0504597da | ||
|
b85aa403cc | ||
|
ab2620147a | ||
|
09a55258a0 | ||
|
3a8d640dab | ||
|
bbc9143404 | ||
|
7be0c02ba1 | ||
|
580be37eb4 | ||
|
0e00196d8e | ||
|
a57ef5a3d5 | ||
|
325b9afca6 | ||
|
1a420828aa | ||
|
57b413ada5 | ||
|
326b787130 | ||
|
0f2824e0f4 | ||
|
7bab6eddf6 | ||
|
b7a9911ffb | ||
|
c219699024 | ||
|
dcc9868129 |
2
.github/workflows/js.yml
vendored
2
.github/workflows/js.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
working-directory: ./js
|
||||
|
||||
- name: Typecheck
|
||||
run: yarn run check-typings || true # REMOVE THIS ONCE TYPE SAFETY REACHED
|
||||
run: yarn run check-typings
|
||||
working-directory: ./js
|
||||
|
||||
type-coverage:
|
||||
|
@@ -9,7 +9,6 @@
|
||||
<a href="https://github.styleci.io/repos/28257573"><img src="https://github.styleci.io/repos/28257573/shield?style=flat" alt="StyleCI"></a>
|
||||
</p>
|
||||
|
||||
|
||||
## 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:
|
||||
@@ -20,13 +19,15 @@
|
||||
|
||||
* **Powerful and extensible.** Customize, extend, and integrate Flarum to suit your community. Flarum’s architecture is amazingly flexible, with a powerful Extension API.
|
||||
|
||||

|
||||
|
||||
## 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).
|
||||
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/).
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to Flarum! Please read the **[Contributing guide](https://flarum.org/docs/contributing.html)** to learn how you can help.
|
||||
Thank you for considering contributing to Flarum! Please read the **[Contributing guide](https://docs.flarum.org/contributing)** to learn how you can help.
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
|
26
js/dist-typings/@types/global.d.ts
vendored
26
js/dist-typings/@types/global.d.ts
vendored
@@ -21,7 +21,20 @@ declare type KeysOfType<Type extends object, Match> = {
|
||||
*/
|
||||
declare type KeyOfType<Type extends object, Match> = KeysOfType<Type, Match>[keyof Type];
|
||||
|
||||
declare type VnodeElementTag<Attrs = Record<string, unknown>, State = Record<string, unknown>> = string | ComponentTypes<Attrs, State>;
|
||||
type Component<A> = import('mithril').Component<A>;
|
||||
|
||||
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.
|
||||
@@ -46,6 +59,17 @@ declare const app: never;
|
||||
declare const m: import('mithril').Static;
|
||||
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 };
|
||||
|
||||
/**
|
||||
|
2
js/dist-typings/admin/AdminApplication.d.ts
vendored
2
js/dist-typings/admin/AdminApplication.d.ts
vendored
@@ -35,7 +35,7 @@ export default class AdminApplication extends Application {
|
||||
history: {
|
||||
canGoBack: () => boolean;
|
||||
getPrevious: () => void;
|
||||
backUrl: () => any;
|
||||
backUrl: () => string;
|
||||
back: () => void;
|
||||
};
|
||||
/**
|
||||
|
@@ -2,8 +2,8 @@ import type Mithril from 'mithril';
|
||||
import Page, { IPageAttrs } from '../../common/components/Page';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
export interface AdminHeaderOptions {
|
||||
title: string;
|
||||
description: string;
|
||||
title: Mithril.Children;
|
||||
description: Mithril.Children;
|
||||
icon: string;
|
||||
/**
|
||||
* Will be used as the class for the AdminPage.
|
||||
|
@@ -1,6 +1,8 @@
|
||||
/// <reference path="../../../src/common/translator-icu-rich.d.ts" />
|
||||
import Modal from '../../common/components/Modal';
|
||||
export default class LoadingModal<ModalAttrs = {}> extends Modal<ModalAttrs> {
|
||||
import Modal, { IInternalModalAttrs } from '../../common/components/Modal';
|
||||
export interface ILoadingModalAttrs extends IInternalModalAttrs {
|
||||
}
|
||||
export default class LoadingModal<ModalAttrs extends ILoadingModalAttrs = ILoadingModalAttrs> extends Modal<ModalAttrs> {
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
|
@@ -1,8 +1,18 @@
|
||||
export default class ReadmeModal extends Modal<import("../../common/components/Modal").IInternalModalAttrs> {
|
||||
constructor();
|
||||
name: any;
|
||||
extName: any;
|
||||
loadReadme(): Promise<void>;
|
||||
readme: any;
|
||||
/// <reference path="../../../src/common/translator-icu-rich.d.ts" />
|
||||
import Modal, { IInternalModalAttrs } from '../../common/components/Modal';
|
||||
import ExtensionReadme from '../models/ExtensionReadme';
|
||||
import type Mithril from 'mithril';
|
||||
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>;
|
||||
}
|
||||
import Modal from "../../common/components/Modal";
|
||||
|
@@ -1,5 +1,6 @@
|
||||
export default class StatusWidget extends DashboardWidget {
|
||||
items(): ItemList<any>;
|
||||
toolsItems(): ItemList<any>;
|
||||
handleClearCache(e: any): void;
|
||||
}
|
||||
import DashboardWidget from "./DashboardWidget";
|
||||
|
@@ -1,5 +1,5 @@
|
||||
/// <reference path="../../../src/common/translator-icu-rich.d.ts" />
|
||||
/// <reference types="mithril" />
|
||||
import type Mithril from 'mithril';
|
||||
import type User from '../../common/models/User';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import AdminPage from './AdminPage';
|
||||
@@ -7,11 +7,11 @@ declare type ColumnData = {
|
||||
/**
|
||||
* Column title
|
||||
*/
|
||||
name: String;
|
||||
name: Mithril.Children;
|
||||
/**
|
||||
* Component(s) to show for this column.
|
||||
*/
|
||||
content: (user: User) => JSX.Element;
|
||||
content: (user: User) => Mithril.Children;
|
||||
};
|
||||
/**
|
||||
* Admin page which displays a paginated list of all users on the forum.
|
||||
|
@@ -1,4 +1,4 @@
|
||||
export default class ExtensionReadme extends Model {
|
||||
content: any;
|
||||
content: () => any;
|
||||
}
|
||||
import Model from "../../common/Model";
|
||||
|
17
js/dist-typings/common/Application.d.ts
vendored
17
js/dist-typings/common/Application.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
import ItemList from './utils/ItemList';
|
||||
import Translator from './Translator';
|
||||
import Store from './Store';
|
||||
import Store, { ApiPayload, ApiResponsePlural, ApiResponseSingle } from './Store';
|
||||
import Session from './Session';
|
||||
import Drawer from './utils/Drawer';
|
||||
import RequestError, { InternalFlarumRequestOptions } from './utils/RequestError';
|
||||
@@ -12,6 +12,7 @@ import type DefaultResolver from './resolvers/DefaultResolver';
|
||||
import type Mithril from 'mithril';
|
||||
import type Component from './Component';
|
||||
import type { ComponentAttrs } from './Component';
|
||||
import Model, { SavedModelData } from './Model';
|
||||
export declare type FlarumScreens = 'phone' | 'tablet' | 'desktop' | 'desktop-hd';
|
||||
export declare type FlarumGenericRoute = RouteItem<any, any, any>;
|
||||
export interface FlarumRequestOptions<ResponseType> extends Omit<Mithril.RequestOptions<ResponseType>, 'extract'> {
|
||||
@@ -159,10 +160,10 @@ export default class Application {
|
||||
*/
|
||||
drawer: Drawer;
|
||||
data: {
|
||||
apiDocument: Record<string, unknown> | null;
|
||||
apiDocument: ApiPayload | null;
|
||||
locale: string;
|
||||
locales: Record<string, string>;
|
||||
resources: Record<string, unknown>[];
|
||||
resources: SavedModelData[];
|
||||
session: {
|
||||
userId: number;
|
||||
csrfToken: string;
|
||||
@@ -190,7 +191,8 @@ export default class Application {
|
||||
/**
|
||||
* Get the API response document that has been preloaded into the application.
|
||||
*/
|
||||
preloadedApiDocument(): Record<string, unknown> | null;
|
||||
preloadedApiDocument<M extends Model>(): ApiResponseSingle<M> | null;
|
||||
preloadedApiDocument<Ms extends Model[]>(): ApiResponsePlural<Ms[number]> | null;
|
||||
/**
|
||||
* Determine the current screen mode, based on our media queries.
|
||||
*/
|
||||
@@ -217,7 +219,12 @@ export default class Application {
|
||||
* @param options
|
||||
* @return {Promise}
|
||||
*/
|
||||
request<ResponseType>(originalOptions: FlarumRequestOptions<ResponseType>): Promise<ResponseType | string>;
|
||||
request<ResponseType>(originalOptions: FlarumRequestOptions<ResponseType>): Promise<ResponseType>;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* Construct a URL to the route with the given name.
|
||||
|
196
js/dist-typings/common/Model.d.ts
vendored
196
js/dist-typings/common/Model.d.ts
vendored
@@ -1,149 +1,147 @@
|
||||
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
|
||||
* persist changes via the API.
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
export default class Model {
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
public static attribute(name: string, transform?: Function | undefined): any;
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
public static hasOne(name: string): Model | boolean | undefined;
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
public static hasMany(name: string): any[] | boolean;
|
||||
/**
|
||||
* Transform the given value into a Date object.
|
||||
*
|
||||
* @param {String} value
|
||||
* @return {Date|null}
|
||||
* @public
|
||||
*/
|
||||
public static transformDate(value: string): Date | null;
|
||||
/**
|
||||
* Get a resource identifier object for the given model.
|
||||
*
|
||||
* @param {Model} model
|
||||
* @return {Object}
|
||||
* @protected
|
||||
*/
|
||||
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);
|
||||
export default abstract class Model {
|
||||
/**
|
||||
* The resource object from the API.
|
||||
*
|
||||
* @type {Object}
|
||||
* @public
|
||||
*/
|
||||
public data: Object;
|
||||
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.
|
||||
*
|
||||
* @type {Date}
|
||||
* @public
|
||||
*/
|
||||
public freshness: Date;
|
||||
freshness: Date;
|
||||
/**
|
||||
* Whether or not the resource exists on the server.
|
||||
*
|
||||
* @type {Boolean}
|
||||
* @public
|
||||
*/
|
||||
public exists: boolean;
|
||||
exists: boolean;
|
||||
/**
|
||||
* The data store that this resource should be persisted to.
|
||||
*
|
||||
* @type {Store}
|
||||
* @protected
|
||||
*/
|
||||
protected store: any;
|
||||
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.
|
||||
*
|
||||
* @return {Integer}
|
||||
* @public
|
||||
* @final
|
||||
*/
|
||||
public id(): any;
|
||||
id(): string | undefined;
|
||||
/**
|
||||
* Get one of the model's attributes.
|
||||
*
|
||||
* @param {String} attribute
|
||||
* @return {*}
|
||||
* @public
|
||||
* @final
|
||||
*/
|
||||
public attribute(attribute: string): any;
|
||||
attribute<T = unknown>(attribute: string): T;
|
||||
/**
|
||||
* Merge new data into this model locally.
|
||||
*
|
||||
* @param {Object} data A resource object to merge into this model
|
||||
* @public
|
||||
* @param data A resource object to merge into this model
|
||||
*/
|
||||
public pushData(data: Object): void;
|
||||
pushData(data: ModelData | {
|
||||
relationships?: SaveRelationships;
|
||||
}): this;
|
||||
/**
|
||||
* Merge new attributes into this model locally.
|
||||
*
|
||||
* @param {Object} attributes The attributes to merge.
|
||||
* @public
|
||||
* @param attributes The attributes to merge.
|
||||
*/
|
||||
public pushAttributes(attributes: Object): void;
|
||||
pushAttributes(attributes: ModelAttributes): void;
|
||||
/**
|
||||
* Merge new attributes into this model, both locally and with persistence.
|
||||
*
|
||||
* @param {Object} attributes The attributes to save. If a 'relationships' key
|
||||
* @param 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>;
|
||||
save(attributes: SaveAttributes, options?: Omit<FlarumRequestOptions<ApiPayloadSingle>, 'url'> & {
|
||||
meta?: MetaInformation;
|
||||
}): Promise<ApiResponseSingle<this>>;
|
||||
/**
|
||||
* Send a request to delete the resource.
|
||||
*
|
||||
* @param {Object} body Data to send along with the DELETE request.
|
||||
* @param {Object} [options]
|
||||
* @return {Promise}
|
||||
* @public
|
||||
* @param body Data to send along with the DELETE request.
|
||||
*/
|
||||
public delete(body: Object, options?: Object | undefined): Promise<any>;
|
||||
delete(body?: FlarumRequestOptions<void>['body'], options?: Omit<FlarumRequestOptions<void>, 'url'>): Promise<void>;
|
||||
/**
|
||||
* Construct a path to the API endpoint for this resource.
|
||||
*
|
||||
* @return {String}
|
||||
* @protected
|
||||
*/
|
||||
protected apiEndpoint(): string;
|
||||
copyData(): any;
|
||||
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.
|
||||
*
|
||||
* @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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* Get a resource identifier object for the given model.
|
||||
*/
|
||||
protected static getIdentifier(model: Model): ModelIdentifier;
|
||||
}
|
||||
|
4
js/dist-typings/common/Session.d.ts
vendored
4
js/dist-typings/common/Session.d.ts
vendored
@@ -5,10 +5,8 @@ export declare type LoginParams = {
|
||||
* The username/email
|
||||
*/
|
||||
identification: string;
|
||||
/**
|
||||
* Password
|
||||
*/
|
||||
password: string;
|
||||
remember: boolean;
|
||||
};
|
||||
/**
|
||||
* The `Session` class defines the current user session. It stores a reference
|
||||
|
134
js/dist-typings/common/Store.d.ts
vendored
134
js/dist-typings/common/Store.d.ts
vendored
@@ -1,97 +1,127 @@
|
||||
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
|
||||
* retrieve data from the API.
|
||||
*/
|
||||
export default class Store {
|
||||
constructor(models: any);
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
protected data: Object;
|
||||
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.
|
||||
*
|
||||
* @type {Object}
|
||||
* @public
|
||||
*/
|
||||
public models: Object;
|
||||
models: Record<string, typeof Model>;
|
||||
constructor(models: Record<string, typeof Model>);
|
||||
/**
|
||||
* Push resources contained within an API payload into the store.
|
||||
*
|
||||
* @param {Object} payload
|
||||
* @return {Model|Model[]} The model(s) representing the resource(s) contained
|
||||
* @return The model(s) representing the resource(s) contained
|
||||
* within the 'data' key of the payload.
|
||||
* @public
|
||||
*/
|
||||
public pushPayload(payload: Object): any | any[];
|
||||
pushPayload<M extends Model>(payload: ApiPayloadSingle): ApiResponseSingle<M>;
|
||||
pushPayload<Ms extends Model[]>(payload: ApiPayloadPlural): ApiResponseSingle<Ms[number]>;
|
||||
/**
|
||||
* 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
|
||||
* @param data The resource object
|
||||
* @return The model, or null if no model class has been
|
||||
* registered for this resource type.
|
||||
* @public
|
||||
*/
|
||||
public pushObject(data: Object): any | null;
|
||||
pushObject<M extends Model>(data: SavedModelData): M | null;
|
||||
pushObject<M extends Model>(data: SavedModelData, allowUnregistered: false): M;
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
public find(type: string, id?: any | any[] | Object, query?: Object | undefined, options?: Object | undefined): Promise<any>;
|
||||
find<M extends Model>(type: string, params: ApiQueryParamsSingle): Promise<ApiResponseSingle<M>>;
|
||||
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.
|
||||
*
|
||||
* @param {String} type The resource type.
|
||||
* @param {Integer} id The resource ID.
|
||||
* @return {Model}
|
||||
* @public
|
||||
*/
|
||||
public getById(type: string, id: any): any;
|
||||
getById<M extends Model>(type: string, id: string): M | undefined;
|
||||
/**
|
||||
* 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
|
||||
* @param type The resource type.
|
||||
* @param key The name of the method on the model.
|
||||
* @param value The value of the model attribute.
|
||||
*/
|
||||
public getBy(type: string, key: string, value: any): any;
|
||||
getBy<M extends Model, T = unknown>(type: string, key: keyof M, value: T): M | undefined;
|
||||
/**
|
||||
* Get all loaded records of a specific type.
|
||||
*
|
||||
* @param {String} type
|
||||
* @return {Model[]}
|
||||
* @public
|
||||
*/
|
||||
public all(type: string): any[];
|
||||
all<M extends Model>(type: string): M[];
|
||||
/**
|
||||
* Remove the given model from the store.
|
||||
*
|
||||
* @param {Model} model
|
||||
*/
|
||||
remove(model: any): void;
|
||||
remove(model: Model): void;
|
||||
/**
|
||||
* 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
|
||||
* @param type The resource type
|
||||
* @param data Any data to initialize the model with
|
||||
*/
|
||||
public createRecord(type: string, data?: Object | undefined): any;
|
||||
createRecord<M extends Model>(type: string, data?: ModelData): M;
|
||||
}
|
||||
export {};
|
||||
|
@@ -1,26 +1,31 @@
|
||||
/**
|
||||
* The `EditUserModal` component displays a modal dialog with a login form.
|
||||
*/
|
||||
export default class EditUserModal extends Modal<import("./Modal").IInternalModalAttrs> {
|
||||
constructor();
|
||||
username: Stream<any> | undefined;
|
||||
email: Stream<any> | undefined;
|
||||
isEmailConfirmed: Stream<any> | undefined;
|
||||
setPassword: Stream<boolean> | undefined;
|
||||
password: Stream<any> | undefined;
|
||||
groups: {} | undefined;
|
||||
fields(): ItemList<any>;
|
||||
/// <reference path="../../../src/common/translator-icu-rich.d.ts" />
|
||||
import Modal, { IInternalModalAttrs } from './Modal';
|
||||
import ItemList from '../utils/ItemList';
|
||||
import Stream from '../utils/Stream';
|
||||
import type Mithril from 'mithril';
|
||||
import type User from '../models/User';
|
||||
import type { SaveAttributes } from '../Model';
|
||||
export interface IEditUserModalAttrs extends IInternalModalAttrs {
|
||||
user: User;
|
||||
}
|
||||
export default class EditUserModal<CustomAttrs extends IEditUserModalAttrs = IEditUserModalAttrs> extends Modal<CustomAttrs> {
|
||||
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>): void;
|
||||
className(): string;
|
||||
title(): import("@askvortsov/rich-icu-message-formatter").NestedStringArray;
|
||||
content(): JSX.Element;
|
||||
fields(): ItemList<unknown>;
|
||||
activate(): void;
|
||||
data(): {
|
||||
relationships: {};
|
||||
};
|
||||
nonAdminEditingAdmin(): any;
|
||||
data(): SaveAttributes;
|
||||
onsubmit(e: SubmitEvent): void;
|
||||
nonAdminEditingAdmin(): boolean | null;
|
||||
/**
|
||||
* @internal
|
||||
* @protected
|
||||
*/
|
||||
protected userIsAdmin(user: any): any;
|
||||
protected userIsAdmin(user: User | null): boolean | null;
|
||||
}
|
||||
import Modal from "./Modal";
|
||||
import Stream from "../utils/Stream";
|
||||
import ItemList from "../utils/ItemList";
|
||||
|
6
js/dist-typings/common/components/Modal.d.ts
vendored
6
js/dist-typings/common/components/Modal.d.ts
vendored
@@ -22,8 +22,8 @@ export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IIn
|
||||
/**
|
||||
* Attributes for an alert component to show below the header.
|
||||
*/
|
||||
alertAttrs: AlertAttrs;
|
||||
oninit(vnode: Mithril.VnodeDOM<ModalAttrs, this>): void;
|
||||
alertAttrs: AlertAttrs | null;
|
||||
oninit(vnode: Mithril.Vnode<ModalAttrs, this>): void;
|
||||
oncreate(vnode: Mithril.VnodeDOM<ModalAttrs, this>): 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.
|
||||
*/
|
||||
abstract title(): string;
|
||||
abstract title(): Mithril.Children;
|
||||
/**
|
||||
* Get the content of the modal.
|
||||
*/
|
||||
|
@@ -1,4 +1,12 @@
|
||||
export default class RequestErrorModal extends Modal<import("./Modal").IInternalModalAttrs> {
|
||||
constructor();
|
||||
/// <reference types="mithril" />
|
||||
import type RequestError from '../utils/RequestError';
|
||||
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";
|
||||
|
@@ -10,3 +10,17 @@
|
||||
* can fix.
|
||||
*/
|
||||
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;
|
||||
|
30
js/dist-typings/common/helpers/listItems.d.ts
vendored
30
js/dist-typings/common/helpers/listItems.d.ts
vendored
@@ -1,16 +1,27 @@
|
||||
import type Mithril from 'mithril';
|
||||
import Component, { ComponentAttrs } from '../Component';
|
||||
export interface ModdedVnodeAttrs {
|
||||
import { ComponentAttrs } from '../Component';
|
||||
declare type ModdedVnodeAttrs = {
|
||||
itemClassName?: string;
|
||||
key?: string;
|
||||
}
|
||||
export declare type ModdedVnode<Attrs> = Mithril.Vnode<ModdedVnodeAttrs, Component<Attrs> | {}> & {
|
||||
};
|
||||
declare type ModdedTag = Mithril.Vnode['tag'] & {
|
||||
isListItem?: boolean;
|
||||
isActive?: (attrs: ComponentAttrs) => boolean;
|
||||
};
|
||||
declare type ModdedVnode = Mithril.Vnode<ModdedVnodeAttrs> & {
|
||||
itemName?: string;
|
||||
itemClassName?: string;
|
||||
tag: Mithril.Vnode['tag'] & {
|
||||
isListItem?: boolean;
|
||||
isActive?: (attrs: ComponentAttrs) => boolean;
|
||||
};
|
||||
tag: ModdedTag;
|
||||
};
|
||||
declare type ModdedChild = ModdedVnode | string | number | boolean | null | undefined;
|
||||
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,
|
||||
@@ -19,4 +30,5 @@ export declare type ModdedVnode<Attrs> = Mithril.Vnode<ModdedVnodeAttrs, Compone
|
||||
* By default, this tag is an `<li>` tag, but this is customisable through the
|
||||
* second function parameter, `customTag`.
|
||||
*/
|
||||
export default function listItems<Attrs extends Record<string, unknown>>(rawItems: ModdedVnode<Attrs> | ModdedVnode<Attrs>[], customTag?: string | Component<Attrs>, attributes?: Attrs): Mithril.Vnode[];
|
||||
export default function listItems<Attrs extends ComponentAttrs>(rawItems: ModdedChildrenWithItemName[], customTag?: VnodeElementTag<Attrs>, attributes?: Attrs): Mithril.Vnode[];
|
||||
export {};
|
||||
|
2
js/dist-typings/common/helpers/username.d.ts
vendored
2
js/dist-typings/common/helpers/username.d.ts
vendored
@@ -4,4 +4,4 @@ import User from '../models/User';
|
||||
* 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].
|
||||
*/
|
||||
export default function username(user: User): Mithril.Vnode;
|
||||
export default function username(user: User | null | undefined | false): Mithril.Vnode;
|
||||
|
47
js/dist-typings/common/models/Discussion.d.ts
vendored
47
js/dist-typings/common/models/Discussion.d.ts
vendored
@@ -1,3 +1,48 @@
|
||||
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 {
|
||||
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";
|
||||
|
3
js/dist-typings/common/models/Forum.d.ts
vendored
3
js/dist-typings/common/models/Forum.d.ts
vendored
@@ -1,3 +1,4 @@
|
||||
import Model from '../Model';
|
||||
export default class Forum extends Model {
|
||||
apiEndpoint(): string;
|
||||
}
|
||||
import Model from "../Model";
|
||||
|
18
js/dist-typings/common/models/Group.d.ts
vendored
18
js/dist-typings/common/models/Group.d.ts
vendored
@@ -1,9 +1,11 @@
|
||||
export default Group;
|
||||
declare class Group extends Model {
|
||||
import Model from '../Model';
|
||||
export default 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";
|
||||
|
10
js/dist-typings/common/models/Notification.d.ts
vendored
10
js/dist-typings/common/models/Notification.d.ts
vendored
@@ -1,3 +1,11 @@
|
||||
import Model from '../Model';
|
||||
import User from './User';
|
||||
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";
|
||||
|
22
js/dist-typings/common/models/Post.d.ts
vendored
22
js/dist-typings/common/models/Post.d.ts
vendored
@@ -1,3 +1,23 @@
|
||||
import Model from '../Model';
|
||||
import Discussion from './Discussion';
|
||||
import User from './User';
|
||||
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";
|
||||
|
45
js/dist-typings/common/models/User.d.ts
vendored
45
js/dist-typings/common/models/User.d.ts
vendored
@@ -1,3 +1,46 @@
|
||||
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 {
|
||||
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";
|
||||
|
@@ -24,7 +24,7 @@ export default class AlertManagerState {
|
||||
*/
|
||||
show(children: Mithril.Children): AlertIdentifier;
|
||||
show(attrs: AlertAttrs, children: Mithril.Children): AlertIdentifier;
|
||||
show(componentClass: Alert, attrs: AlertAttrs, children: Mithril.Children): AlertIdentifier;
|
||||
show(componentClass: typeof Alert, attrs: AlertAttrs, children: Mithril.Children): AlertIdentifier;
|
||||
/**
|
||||
* Dismiss an alert.
|
||||
*/
|
||||
|
@@ -1,4 +1,16 @@
|
||||
import type Component from '../Component';
|
||||
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.
|
||||
*
|
||||
@@ -9,7 +21,7 @@ export default class ModalManagerState {
|
||||
* @internal
|
||||
*/
|
||||
modal: null | {
|
||||
componentClass: typeof Modal;
|
||||
componentClass: UnsafeModalClass;
|
||||
attrs?: Record<string, unknown>;
|
||||
};
|
||||
private closeTimeout?;
|
||||
@@ -25,7 +37,7 @@ export default class ModalManagerState {
|
||||
* // This "hack" is needed due to quirks with nested redraws in Mithril.
|
||||
* setTimeout(() => app.modal.show(MyCoolModal, { attr: 'value' }), 0);
|
||||
*/
|
||||
show(componentClass: typeof Modal, attrs?: Record<string, unknown>): void;
|
||||
show(componentClass: UnsafeModalClass, attrs?: Record<string, unknown>): void;
|
||||
/**
|
||||
* Closes the currently open dialog, if one is open.
|
||||
*/
|
||||
@@ -37,3 +49,4 @@ export default class ModalManagerState {
|
||||
*/
|
||||
isModalOpen(): boolean;
|
||||
}
|
||||
export {};
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import Model from '../Model';
|
||||
import { ApiQueryParamsPlural, ApiResponsePlural } from '../Store';
|
||||
export interface Page<TModel> {
|
||||
number: number;
|
||||
items: TModel[];
|
||||
@@ -13,6 +14,9 @@ export interface PaginationLocation {
|
||||
export interface PaginatedListParams {
|
||||
[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> {
|
||||
protected location: PaginationLocation;
|
||||
protected pageSize: number;
|
||||
@@ -26,11 +30,11 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
||||
clear(): void;
|
||||
loadPrev(): Promise<void>;
|
||||
loadNext(): Promise<void>;
|
||||
protected parseResults(pg: number, results: T[]): void;
|
||||
protected parseResults(pg: number, results: ApiResponsePlural<T>): void;
|
||||
/**
|
||||
* Load a new page of results.
|
||||
*/
|
||||
protected loadPage(page?: number): Promise<T[]>;
|
||||
protected loadPage(page?: number): Promise<ApiResponsePlural<T>>;
|
||||
/**
|
||||
* Get the parameters that should be passed in the API request.
|
||||
* Do not include page offset unless subclass overrides loadPage.
|
||||
@@ -38,7 +42,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
||||
* @abstract
|
||||
* @see loadPage
|
||||
*/
|
||||
protected requestParams(): any;
|
||||
protected requestParams(): PaginatedListRequestParams;
|
||||
/**
|
||||
* Update the `this.params` object, calling `refresh` if they have changed.
|
||||
* Use `requestParams` for converting `this.params` into API parameters
|
||||
@@ -73,7 +77,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
||||
/**
|
||||
* Stored state parameters.
|
||||
*/
|
||||
getParams(): any;
|
||||
getParams(): P;
|
||||
protected getNextPageNumber(): number;
|
||||
protected getPrevPageNumber(): number;
|
||||
protected paramsChanged(newParams: P): boolean;
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import type Mithril from 'mithril';
|
||||
import type { AlertAttrs } from '../components/Alert';
|
||||
export declare type InternalFlarumRequestOptions<ResponseType> = Mithril.RequestOptions<ResponseType> & {
|
||||
errorHandler: (error: RequestError) => void;
|
||||
url: string;
|
||||
};
|
||||
export default class RequestError<ResponseType = string> {
|
||||
@@ -16,6 +16,6 @@ export default class RequestError<ResponseType = string> {
|
||||
[key: string]: unknown;
|
||||
}[];
|
||||
} | null;
|
||||
alert: any;
|
||||
alert: AlertAttrs | null;
|
||||
constructor(status: number, responseText: string | null, options: InternalFlarumRequestOptions<ResponseType>, xhr: XMLHttpRequest);
|
||||
}
|
||||
|
3
js/dist-typings/common/utils/computed.d.ts
vendored
3
js/dist-typings/common/utils/computed.d.ts
vendored
@@ -1,3 +1,4 @@
|
||||
import Model from '../Model';
|
||||
/**
|
||||
* The `computed` utility creates a function that will cache its output until
|
||||
* any of the dependent values are dirty.
|
||||
@@ -7,4 +8,4 @@
|
||||
* dependent values.
|
||||
* @return {Function}
|
||||
*/
|
||||
export default function computed(...dependentKeys: string[]): Function;
|
||||
export default function computed<T, M = Model>(...args: [...string[], (this: M, ...args: unknown[]) => T]): () => T;
|
||||
|
@@ -9,6 +9,7 @@ interface StyleArgs {
|
||||
scanFor: string;
|
||||
surroundWithNewlines: boolean;
|
||||
orderedList: boolean;
|
||||
unorderedList: boolean;
|
||||
trimFirst: boolean;
|
||||
}
|
||||
export default function styleSelectedText(textarea: HTMLTextAreaElement, styleArgs: StyleArgs): void;
|
||||
|
@@ -3,6 +3,7 @@ import Page, { IPageAttrs } from '../../common/components/Page';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import PostStreamState from '../states/PostStreamState';
|
||||
import Discussion from '../../common/models/Discussion';
|
||||
import { ApiResponseSingle } from '../../common/Store';
|
||||
export interface IDiscussionPageAttrs extends IPageAttrs {
|
||||
id: string;
|
||||
near?: number;
|
||||
@@ -77,7 +78,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
||||
/**
|
||||
* Initialize the component to display the given discussion.
|
||||
*/
|
||||
show(discussion: Discussion): void;
|
||||
show(discussion: ApiResponseSingle<Discussion>): void;
|
||||
/**
|
||||
* Build an item list for the contents of the sidebar.
|
||||
*/
|
||||
|
@@ -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
|
||||
* enter their email address and request a link to reset their password.
|
||||
*
|
||||
* ### Attrs
|
||||
*
|
||||
* - `email`
|
||||
*/
|
||||
export default class ForgotPasswordModal extends Modal<import("../../common/components/Modal").IInternalModalAttrs> {
|
||||
constructor();
|
||||
export default class ForgotPasswordModal<CustomAttrs extends IForgotPasswordModalAttrs = IForgotPasswordModalAttrs> extends Modal<CustomAttrs> {
|
||||
/**
|
||||
* The value of the email input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
email: Function | undefined;
|
||||
/**
|
||||
* Whether or not the password reset email was sent successfully.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
success: boolean | undefined;
|
||||
alert: any;
|
||||
email: Stream<string>;
|
||||
success: boolean;
|
||||
oninit(vnode: Mithril.Vnode<CustomAttrs, this>): void;
|
||||
className(): string;
|
||||
title(): import("@askvortsov/rich-icu-message-formatter").NestedStringArray;
|
||||
content(): JSX.Element;
|
||||
onsubmit(e: SubmitEvent): void;
|
||||
onerror(error: RequestError): void;
|
||||
}
|
||||
import Modal from "../../common/components/Modal";
|
||||
|
53
js/dist-typings/forum/components/LogInModal.d.ts
vendored
53
js/dist-typings/forum/components/LogInModal.d.ts
vendored
@@ -1,48 +1,45 @@
|
||||
/**
|
||||
* The `LogInModal` component displays a modal dialog with a login form.
|
||||
*
|
||||
* ### Attrs
|
||||
*
|
||||
* - `identification`
|
||||
* - `password`
|
||||
*/
|
||||
export default class LogInModal extends Modal<import("../../common/components/Modal").IInternalModalAttrs> {
|
||||
constructor();
|
||||
/// <reference path="../../../src/common/translator-icu-rich.d.ts" />
|
||||
import Modal, { IInternalModalAttrs } from '../../common/components/Modal';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import type Mithril from 'mithril';
|
||||
import RequestError from '../../common/utils/RequestError';
|
||||
export interface ILoginModalAttrs extends IInternalModalAttrs {
|
||||
identification?: string;
|
||||
password?: string;
|
||||
remember?: boolean;
|
||||
}
|
||||
export default class LogInModal<CustomAttrs extends ILoginModalAttrs = ILoginModalAttrs> extends Modal<CustomAttrs> {
|
||||
/**
|
||||
* The value of the identification input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
identification: Function | undefined;
|
||||
identification: Stream<string>;
|
||||
/**
|
||||
* The value of the password input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
password: Function | undefined;
|
||||
password: Stream<string>;
|
||||
/**
|
||||
* The value of the remember me input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
remember: Function | undefined;
|
||||
remember: Stream<boolean>;
|
||||
oninit(vnode: Mithril.Vnode<CustomAttrs, this>): void;
|
||||
className(): string;
|
||||
title(): import("@askvortsov/rich-icu-message-formatter").NestedStringArray;
|
||||
content(): JSX.Element[];
|
||||
body(): JSX.Element[];
|
||||
fields(): ItemList<any>;
|
||||
fields(): ItemList<unknown>;
|
||||
footer(): (string | JSX.Element)[];
|
||||
/**
|
||||
* Open the forgot password modal, prefilling it with an email if the user has
|
||||
* entered one.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
public forgotPassword(): void;
|
||||
forgotPassword(): void;
|
||||
/**
|
||||
* Open the sign up modal, prefilling it with an email/username/password if
|
||||
* the user has entered one.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
public signUp(): void;
|
||||
signUp(): void;
|
||||
onready(): void;
|
||||
onsubmit(e: SubmitEvent): void;
|
||||
onerror(error: RequestError): void;
|
||||
}
|
||||
import Modal from "../../common/components/Modal";
|
||||
import ItemList from "../../common/utils/ItemList";
|
||||
|
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
export default class NotificationList extends Component<import("../../common/Component").ComponentAttrs, undefined> {
|
||||
constructor();
|
||||
controlItems(): ItemList<any>;
|
||||
content(state: any): any;
|
||||
$notifications: JQuery<HTMLElement> | undefined;
|
||||
$scrollParent: JQuery<HTMLElement> | JQuery<Window & typeof globalThis> | undefined;
|
||||
@@ -16,3 +17,4 @@ export default class NotificationList extends Component<import("../../common/Com
|
||||
inPanel(): boolean;
|
||||
}
|
||||
import Component from "../../common/Component";
|
||||
import ItemList from "../../common/utils/ItemList";
|
||||
|
@@ -1,8 +1,8 @@
|
||||
export default class NotificationsDropdown extends Dropdown {
|
||||
onclick(): void;
|
||||
goToRoute(): void;
|
||||
getUnreadCount(): any;
|
||||
getNewCount(): any;
|
||||
getUnreadCount(): number | undefined;
|
||||
getNewCount(): number | undefined;
|
||||
menuClick(e: any): void;
|
||||
}
|
||||
import Dropdown from "../../common/components/Dropdown";
|
||||
|
12
js/dist-typings/forum/components/Search.d.ts
vendored
12
js/dist-typings/forum/components/Search.d.ts
vendored
@@ -42,12 +42,22 @@ export interface SearchAttrs extends ComponentAttrs {
|
||||
*
|
||||
* - state: SearchState instance.
|
||||
*/
|
||||
export default class Search<T extends SearchAttrs = SearchAttrs> extends Component<T> {
|
||||
export default class Search<T extends SearchAttrs = SearchAttrs> extends Component<T, SearchState> {
|
||||
/**
|
||||
* The minimum query length before sources are searched.
|
||||
*/
|
||||
protected static MIN_SEARCH_LEN: number;
|
||||
/**
|
||||
* The instance of `SearchState` for this component.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
|
@@ -1,36 +1,43 @@
|
||||
/**
|
||||
* The `SignUpModal` component displays a modal dialog with a singup form.
|
||||
*
|
||||
* ### Attrs
|
||||
*
|
||||
* - `username`
|
||||
* - `email`
|
||||
* - `password`
|
||||
* - `token` An email token to sign up with.
|
||||
*/
|
||||
export default class SignUpModal extends Modal<import("../../common/components/Modal").IInternalModalAttrs> {
|
||||
constructor();
|
||||
/// <reference path="../../../src/common/translator-icu-rich.d.ts" />
|
||||
import Modal, { IInternalModalAttrs } from '../../common/components/Modal';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import type Mithril from 'mithril';
|
||||
export interface ISignupModalAttrs extends IInternalModalAttrs {
|
||||
username?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
token?: string;
|
||||
provided?: string[];
|
||||
}
|
||||
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.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
username: Function | undefined;
|
||||
username: Stream<string>;
|
||||
/**
|
||||
* The value of the email input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
email: Function | undefined;
|
||||
email: Stream<string>;
|
||||
/**
|
||||
* The value of the password input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
password: Function | undefined;
|
||||
isProvided(field: any): any;
|
||||
body(): (string | JSX.Element)[];
|
||||
fields(): ItemList<any>;
|
||||
password: Stream<string>;
|
||||
oninit(vnode: Mithril.Vnode<CustomAttrs, this>): void;
|
||||
className(): string;
|
||||
title(): import("@askvortsov/rich-icu-message-formatter").NestedStringArray;
|
||||
content(): JSX.Element[];
|
||||
isProvided(field: string): boolean;
|
||||
body(): (false | JSX.Element)[];
|
||||
fields(): ItemList<unknown>;
|
||||
footer(): JSX.Element[];
|
||||
/**
|
||||
* Open the log in modal, prefilling it with an email/username/password if
|
||||
@@ -38,14 +45,11 @@ export default class SignUpModal extends Modal<import("../../common/components/M
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
public logIn(): void;
|
||||
logIn(): void;
|
||||
onready(): void;
|
||||
onsubmit(e: SubmitEvent): void;
|
||||
/**
|
||||
* Get the data that should be submitted in the sign-up request.
|
||||
*
|
||||
* @return {Object}
|
||||
* @protected
|
||||
*/
|
||||
protected submitData(): Object;
|
||||
submitData(): SignupBody;
|
||||
}
|
||||
import Modal from "../../common/components/Modal";
|
||||
import ItemList from "../../common/utils/ItemList";
|
||||
|
@@ -1,13 +1,26 @@
|
||||
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
|
||||
* forum.
|
||||
*/
|
||||
export default class WelcomeHero extends Component<import("../../common/Component").ComponentAttrs, undefined> {
|
||||
constructor();
|
||||
hidden: string | boolean | null | undefined;
|
||||
export default class WelcomeHero extends Component<IWelcomeHeroAttrs> {
|
||||
/**
|
||||
* @deprecated Extend the `isHidden` method instead.
|
||||
*/
|
||||
hidden: boolean;
|
||||
oninit(vnode: Mithril.Vnode<IWelcomeHeroAttrs, this>): void;
|
||||
view(vnode: Mithril.Vnode<IWelcomeHeroAttrs, this>): JSX.Element | null;
|
||||
/**
|
||||
* Hide the welcome hero.
|
||||
*/
|
||||
hide(): void;
|
||||
/**
|
||||
* Determines whether the welcome hero should be hidden.
|
||||
*
|
||||
* @returns if the welcome hero is hidden.
|
||||
*/
|
||||
isHidden(): boolean;
|
||||
}
|
||||
import Component from "../../common/Component";
|
||||
|
@@ -1,10 +1,6 @@
|
||||
import PaginatedListState, { Page, PaginatedListParams } from '../../common/states/PaginatedListState';
|
||||
import PaginatedListState, { Page, PaginatedListParams, PaginatedListRequestParams } from '../../common/states/PaginatedListState';
|
||||
import Discussion from '../../common/models/Discussion';
|
||||
export interface IRequestParams {
|
||||
include: string[];
|
||||
filter: Record<string, string>;
|
||||
sort?: string;
|
||||
}
|
||||
import { ApiResponsePlural } from '../../common/Store';
|
||||
export interface DiscussionListParams extends PaginatedListParams {
|
||||
sort?: string;
|
||||
}
|
||||
@@ -12,8 +8,8 @@ export default class DiscussionListState<P extends DiscussionListParams = Discus
|
||||
protected extraDiscussions: Discussion[];
|
||||
constructor(params: P, page?: number);
|
||||
get type(): string;
|
||||
requestParams(): IRequestParams;
|
||||
protected loadPage(page?: number): Promise<Discussion[]>;
|
||||
requestParams(): PaginatedListRequestParams;
|
||||
protected loadPage(page?: number): Promise<ApiResponsePlural<Discussion>>;
|
||||
clear(): void;
|
||||
/**
|
||||
* Get a map of sort keys (which appear in the URL, and are used for
|
||||
|
2
js/dist/admin.js
generated
vendored
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
29
js/dist/admin.js.LICENSE.txt
generated
vendored
@@ -9,6 +9,30 @@
|
||||
* 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
|
||||
* https://jquery.com/
|
||||
@@ -28,6 +52,11 @@
|
||||
* @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
|
||||
* @license MIT, https://github.com/focus-trap/tabbable/blob/master/LICENSE
|
||||
|
2
js/dist/admin.js.map
generated
vendored
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
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
2
js/dist/forum.js.map
generated
vendored
File diff suppressed because one or more lines are too long
26
js/src/@types/global.d.ts
vendored
26
js/src/@types/global.d.ts
vendored
@@ -21,7 +21,20 @@ declare type KeysOfType<Type extends object, Match> = {
|
||||
*/
|
||||
declare type KeyOfType<Type extends object, Match> = KeysOfType<Type, Match>[keyof Type];
|
||||
|
||||
declare type VnodeElementTag<Attrs = Record<string, unknown>, State = Record<string, unknown>> = string | ComponentTypes<Attrs, State>;
|
||||
type Component<A> = import('mithril').Component<A>;
|
||||
|
||||
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.
|
||||
@@ -46,6 +59,17 @@ declare const app: never;
|
||||
declare const m: import('mithril').Static;
|
||||
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 };
|
||||
|
||||
/**
|
||||
|
@@ -44,9 +44,9 @@ export default class AdminApplication extends Application {
|
||||
history = {
|
||||
canGoBack: () => true,
|
||||
getPrevious: () => {},
|
||||
backUrl: () => this.forum.attribute('baseUrl'),
|
||||
backUrl: () => this.forum.attribute<string>('baseUrl'),
|
||||
back: function () {
|
||||
window.location = this.backUrl();
|
||||
window.location.assign(this.backUrl());
|
||||
},
|
||||
};
|
||||
|
||||
|
@@ -13,8 +13,8 @@ import generateElementId from '../utils/generateElementId';
|
||||
import ColorPreviewInput from '../../common/components/ColorPreviewInput';
|
||||
|
||||
export interface AdminHeaderOptions {
|
||||
title: string;
|
||||
description: string;
|
||||
title: Mithril.Children;
|
||||
description: Mithril.Children;
|
||||
icon: string;
|
||||
/**
|
||||
* Will be used as the class for the AdminPage.
|
||||
|
@@ -16,6 +16,7 @@ import RequestError from '../../common/utils/RequestError';
|
||||
import { Extension } from '../AdminApplication';
|
||||
import { IPageAttrs } from '../../common/components/Page';
|
||||
import type Mithril from 'mithril';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
|
||||
export interface ExtensionPageAttrs extends IPageAttrs {
|
||||
id: string;
|
||||
@@ -156,7 +157,7 @@ export default class ExtensionPage<Attrs extends ExtensionPageAttrs = ExtensionP
|
||||
|
||||
if (!this.isEnabled()) {
|
||||
const purge = () => {
|
||||
if (confirm(app.translator.trans('core.admin.extension.confirm_purge'))) {
|
||||
if (confirm(extractText(app.translator.trans('core.admin.extension.confirm_purge')))) {
|
||||
app
|
||||
.request({
|
||||
url: app.forum.attribute('apiUrl') + '/extensions/' + this.extension.id,
|
||||
|
@@ -1,7 +1,9 @@
|
||||
import app from '../../admin/app';
|
||||
import Modal from '../../common/components/Modal';
|
||||
import Modal, { IInternalModalAttrs } from '../../common/components/Modal';
|
||||
|
||||
export default class LoadingModal<ModalAttrs = {}> extends Modal<ModalAttrs> {
|
||||
export interface ILoadingModalAttrs extends IInternalModalAttrs {}
|
||||
|
||||
export default class LoadingModal<ModalAttrs extends ILoadingModalAttrs = ILoadingModalAttrs> extends Modal<ModalAttrs> {
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
|
@@ -38,7 +38,6 @@ export default class PermissionDropdown extends Dropdown {
|
||||
|
||||
attrs.className = 'PermissionDropdown';
|
||||
attrs.buttonClassName = 'Button Button--text';
|
||||
attrs.lazyDraw = true;
|
||||
}
|
||||
|
||||
view(vnode) {
|
||||
|
@@ -38,11 +38,13 @@ export default class PermissionGrid<CustomAttrs extends IPermissionGridAttrs = I
|
||||
|
||||
const permissionCells = (permission: PermissionGridEntry | { children: PermissionGridEntry[] }) => {
|
||||
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) {
|
||||
return <td></td>;
|
||||
}
|
||||
|
||||
return scope.render(permission);
|
||||
return <td>{scope.render(permission)}</td>;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -416,7 +418,7 @@ export default class PermissionGrid<CustomAttrs extends IPermissionGridAttrs = I
|
||||
});
|
||||
}
|
||||
|
||||
return '';
|
||||
return null;
|
||||
},
|
||||
},
|
||||
100
|
||||
|
@@ -1,11 +1,21 @@
|
||||
import app from '../../admin/app';
|
||||
import Modal from '../../common/components/Modal';
|
||||
import Modal, { IInternalModalAttrs } from '../../common/components/Modal';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import Placeholder from '../../common/components/Placeholder';
|
||||
import ExtensionReadme from '../models/ExtensionReadme';
|
||||
import type Mithril from 'mithril';
|
||||
import type { Extension } from '../AdminApplication';
|
||||
|
||||
export default class ReadmeModal extends Modal {
|
||||
oninit(vnode) {
|
||||
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>) {
|
||||
super.oninit(vnode);
|
||||
|
||||
app.store.models['extension-readmes'] = ExtensionReadme;
|
@@ -1,4 +1,4 @@
|
||||
import app from '../../admin/app';
|
||||
import app from '../app';
|
||||
import SelectDropdown from '../../common/components/SelectDropdown';
|
||||
import Button from '../../common/components/Button';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
@@ -11,18 +11,23 @@ export default class SettingDropdown extends SelectDropdown {
|
||||
attrs.buttonClassName = 'Button Button--text';
|
||||
attrs.caretIcon = 'fas fa-caret-down';
|
||||
attrs.defaultLabel = 'Custom';
|
||||
|
||||
if ('key' in attrs) {
|
||||
attrs.setting = attrs.key;
|
||||
delete attrs.key;
|
||||
}
|
||||
}
|
||||
|
||||
view(vnode) {
|
||||
return super.view({
|
||||
...vnode,
|
||||
children: this.attrs.options.map(({ value, label }) => {
|
||||
const active = app.data.settings[this.attrs.key] === value;
|
||||
const active = app.data.settings[this.attrs.setting] === value;
|
||||
|
||||
return Button.component(
|
||||
{
|
||||
icon: active ? 'fas fa-check' : true,
|
||||
onclick: saveSettings.bind(this, { [this.attrs.key]: value }),
|
||||
onclick: saveSettings.bind(this, { [this.attrs.setting]: value }),
|
||||
active,
|
||||
},
|
||||
label
|
||||
|
@@ -26,7 +26,7 @@ export default class StatusWidget extends DashboardWidget {
|
||||
buttonClassName="Button"
|
||||
menuClassName="Dropdown-menu--right"
|
||||
>
|
||||
<Button onclick={this.handleClearCache.bind(this)}>{app.translator.trans('core.admin.dashboard.clear_cache_button')}</Button>
|
||||
{this.toolsItems().toArray()}
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
@@ -37,6 +37,17 @@ export default class StatusWidget extends DashboardWidget {
|
||||
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) {
|
||||
app.modal.show(LoadingModal);
|
||||
|
||||
|
@@ -1,3 +1,5 @@
|
||||
import type Mithril from 'mithril';
|
||||
|
||||
import app from '../../admin/app';
|
||||
|
||||
import EditUserModal from '../../common/components/EditUserModal';
|
||||
@@ -14,30 +16,18 @@ import classList from '../../common/utils/classList';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
|
||||
import AdminPage from './AdminPage';
|
||||
import Mithril from 'mithril';
|
||||
|
||||
type ColumnData = {
|
||||
/**
|
||||
* Column title
|
||||
*/
|
||||
name: String;
|
||||
name: Mithril.Children;
|
||||
/**
|
||||
* Component(s) to show for this column.
|
||||
*/
|
||||
content: (user: User) => JSX.Element;
|
||||
content: (user: User) => Mithril.Children;
|
||||
};
|
||||
|
||||
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.
|
||||
*/
|
||||
@@ -185,7 +175,7 @@ export default class UserListPage extends AdminPage {
|
||||
'id',
|
||||
{
|
||||
name: app.translator.trans('core.admin.users.grid.columns.user_id.title'),
|
||||
content: (user: User) => user.id(),
|
||||
content: (user: User) => user.id() ?? '',
|
||||
},
|
||||
100
|
||||
);
|
||||
@@ -348,15 +338,15 @@ export default class UserListPage extends AdminPage {
|
||||
if (pageNumber < 0) pageNumber = 0;
|
||||
|
||||
app.store
|
||||
.find('users', {
|
||||
.find<User[]>('users', {
|
||||
page: {
|
||||
limit: this.numPerPage,
|
||||
offset: pageNumber * this.numPerPage,
|
||||
},
|
||||
})
|
||||
.then((apiData: UsersApiResponse) => {
|
||||
.then((apiData) => {
|
||||
// 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;
|
||||
|
||||
|
@@ -6,7 +6,7 @@ import ModalManager from './components/ModalManager';
|
||||
import AlertManager from './components/AlertManager';
|
||||
import RequestErrorModal from './components/RequestErrorModal';
|
||||
import Translator from './Translator';
|
||||
import Store from './Store';
|
||||
import Store, { ApiPayload, ApiResponse, ApiResponsePlural, ApiResponseSingle, payloadIsPlural } from './Store';
|
||||
import Session from './Session';
|
||||
import extract from './utils/extract';
|
||||
import Drawer from './utils/Drawer';
|
||||
@@ -31,6 +31,7 @@ import type DefaultResolver from './resolvers/DefaultResolver';
|
||||
import type Mithril from 'mithril';
|
||||
import type Component from './Component';
|
||||
import type { ComponentAttrs } from './Component';
|
||||
import Model, { SavedModelData } from './Model';
|
||||
|
||||
export type FlarumScreens = 'phone' | 'tablet' | 'desktop' | 'desktop-hd';
|
||||
|
||||
@@ -210,10 +211,10 @@ export default class Application {
|
||||
drawer!: Drawer;
|
||||
|
||||
data!: {
|
||||
apiDocument: Record<string, unknown> | null;
|
||||
apiDocument: ApiPayload | null;
|
||||
locale: string;
|
||||
locales: Record<string, string>;
|
||||
resources: Record<string, unknown>[];
|
||||
resources: SavedModelData[];
|
||||
session: { userId: number; csrfToken: string };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@@ -255,9 +256,9 @@ export default class Application {
|
||||
|
||||
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('users', this.data.session.userId), this.data.session.csrfToken);
|
||||
this.session = new Session(this.store.getById<User>('users', String(this.data.session.userId)) ?? null, this.data.session.csrfToken);
|
||||
|
||||
this.mount();
|
||||
|
||||
@@ -317,10 +318,14 @@ export default class Application {
|
||||
/**
|
||||
* Get the API response document that has been preloaded into the application.
|
||||
*/
|
||||
preloadedApiDocument(): Record<string, unknown> | null {
|
||||
preloadedApiDocument<M extends Model>(): ApiResponseSingle<M> | 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 (this.data.apiDocument && window.location.href === this.initialRoute) {
|
||||
const results = this.store.pushPayload(this.data.apiDocument);
|
||||
const results = payloadIsPlural(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;
|
||||
|
||||
@@ -366,7 +371,7 @@ export default class Application {
|
||||
}
|
||||
|
||||
protected transformRequestOptions<ResponseType>(flarumOptions: FlarumRequestOptions<ResponseType>): InternalFlarumRequestOptions<ResponseType> {
|
||||
const { background, deserialize, errorHandler, extract, modifyText, ...tmpOptions } = { ...flarumOptions };
|
||||
const { background, deserialize, extract, modifyText, ...tmpOptions } = { ...flarumOptions };
|
||||
|
||||
// Unless specified otherwise, requests should run asynchronously in the
|
||||
// background, so that they don't prevent redraws from occurring.
|
||||
@@ -380,10 +385,6 @@ export default class Application {
|
||||
// so it errors due to Mithril's typings
|
||||
const defaultDeserialize = (response: string) => response as ResponseType;
|
||||
|
||||
const defaultErrorHandler = (error: RequestError) => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
// When extracting the data from the response, we can check the server
|
||||
// response code and show an error message to the user if something's gone
|
||||
// awry.
|
||||
@@ -392,7 +393,6 @@ export default class Application {
|
||||
const options: InternalFlarumRequestOptions<ResponseType> = {
|
||||
background: background ?? defaultBackground,
|
||||
deserialize: deserialize ?? defaultDeserialize,
|
||||
errorHandler: errorHandler ?? defaultErrorHandler,
|
||||
...tmpOptions,
|
||||
};
|
||||
|
||||
@@ -455,91 +455,95 @@ export default class Application {
|
||||
* @param options
|
||||
* @return {Promise}
|
||||
*/
|
||||
request<ResponseType>(originalOptions: FlarumRequestOptions<ResponseType>): Promise<ResponseType | string> {
|
||||
request<ResponseType>(originalOptions: FlarumRequestOptions<ResponseType>): Promise<ResponseType> {
|
||||
const options = this.transformRequestOptions(originalOptions);
|
||||
|
||||
if (this.requestErrorAlert) this.alerts.dismiss(this.requestErrorAlert);
|
||||
|
||||
// Now make the request. If it's a failure, inspect the error that was
|
||||
// returned and show an alert containing its contents.
|
||||
return m.request(options).then(
|
||||
(response) => response,
|
||||
(error: RequestError) => {
|
||||
let content;
|
||||
|
||||
switch (error.status) {
|
||||
case 422:
|
||||
content = ((error.response?.errors ?? {}) as Record<string, unknown>[])
|
||||
.map((error) => [error.detail, <br />])
|
||||
.flat()
|
||||
.slice(0, -1);
|
||||
break;
|
||||
|
||||
case 401:
|
||||
case 403:
|
||||
content = app.translator.trans('core.lib.error.permission_denied_message');
|
||||
break;
|
||||
|
||||
case 404:
|
||||
case 410:
|
||||
content = app.translator.trans('core.lib.error.not_found_message');
|
||||
break;
|
||||
|
||||
case 413:
|
||||
content = app.translator.trans('core.lib.error.payload_too_large_message');
|
||||
break;
|
||||
|
||||
case 429:
|
||||
content = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
|
||||
break;
|
||||
|
||||
default:
|
||||
content = app.translator.trans('core.lib.error.generic_message');
|
||||
}
|
||||
|
||||
const isDebug = app.forum.attribute('debug');
|
||||
// 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;
|
||||
|
||||
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>,
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
options.errorHandler(error);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestError) {
|
||||
if (isDebug && error.xhr) {
|
||||
const { method, url } = error.options;
|
||||
const { status = '' } = error.xhr;
|
||||
|
||||
console.group(`${method} ${url} ${status}`);
|
||||
|
||||
console.error(...(formattedError || [error]));
|
||||
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
this.requestErrorAlert = this.alerts.show(error.alert, error.alert.content);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
return m.request(options).catch((e) => this.requestErrorCatch(e, originalOptions.errorHandler));
|
||||
}
|
||||
|
||||
private showDebug(error: RequestError, formattedError?: string[]) {
|
||||
/**
|
||||
* By default, show an error alert, and log the error to the console.
|
||||
*/
|
||||
protected requestErrorCatch<ResponseType>(error: RequestError, customErrorHandler: FlarumRequestOptions<ResponseType>['errorHandler']) {
|
||||
// the details property is decoded to transform escaped characters such as '\n'
|
||||
const formattedErrors = error.response?.errors?.map((e) => decodeURI(e.detail ?? '')) ?? [];
|
||||
|
||||
let content;
|
||||
switch (error.status) {
|
||||
case 422:
|
||||
content = formattedErrors
|
||||
.map((detail) => [detail, <br />])
|
||||
.flat()
|
||||
.slice(0, -1);
|
||||
break;
|
||||
|
||||
case 401:
|
||||
case 403:
|
||||
content = app.translator.trans('core.lib.error.permission_denied_message');
|
||||
break;
|
||||
|
||||
case 404:
|
||||
case 410:
|
||||
content = app.translator.trans('core.lib.error.not_found_message');
|
||||
break;
|
||||
|
||||
case 413:
|
||||
content = app.translator.trans('core.lib.error.payload_too_large_message');
|
||||
break;
|
||||
|
||||
case 429:
|
||||
content = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
|
||||
break;
|
||||
|
||||
default:
|
||||
content = app.translator.trans('core.lib.error.generic_message');
|
||||
}
|
||||
|
||||
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, formattedErrors)}>
|
||||
{app.translator.trans('core.lib.debug_button')}
|
||||
</Button>,
|
||||
],
|
||||
};
|
||||
|
||||
if (customErrorHandler) {
|
||||
customErrorHandler(error);
|
||||
} else {
|
||||
this.requestErrorDefaultHandler(error, isDebug, formattedErrors);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
protected requestErrorDefaultHandler(e: unknown, isDebug: boolean, formattedErrors: string[]): void {
|
||||
if (e instanceof RequestError) {
|
||||
if (isDebug && e.xhr) {
|
||||
const { method, url } = e.options;
|
||||
const { status = '' } = e.xhr;
|
||||
|
||||
console.group(`${method} ${url} ${status}`);
|
||||
|
||||
console.error(...(formattedErrors || [e]));
|
||||
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
if (e.alert) {
|
||||
this.requestErrorAlert = this.alerts.show(e.alert, e.alert.content);
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private showDebug(error: RequestError, formattedError: string[]) {
|
||||
if (this.requestErrorAlert !== null) this.alerts.dismiss(this.requestErrorAlert);
|
||||
|
||||
this.modal.show(RequestErrorModal, { error, formattedError });
|
||||
|
@@ -1,323 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
360
js/src/common/Model.ts
Normal file
360
js/src/common/Model.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
@@ -7,11 +7,8 @@ export type LoginParams = {
|
||||
* The username/email
|
||||
*/
|
||||
identification: string;
|
||||
|
||||
/**
|
||||
* Password
|
||||
*/
|
||||
password: string;
|
||||
remember: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
@@ -1,171 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
244
js/src/common/Store.ts
Normal file
244
js/src/common/Store.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
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);
|
||||
}
|
||||
}
|
@@ -1,17 +1,28 @@
|
||||
import app from '../../common/app';
|
||||
import Modal from './Modal';
|
||||
import Modal, { IInternalModalAttrs } from './Modal';
|
||||
import Button from './Button';
|
||||
import GroupBadge from './GroupBadge';
|
||||
import Group from '../models/Group';
|
||||
import extractText from '../utils/extractText';
|
||||
import ItemList from '../utils/ItemList';
|
||||
import Stream from '../utils/Stream';
|
||||
import type Mithril from 'mithril';
|
||||
import type User from '../models/User';
|
||||
import type { SaveAttributes, SaveRelationships } from '../Model';
|
||||
|
||||
/**
|
||||
* The `EditUserModal` component displays a modal dialog with a login form.
|
||||
*/
|
||||
export default class EditUserModal extends Modal {
|
||||
oninit(vnode) {
|
||||
export interface IEditUserModalAttrs extends IInternalModalAttrs {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default class EditUserModal<CustomAttrs extends IEditUserModalAttrs = IEditUserModalAttrs> extends Modal<CustomAttrs> {
|
||||
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);
|
||||
|
||||
const user = this.attrs.user;
|
||||
@@ -19,14 +30,15 @@ export default class EditUserModal extends Modal {
|
||||
this.username = Stream(user.username() || '');
|
||||
this.email = Stream(user.email() || '');
|
||||
this.isEmailConfirmed = Stream(user.isEmailConfirmed() || false);
|
||||
this.setPassword = Stream(false);
|
||||
this.setPassword = Stream(false as boolean);
|
||||
this.password = Stream(user.password() || '');
|
||||
this.groups = {};
|
||||
|
||||
const userGroups = user.groups() || [];
|
||||
|
||||
app.store
|
||||
.all('groups')
|
||||
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
||||
.forEach((group) => (this.groups[group.id()] = Stream(user.groups().indexOf(group) !== -1)));
|
||||
.all<Group>('groups')
|
||||
.filter((group) => ![Group.GUEST_ID, Group.MEMBER_ID].includes(group.id()!))
|
||||
.forEach((group) => (this.groups[group.id()!] = Stream(userGroups.includes(group))));
|
||||
}
|
||||
|
||||
className() {
|
||||
@@ -49,7 +61,7 @@ export default class EditUserModal extends Modal {
|
||||
fields() {
|
||||
const items = new ItemList();
|
||||
|
||||
if (app.session.user.canEditCredentials()) {
|
||||
if (app.session.user?.canEditCredentials()) {
|
||||
items.add(
|
||||
'username',
|
||||
<div className="Form-group">
|
||||
@@ -103,10 +115,11 @@ export default class EditUserModal extends Modal {
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
onchange={(e) => {
|
||||
this.setPassword(e.target.checked);
|
||||
onchange={(e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
this.setPassword(target.checked);
|
||||
m.redraw.sync();
|
||||
if (e.target.checked) this.$('[name=password]').select();
|
||||
if (target.checked) this.$('[name=password]').select();
|
||||
e.redraw = false;
|
||||
}}
|
||||
disabled={this.nonAdminEditingAdmin()}
|
||||
@@ -132,24 +145,31 @@ export default class EditUserModal extends Modal {
|
||||
}
|
||||
}
|
||||
|
||||
if (app.session.user.canEditGroups()) {
|
||||
if (app.session.user?.canEditGroups()) {
|
||||
items.add(
|
||||
'groups',
|
||||
<div className="Form-group EditUserModal-groups">
|
||||
<label>{app.translator.trans('core.lib.edit_user.groups_heading')}</label>
|
||||
<div>
|
||||
{Object.keys(this.groups)
|
||||
.map((id) => app.store.getById('groups', id))
|
||||
.map((group) => (
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
bidi={this.groups[group.id()]}
|
||||
disabled={group.id() === Group.ADMINISTRATOR_ID && (this.attrs.user === app.session.user || !this.userIsAdmin(app.session.user))}
|
||||
/>
|
||||
{GroupBadge.component({ group, label: '' })} {group.nameSingular()}
|
||||
</label>
|
||||
))}
|
||||
.map((id) => app.store.getById<Group>('groups', id))
|
||||
.filter(Boolean)
|
||||
.map(
|
||||
(group) =>
|
||||
// Necessary because filter(Boolean) doesn't narrow out falsy values.
|
||||
group && (
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
bidi={this.groups[group.id()!]}
|
||||
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>,
|
||||
10
|
||||
@@ -194,9 +214,8 @@ export default class EditUserModal extends Modal {
|
||||
}
|
||||
|
||||
data() {
|
||||
const data = {
|
||||
relationships: {},
|
||||
};
|
||||
const data: SaveAttributes = {};
|
||||
const relationships: SaveRelationships = {};
|
||||
|
||||
if (this.attrs.user.canEditCredentials() && !this.nonAdminEditingAdmin()) {
|
||||
data.username = this.username();
|
||||
@@ -211,15 +230,18 @@ export default class EditUserModal extends Modal {
|
||||
}
|
||||
|
||||
if (this.attrs.user.canEditGroups()) {
|
||||
data.relationships.groups = Object.keys(this.groups)
|
||||
relationships.groups = Object.keys(this.groups)
|
||||
.filter((id) => this.groups[id]())
|
||||
.map((id) => app.store.getById('groups', id));
|
||||
.map((id) => app.store.getById<Group>('groups', id))
|
||||
.filter((g): g is Group => g instanceof Group);
|
||||
}
|
||||
|
||||
data.relationships = relationships;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
onsubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
this.loading = true;
|
||||
@@ -239,9 +261,8 @@ export default class EditUserModal extends Modal {
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* @protected
|
||||
*/
|
||||
userIsAdmin(user) {
|
||||
return user.groups().some((g) => g.id() === Group.ADMINISTRATOR_ID);
|
||||
protected userIsAdmin(user: User | null) {
|
||||
return user && (user.groups() || []).some((g) => g?.id() === Group.ADMINISTRATOR_ID);
|
||||
}
|
||||
}
|
@@ -30,9 +30,9 @@ export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IIn
|
||||
/**
|
||||
* Attributes for an alert component to show below the header.
|
||||
*/
|
||||
alertAttrs!: AlertAttrs;
|
||||
alertAttrs: AlertAttrs | null = null;
|
||||
|
||||
oninit(vnode: Mithril.VnodeDOM<ModalAttrs, this>) {
|
||||
oninit(vnode: Mithril.Vnode<ModalAttrs, this>) {
|
||||
super.oninit(vnode);
|
||||
|
||||
// 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.
|
||||
*/
|
||||
abstract title(): string;
|
||||
abstract title(): Mithril.Children;
|
||||
|
||||
/**
|
||||
* Get the content of the modal.
|
||||
|
@@ -1,6 +1,12 @@
|
||||
import Modal from './Modal';
|
||||
import type RequestError from '../utils/RequestError';
|
||||
import Modal, { IInternalModalAttrs } from './Modal';
|
||||
|
||||
export default class RequestErrorModal extends Modal {
|
||||
export interface IRequestErrorModalAttrs extends IInternalModalAttrs {
|
||||
error: RequestError;
|
||||
formattedError: string[];
|
||||
}
|
||||
|
||||
export default class RequestErrorModal<CustomAttrs extends IRequestErrorModalAttrs = IRequestErrorModalAttrs> extends Modal<CustomAttrs> {
|
||||
className() {
|
||||
return 'RequestErrorModal Modal--large';
|
||||
}
|
||||
@@ -18,14 +24,10 @@ export default class RequestErrorModal extends Modal {
|
||||
// else try to parse it as JSON and stringify it with indentation
|
||||
if (formattedError) {
|
||||
responseText = formattedError.join('\n\n');
|
||||
} else if (error.response) {
|
||||
responseText = JSON.stringify(error.response, null, 2);
|
||||
} else {
|
||||
try {
|
||||
const json = error.response || JSON.parse(error.responseText);
|
||||
|
||||
responseText = JSON.stringify(json, null, 2);
|
||||
} catch (e) {
|
||||
responseText = error.responseText;
|
||||
}
|
||||
responseText = error.responseText;
|
||||
}
|
||||
|
||||
return (
|
@@ -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 one.
|
||||
if (user) {
|
||||
const username: string = user.displayName() || '?';
|
||||
const avatarUrl: string = user.avatarUrl();
|
||||
const username = user.displayName() || '?';
|
||||
const avatarUrl = user.avatarUrl();
|
||||
|
||||
if (hasTitle) attrs.title = attrs.title || username;
|
||||
|
||||
|
@@ -16,3 +16,21 @@ export default function fireDebugWarning(...args: Parameters<typeof console.warn
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
@@ -3,29 +3,41 @@ import Component, { ComponentAttrs } from '../Component';
|
||||
import Separator from '../components/Separator';
|
||||
import classList from '../utils/classList';
|
||||
|
||||
export interface ModdedVnodeAttrs {
|
||||
type ModdedVnodeAttrs = {
|
||||
itemClassName?: string;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
export type ModdedVnode<Attrs> = Mithril.Vnode<ModdedVnodeAttrs, Component<Attrs> | {}> & {
|
||||
itemName?: string;
|
||||
itemClassName?: string;
|
||||
tag: Mithril.Vnode['tag'] & {
|
||||
isListItem?: boolean;
|
||||
isActive?: (attrs: ComponentAttrs) => boolean;
|
||||
};
|
||||
};
|
||||
|
||||
function isSeparator<Attrs>(item: ModdedVnode<Attrs>): boolean {
|
||||
return item.tag === Separator;
|
||||
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 withoutUnnecessarySeparators<Attrs>(items: ModdedVnode<Attrs>[]): ModdedVnode<Attrs>[] {
|
||||
const newItems: ModdedVnode<Attrs>[] = [];
|
||||
let prevItem: ModdedVnode<Attrs>;
|
||||
function isSeparator(item: ModdedChildren): boolean {
|
||||
return isVnode(item) && item.tag === Separator;
|
||||
}
|
||||
|
||||
items.filter(Boolean).forEach((item: Mithril.Vnode, i: number) => {
|
||||
function withoutUnnecessarySeparators(items: ModdedChildrenWithItemName[]): ModdedChildrenWithItemName[] {
|
||||
const newItems: ModdedChildrenWithItemName[] = [];
|
||||
let prevItem: ModdedChildren;
|
||||
|
||||
items.filter(Boolean).forEach((item, i: number) => {
|
||||
if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) {
|
||||
prevItem = item;
|
||||
newItems.push(item);
|
||||
@@ -42,38 +54,39 @@ function withoutUnnecessarySeparators<Attrs>(items: ModdedVnode<Attrs>[]): Modde
|
||||
* By default, this tag is an `<li>` tag, but this is customisable through the
|
||||
* second function parameter, `customTag`.
|
||||
*/
|
||||
export default function listItems<Attrs extends Record<string, unknown>>(
|
||||
rawItems: ModdedVnode<Attrs> | ModdedVnode<Attrs>[],
|
||||
customTag: string | Component<Attrs> = 'li',
|
||||
export default function listItems<Attrs extends ComponentAttrs>(
|
||||
rawItems: ModdedChildrenWithItemName[],
|
||||
customTag: VnodeElementTag<Attrs> = 'li',
|
||||
attributes: Attrs = {} as Attrs
|
||||
): Mithril.Vnode[] {
|
||||
const items = rawItems instanceof Array ? rawItems : [rawItems];
|
||||
const Tag = customTag;
|
||||
|
||||
return withoutUnnecessarySeparators(items).map((item: ModdedVnode<Attrs>) => {
|
||||
const isListItem = item.tag?.isListItem;
|
||||
const active = item.tag?.isActive?.(item.attrs);
|
||||
const className = item.attrs?.itemClassName || item.itemClassName;
|
||||
return withoutUnnecessarySeparators(items).map((item) => {
|
||||
const classes = [item.itemName && `item-${item.itemName}`];
|
||||
|
||||
if (isListItem) {
|
||||
if (isVnode(item) && item.tag.isListItem) {
|
||||
item.attrs = item.attrs || {};
|
||||
item.attrs.key = item.attrs.key || item.itemName;
|
||||
item.key = item.attrs.key;
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
const node: Mithril.Vnode = isListItem ? (
|
||||
item
|
||||
) : (
|
||||
// @ts-expect-error `Component` does not have any construct or call signatures
|
||||
<Tag
|
||||
className={classList([className, item.itemName && `item-${item.itemName}`, active && 'active'])}
|
||||
key={item?.attrs?.key || item.itemName}
|
||||
{...attributes}
|
||||
>
|
||||
if (isVnode(item)) {
|
||||
classes.push(item.attrs?.itemClassName || item.itemClassName);
|
||||
|
||||
if (item.tag.isActive?.(item.attrs)) {
|
||||
classes.push('active');
|
||||
}
|
||||
}
|
||||
|
||||
const key = (isVnode(item) && item?.attrs?.key) || item.itemName;
|
||||
|
||||
return (
|
||||
<Tag className={classList(classes)} key={key} {...attributes}>
|
||||
{item}
|
||||
</Tag>
|
||||
);
|
||||
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
@@ -6,7 +6,7 @@ import User from '../models/User';
|
||||
* 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].
|
||||
*/
|
||||
export default function username(user: User): Mithril.Vnode {
|
||||
export default function username(user: User | null | undefined | false): Mithril.Vnode {
|
||||
const name = (user && user.displayName()) || app.translator.trans('core.lib.username.deleted_text');
|
||||
|
||||
return <span className="username">{name}</span>;
|
||||
|
@@ -1,108 +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';
|
||||
|
||||
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) : [];
|
||||
},
|
||||
});
|
146
js/src/common/models/Discussion.tsx
Normal file
146
js/src/common/models/Discussion.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
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) ?? [];
|
||||
}
|
||||
}
|
@@ -1,17 +0,0 @@
|
||||
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;
|
25
js/src/common/models/Group.ts
Normal file
25
js/src/common/models/Group.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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);
|
||||
}
|
||||
}
|
@@ -1,15 +0,0 @@
|
||||
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'),
|
||||
});
|
28
js/src/common/models/Notification.ts
Normal file
28
js/src/common/models/Notification.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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);
|
||||
}
|
||||
}
|
@@ -1,31 +0,0 @@
|
||||
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'),
|
||||
});
|
73
js/src/common/models/Post.ts
Normal file
73
js/src/common/models/Post.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
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);
|
||||
}
|
||||
}
|
@@ -1,124 +0,0 @@
|
||||
/*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 });
|
||||
},
|
||||
});
|
164
js/src/common/models/User.tsx
Normal file
164
js/src/common/models/User.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
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 });
|
||||
}
|
||||
}
|
@@ -27,7 +27,7 @@ export default class AlertManagerState {
|
||||
*/
|
||||
show(children: Mithril.Children): AlertIdentifier;
|
||||
show(attrs: AlertAttrs, children: Mithril.Children): AlertIdentifier;
|
||||
show(componentClass: Alert, attrs: AlertAttrs, children: Mithril.Children): AlertIdentifier;
|
||||
show(componentClass: typeof Alert, attrs: AlertAttrs, children: Mithril.Children): AlertIdentifier;
|
||||
|
||||
show(arg1: any, arg2?: any, arg3?: any) {
|
||||
// Assigns variables as per the above signatures
|
||||
|
@@ -1,5 +1,15 @@
|
||||
import type Component from '../Component';
|
||||
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.
|
||||
*
|
||||
@@ -10,7 +20,7 @@ export default class ModalManagerState {
|
||||
* @internal
|
||||
*/
|
||||
modal: null | {
|
||||
componentClass: typeof Modal;
|
||||
componentClass: UnsafeModalClass;
|
||||
attrs?: Record<string, unknown>;
|
||||
} = null;
|
||||
|
||||
@@ -28,7 +38,7 @@ export default class ModalManagerState {
|
||||
* // This "hack" is needed due to quirks with nested redraws in Mithril.
|
||||
* setTimeout(() => app.modal.show(MyCoolModal, { attr: 'value' }), 0);
|
||||
*/
|
||||
show(componentClass: typeof Modal, attrs: Record<string, unknown> = {}): void {
|
||||
show(componentClass: UnsafeModalClass, attrs: Record<string, unknown> = {}): void {
|
||||
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.
|
||||
const invalidModalWarning = 'The ModalManager can only show Modals.';
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import app from '../../common/app';
|
||||
import Model from '../Model';
|
||||
import { ApiQueryParamsPlural, ApiResponsePlural } from '../Store';
|
||||
|
||||
export interface Page<TModel> {
|
||||
number: number;
|
||||
@@ -19,6 +20,10 @@ export interface PaginatedListParams {
|
||||
[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> {
|
||||
protected location!: PaginationLocation;
|
||||
protected pageSize: number;
|
||||
@@ -39,7 +44,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
||||
|
||||
abstract get type(): string;
|
||||
|
||||
public clear() {
|
||||
public clear(): void {
|
||||
this.pages = [];
|
||||
|
||||
m.redraw();
|
||||
@@ -69,15 +74,15 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
||||
.finally(() => (this.loadingNext = false));
|
||||
}
|
||||
|
||||
protected parseResults(pg: number, results: T[]) {
|
||||
protected parseResults(pg: number, results: ApiResponsePlural<T>): void {
|
||||
const pageNum = Number(pg);
|
||||
|
||||
const links = results.payload?.links || {};
|
||||
const links = results.payload?.links;
|
||||
const page = {
|
||||
number: pageNum,
|
||||
items: results,
|
||||
hasNext: !!links.next,
|
||||
hasPrev: !!links.prev,
|
||||
hasNext: !!links?.next,
|
||||
hasPrev: !!links?.prev,
|
||||
};
|
||||
|
||||
if (this.isEmpty() || pageNum > this.getNextPageNumber() - 1) {
|
||||
@@ -94,18 +99,21 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
||||
/**
|
||||
* Load a new page of results.
|
||||
*/
|
||||
protected loadPage(page = 1): Promise<T[]> {
|
||||
const params = this.requestParams();
|
||||
params.page = {
|
||||
...params.page,
|
||||
offset: this.pageSize * (page - 1),
|
||||
protected loadPage(page = 1): Promise<ApiResponsePlural<T>> {
|
||||
const reqParams = this.requestParams();
|
||||
|
||||
const include = Array.isArray(reqParams.include) ? reqParams.include.join(',') : reqParams.include;
|
||||
|
||||
const params: ApiQueryParamsPlural = {
|
||||
...reqParams,
|
||||
page: {
|
||||
...reqParams.page,
|
||||
offset: this.pageSize * (page - 1),
|
||||
},
|
||||
include,
|
||||
};
|
||||
|
||||
if (Array.isArray(params.include)) {
|
||||
params.include = params.include.join(',');
|
||||
}
|
||||
|
||||
return app.store.find(this.type, params);
|
||||
return app.store.find<T[]>(this.type, params);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,7 +123,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
||||
* @abstract
|
||||
* @see loadPage
|
||||
*/
|
||||
protected requestParams(): any {
|
||||
protected requestParams(): PaginatedListRequestParams {
|
||||
return this.params;
|
||||
}
|
||||
|
||||
@@ -137,7 +145,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public refresh(page: number = 1) {
|
||||
public refresh(page: number = 1): Promise<void> {
|
||||
this.initialLoading = true;
|
||||
this.loadingPrev = false;
|
||||
this.loadingNext = false;
|
||||
@@ -147,14 +155,14 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
||||
this.location = { page };
|
||||
|
||||
return this.loadPage()
|
||||
.then((results: T[]) => {
|
||||
.then((results) => {
|
||||
this.pages = [];
|
||||
this.parseResults(this.location.page, results);
|
||||
})
|
||||
.finally(() => (this.initialLoading = false));
|
||||
}
|
||||
|
||||
public getPages() {
|
||||
public getPages(): Page<T>[] {
|
||||
return this.pages;
|
||||
}
|
||||
public getLocation(): PaginationLocation {
|
||||
@@ -203,7 +211,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
||||
/**
|
||||
* Stored state parameters.
|
||||
*/
|
||||
public getParams(): any {
|
||||
public getParams(): P {
|
||||
return this.params;
|
||||
}
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import type Mithril from 'mithril';
|
||||
import type { AlertAttrs } from '../components/Alert';
|
||||
|
||||
export type InternalFlarumRequestOptions<ResponseType> = Mithril.RequestOptions<ResponseType> & {
|
||||
errorHandler: (error: RequestError) => void;
|
||||
url: string;
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ export default class RequestError<ResponseType = string> {
|
||||
}[];
|
||||
} | null;
|
||||
|
||||
alert: any;
|
||||
alert: AlertAttrs | null;
|
||||
|
||||
constructor(status: number, responseText: string | null, options: InternalFlarumRequestOptions<ResponseType>, xhr: XMLHttpRequest) {
|
||||
this.status = status;
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import app from '../../common/app';
|
||||
import extractText from './extractText';
|
||||
|
||||
/**
|
||||
* The `abbreviateNumber` utility converts a number to a shorter localized form.
|
||||
@@ -10,9 +11,9 @@ import app from '../../common/app';
|
||||
export default function abbreviateNumber(number: number): string {
|
||||
// TODO: translation
|
||||
if (number >= 1000000) {
|
||||
return Math.floor(number / 1000000) + app.translator.trans('core.lib.number_suffix.mega_text');
|
||||
return Math.floor(number / 1000000) + extractText(app.translator.trans('core.lib.number_suffix.mega_text'));
|
||||
} else if (number >= 1000) {
|
||||
return (number / 1000).toFixed(1) + app.translator.trans('core.lib.number_suffix.kilo_text');
|
||||
return (number / 1000).toFixed(1) + extractText(app.translator.trans('core.lib.number_suffix.kilo_text'));
|
||||
} else {
|
||||
return number.toString();
|
||||
}
|
||||
|
@@ -1,3 +1,5 @@
|
||||
import Model from '../Model';
|
||||
|
||||
/**
|
||||
* The `computed` utility creates a function that will cache its output until
|
||||
* any of the dependent values are dirty.
|
||||
@@ -7,20 +9,21 @@
|
||||
* dependent values.
|
||||
* @return {Function}
|
||||
*/
|
||||
export default function computed(...dependentKeys) {
|
||||
const keys = dependentKeys.slice(0, -1);
|
||||
const compute = dependentKeys.slice(-1)[0];
|
||||
export default function computed<T, M = Model>(...args: [...string[], (this: M, ...args: unknown[]) => T]): () => T {
|
||||
const keys = args.slice(0, -1) as string[];
|
||||
const compute = args.slice(-1)[0] as (this: M, ...args: unknown[]) => T;
|
||||
|
||||
const dependentValues = {};
|
||||
let computedValue;
|
||||
const dependentValues: Record<string, unknown> = {};
|
||||
let computedValue: T;
|
||||
|
||||
return function () {
|
||||
return function (this: M) {
|
||||
let recompute = false;
|
||||
|
||||
// Read all of the dependent values. If any of them have changed since last
|
||||
// time, then we'll want to recompute our output.
|
||||
keys.forEach((key) => {
|
||||
const value = typeof this[key] === 'function' ? this[key]() : this[key];
|
||||
const attr = (this as Record<string, unknown | (() => unknown)>)[key];
|
||||
const value = typeof attr === 'function' ? attr.call(this) : attr;
|
||||
|
||||
if (dependentValues[key] !== value) {
|
||||
recompute = true;
|
@@ -1,15 +1,14 @@
|
||||
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,
|
||||
* running externally registered event handlers.
|
||||
*
|
||||
* @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 {
|
||||
/**
|
||||
* Arrays of registered event handlers, grouped by the event name.
|
||||
@@ -27,7 +26,7 @@ export default {
|
||||
* @protected
|
||||
*/
|
||||
getHandlers(event) {
|
||||
fireDebugWarning(deprecatedNotice);
|
||||
fireDeprecationWarning(deprecatedNotice, deprecationIssueId);
|
||||
|
||||
this.handlers = this.handlers || {};
|
||||
|
||||
@@ -44,7 +43,7 @@ export default {
|
||||
* @public
|
||||
*/
|
||||
trigger(event, ...args) {
|
||||
fireDebugWarning(deprecatedNotice);
|
||||
fireDeprecationWarning(deprecatedNotice, deprecationIssueId);
|
||||
|
||||
this.getHandlers(event).forEach((handler) => handler.apply(this, args));
|
||||
},
|
||||
@@ -56,7 +55,7 @@ export default {
|
||||
* @param {function} handler The function to handle the event.
|
||||
*/
|
||||
on(event, handler) {
|
||||
fireDebugWarning(deprecatedNotice);
|
||||
fireDeprecationWarning(deprecatedNotice, deprecationIssueId);
|
||||
|
||||
this.getHandlers(event).push(handler);
|
||||
},
|
||||
@@ -69,7 +68,7 @@ export default {
|
||||
* @param {function} handler The function to handle the event.
|
||||
*/
|
||||
one(event, handler) {
|
||||
fireDebugWarning(deprecatedNotice);
|
||||
fireDeprecationWarning(deprecatedNotice, deprecationIssueId);
|
||||
|
||||
const wrapper = function () {
|
||||
handler.apply(this, arguments);
|
||||
@@ -87,7 +86,7 @@ export default {
|
||||
* @param {function} handler The function that handles the event.
|
||||
*/
|
||||
off(event, handler) {
|
||||
fireDebugWarning(deprecatedNotice);
|
||||
fireDeprecationWarning(deprecatedNotice, deprecationIssueId);
|
||||
|
||||
const handlers = this.getHandlers(event);
|
||||
const index = handlers.indexOf(handler);
|
||||
|
@@ -28,11 +28,16 @@ export function slug(string: string): string {
|
||||
export function getPlainContent(string: string): string {
|
||||
const html = string.replace(/(<\/p>|<br>)/g, '$1 ').replace(/<img\b[^>]*>/gi, ' ');
|
||||
|
||||
const dom = $('<div/>').html(html);
|
||||
const element = new DOMParser().parseFromString(html, 'text/html').documentElement;
|
||||
|
||||
dom.find(getPlainContent.removeSelectors.join(',')).remove();
|
||||
getPlainContent.removeSelectors.forEach((selector) => {
|
||||
const el = element.querySelectorAll(selector);
|
||||
el.forEach((e) => {
|
||||
e.remove();
|
||||
});
|
||||
});
|
||||
|
||||
return dom.text().replace(/\s+/g, ' ').trim();
|
||||
return element.innerText.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -16,6 +16,7 @@ interface StyleArgs {
|
||||
scanFor: string;
|
||||
surroundWithNewlines: boolean;
|
||||
orderedList: boolean;
|
||||
unorderedList: boolean;
|
||||
trimFirst: boolean;
|
||||
}
|
||||
|
||||
@@ -30,6 +31,7 @@ const defaults: StyleArgs = {
|
||||
scanFor: '',
|
||||
surroundWithNewlines: false,
|
||||
orderedList: false,
|
||||
unorderedList: false,
|
||||
trimFirst: false,
|
||||
};
|
||||
|
||||
@@ -41,8 +43,8 @@ export default function styleSelectedText(textarea: HTMLTextAreaElement, styleAr
|
||||
const text = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
|
||||
|
||||
let result;
|
||||
if (styleArgs.orderedList) {
|
||||
result = orderedList(textarea);
|
||||
if (styleArgs.orderedList || styleArgs.unorderedList) {
|
||||
result = listStyle(textarea, styleArgs);
|
||||
} else if (styleArgs.multiline && isMultipleLines(text)) {
|
||||
result = multilineStyle(textarea, styleArgs);
|
||||
} else {
|
||||
@@ -77,6 +79,21 @@ function wordSelectionEnd(text: string, i: number, multiline: boolean): number {
|
||||
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 {
|
||||
if (textarea.selectionStart === textarea.selectionEnd) {
|
||||
textarea.selectionStart = wordSelectionStart(textarea.value, textarea.selectionStart);
|
||||
@@ -140,7 +157,9 @@ function blockStyle(textarea: HTMLTextAreaElement, arg: StyleArgs): SelectionRan
|
||||
|
||||
let selectedText = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
|
||||
let prefixToUse = isMultipleLines(selectedText) && blockPrefix.length > 0 ? `${blockPrefix}\n` : prefix;
|
||||
let suffixToUse = isMultipleLines(selectedText) && blockSuffix.length > 0 ? `\n${blockSuffix}` : suffix;
|
||||
// CHANGED
|
||||
let suffixToUse = isMultipleLines(selectedText) && blockSuffix.length > 0 ? `\n${blockSuffix}` : prefixToUse === prefix ? suffix : '';
|
||||
// END CHANGED
|
||||
|
||||
if (prefixSpace) {
|
||||
const beforeSelection = textarea.value[textarea.selectionStart - 1];
|
||||
@@ -198,19 +217,25 @@ function blockStyle(textarea: HTMLTextAreaElement, arg: StyleArgs): SelectionRan
|
||||
}
|
||||
|
||||
function multilineStyle(textarea: HTMLTextAreaElement, arg: StyleArgs) {
|
||||
const { prefix, suffix, surroundWithNewlines } = arg;
|
||||
const { prefix, suffix, blockPrefix, blockSuffix, surroundWithNewlines } = arg;
|
||||
let text = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
|
||||
let selectionStart = textarea.selectionStart;
|
||||
let selectionEnd = textarea.selectionEnd;
|
||||
const lines = text.split('\n');
|
||||
const undoStyle = lines.every((line) => line.startsWith(prefix) && line.endsWith(suffix));
|
||||
// CHANGED
|
||||
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) {
|
||||
text = lines.map((line) => line.slice(prefix.length, line.length - suffix.length)).join('\n');
|
||||
text = lines.map((line) => line.slice(prefixToUse.length, line.length - suffixToUse.length)).join('\n');
|
||||
selectionEnd = selectionStart + text.length;
|
||||
} else {
|
||||
text = lines.map((line) => prefix + line + suffix).join('\n');
|
||||
if (surroundWithNewlines) {
|
||||
// CHANGED
|
||||
text = lines.map((line) => prefixToUse + line + suffixToUse).join('\n');
|
||||
if (surroundWithNewlines || suffixToUse === '') {
|
||||
// END CHANGED
|
||||
const { newlinesToAppend, newlinesToPrepend } = newlinesToSurroundSelectedText(textarea);
|
||||
selectionStart += newlinesToAppend.length;
|
||||
selectionEnd = selectionStart + text.length;
|
||||
@@ -221,54 +246,116 @@ function multilineStyle(textarea: HTMLTextAreaElement, arg: StyleArgs) {
|
||||
return { text, selectionStart, selectionEnd };
|
||||
}
|
||||
|
||||
function orderedList(textarea: HTMLTextAreaElement): SelectionRange {
|
||||
interface UndoResult {
|
||||
text: string;
|
||||
processed: boolean;
|
||||
}
|
||||
function undoOrderedListStyle(text: string): UndoResult {
|
||||
const lines = text.split('\n');
|
||||
const orderedListRegex = /^\d+\.\s+/;
|
||||
const noInitialSelection = textarea.selectionStart === textarea.selectionEnd;
|
||||
let selectionEnd;
|
||||
let selectionStart;
|
||||
let text = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
|
||||
let textToUnstyle = text;
|
||||
let lines = text.split('\n');
|
||||
let startOfLine, endOfLine;
|
||||
if (noInitialSelection) {
|
||||
const linesBefore = textarea.value.slice(0, textarea.selectionStart).split(/\n/);
|
||||
startOfLine = textarea.selectionStart - linesBefore[linesBefore.length - 1].length;
|
||||
endOfLine = wordSelectionEnd(textarea.value, textarea.selectionStart, true);
|
||||
textToUnstyle = textarea.value.slice(startOfLine, endOfLine);
|
||||
const shouldUndoOrderedList = lines.every((line) => orderedListRegex.test(line));
|
||||
let result = lines;
|
||||
if (shouldUndoOrderedList) {
|
||||
result = lines.map((line) => line.replace(orderedListRegex, ''));
|
||||
}
|
||||
const linesToUnstyle = textToUnstyle.split('\n');
|
||||
const undoStyling = linesToUnstyle.every((line) => orderedListRegex.test(line));
|
||||
|
||||
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;
|
||||
}
|
||||
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 {
|
||||
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 `${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;
|
||||
let selectionStart = textarea.selectionStart;
|
||||
let selectionEnd = textarea.selectionEnd;
|
||||
|
||||
// Select whole line
|
||||
expandSelectionToLine(textarea);
|
||||
|
||||
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) {
|
||||
selectionStart = Math.max(selectionStart + makePrefix(0, style.unorderedList).length + newlinesToAppend.length, 0);
|
||||
selectionEnd = selectionStart;
|
||||
} else {
|
||||
if (undoResultOpositeList.processed) {
|
||||
selectionStart = Math.max(textarea.selectionStart + newlinesToAppend.length, 0);
|
||||
selectionEnd = textarea.selectionEnd + newlinesToAppend.length + totalPrefixLength - totalPrefixLengthOpositeList;
|
||||
} else {
|
||||
selectionStart = Math.max(textarea.selectionStart + newlinesToAppend.length, 0);
|
||||
selectionEnd = textarea.selectionEnd + newlinesToAppend.length + totalPrefixLength;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
@@ -23,6 +23,7 @@ import isSafariMobile from './utils/isSafariMobile';
|
||||
import type Notification from './components/Notification';
|
||||
import type Post from './components/Post';
|
||||
import Discussion from '../common/models/Discussion';
|
||||
import extractText from '../common/utils/extractText';
|
||||
|
||||
export default class ForumApplication extends Application {
|
||||
/**
|
||||
@@ -99,7 +100,7 @@ export default class ForumApplication extends Application {
|
||||
}
|
||||
|
||||
this.routes[defaultAction].path = '/';
|
||||
this.history.push(defaultAction, this.translator.trans('core.forum.header.back_to_index_tooltip'), '/');
|
||||
this.history.push(defaultAction, extractText(this.translator.trans('core.forum.header.back_to_index_tooltip')), '/');
|
||||
|
||||
this.pane = new Pane(document.getElementById('app'));
|
||||
|
||||
@@ -124,8 +125,9 @@ export default class ForumApplication extends Application {
|
||||
app.history.home();
|
||||
|
||||
// Reload the current user so that their unread notification count is refreshed.
|
||||
if (app.session.user) {
|
||||
app.store.find('users', app.session.user.id());
|
||||
const userId = app.session.user?.id();
|
||||
if (userId) {
|
||||
app.store.find('users', userId);
|
||||
m.redraw();
|
||||
}
|
||||
});
|
||||
|
@@ -14,6 +14,7 @@ import DiscussionControls from '../utils/DiscussionControls';
|
||||
import PostStreamState from '../states/PostStreamState';
|
||||
import Discussion from '../../common/models/Discussion';
|
||||
import Post from '../../common/models/Post';
|
||||
import { ApiResponseSingle } from '../../common/Store';
|
||||
|
||||
export interface IDiscussionPageAttrs extends IPageAttrs {
|
||||
id: string;
|
||||
@@ -163,7 +164,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
||||
* Load the discussion from the API or use the preloaded one.
|
||||
*/
|
||||
load() {
|
||||
const preloadedDiscussion = app.preloadedApiDocument() as Discussion | null;
|
||||
const preloadedDiscussion = app.preloadedApiDocument<Discussion>();
|
||||
if (preloadedDiscussion) {
|
||||
// We must wrap this in a setTimeout because if we are mounting this
|
||||
// component for the first time on page load, then any calls to m.redraw
|
||||
@@ -173,7 +174,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
||||
} else {
|
||||
const params = this.requestParams();
|
||||
|
||||
app.store.find('discussions', m.route.param('id'), params).then(this.show.bind(this));
|
||||
app.store.find<Discussion>('discussions', m.route.param('id'), params).then(this.show.bind(this));
|
||||
}
|
||||
|
||||
m.redraw();
|
||||
@@ -195,7 +196,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
||||
/**
|
||||
* Initialize the component to display the given discussion.
|
||||
*/
|
||||
show(discussion: Discussion) {
|
||||
show(discussion: ApiResponseSingle<Discussion>) {
|
||||
app.history.push('discussion', discussion.title());
|
||||
app.setTitle(discussion.title());
|
||||
app.setTitleCount(0);
|
||||
@@ -207,7 +208,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
||||
// 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 'discussion' relationship linked, then sorting and splicing.
|
||||
let includedPosts = [];
|
||||
let includedPosts: (Post | undefined)[] = [];
|
||||
if (discussion.payload && discussion.payload.included) {
|
||||
const discussionId = discussion.id();
|
||||
|
||||
@@ -217,10 +218,11 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
||||
record.type === 'posts' &&
|
||||
record.relationships &&
|
||||
record.relationships.discussion &&
|
||||
!Array.isArray(record.relationships.discussion.data) &&
|
||||
record.relationships.discussion.data.id === discussionId
|
||||
)
|
||||
.map((record) => app.store.getById('posts', record.id))
|
||||
.sort((a: Post, b: Post) => a.createdAt() - b.createdAt())
|
||||
.map((record) => app.store.getById<Post>('posts', record.id))
|
||||
.sort((a?: Post, b?: Post) => (a?.createdAt()?.getTime() ?? 0) - (b?.createdAt()?.getTime() ?? 0))
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
||||
@@ -228,7 +230,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
||||
// posts we want to display. Tell the stream to scroll down and highlight
|
||||
// the specific post that was routed to.
|
||||
this.stream = new PostStreamState(discussion, includedPosts);
|
||||
this.stream.goToNumber(m.route.param('near') || (includedPosts[0] && includedPosts[0].number()), true).then(() => {
|
||||
this.stream.goToNumber(m.route.param('near') || (includedPosts[0]?.number() ?? 0), true).then(() => {
|
||||
this.discussion = discussion;
|
||||
|
||||
app.current.set('discussion', discussion);
|
||||
|
@@ -24,7 +24,7 @@ export default class DiscussionsSearchSource implements SearchSource {
|
||||
include: 'mostRelevantPost',
|
||||
};
|
||||
|
||||
return app.store.find('discussions', params).then((results) => {
|
||||
return app.store.find<Discussion[]>('discussions', params).then((results) => {
|
||||
this.results.set(query, results);
|
||||
m.redraw();
|
||||
});
|
||||
@@ -38,9 +38,13 @@ export default class DiscussionsSearchSource implements SearchSource {
|
||||
|
||||
return (
|
||||
<li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()}>
|
||||
<Link href={app.route.discussion(discussion, mostRelevantPost && mostRelevantPost.number())}>
|
||||
<Link href={app.route.discussion(discussion, (mostRelevantPost && mostRelevantPost.number()) || 0)}>
|
||||
<div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div>
|
||||
{mostRelevantPost ? <div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain(), query, 100)}</div> : ''}
|
||||
{mostRelevantPost ? (
|
||||
<div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain() ?? '', query, 100)}</div>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
|
@@ -1,34 +1,31 @@
|
||||
import app from '../../forum/app';
|
||||
import Modal from '../../common/components/Modal';
|
||||
import Modal, { IInternalModalAttrs } from '../../common/components/Modal';
|
||||
import Button from '../../common/components/Button';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
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
|
||||
* enter their email address and request a link to reset their password.
|
||||
*
|
||||
* ### Attrs
|
||||
*
|
||||
* - `email`
|
||||
*/
|
||||
export default class ForgotPasswordModal extends Modal {
|
||||
oninit(vnode) {
|
||||
export default class ForgotPasswordModal<CustomAttrs extends IForgotPasswordModalAttrs = IForgotPasswordModalAttrs> extends Modal<CustomAttrs> {
|
||||
/**
|
||||
* The value of the email input.
|
||||
*/
|
||||
email!: Stream<string>;
|
||||
|
||||
success: boolean = false;
|
||||
|
||||
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
|
||||
super.oninit(vnode);
|
||||
|
||||
/**
|
||||
* The value of the email input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.email = Stream(this.attrs.email || '');
|
||||
|
||||
/**
|
||||
* Whether or not the password reset email was sent successfully.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.success = false;
|
||||
}
|
||||
|
||||
className() {
|
||||
@@ -55,6 +52,8 @@ export default class ForgotPasswordModal extends Modal {
|
||||
);
|
||||
}
|
||||
|
||||
const emailLabel = extractText(app.translator.trans('core.forum.forgot_password.email_placeholder'));
|
||||
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<div className="Form Form--centered">
|
||||
@@ -64,7 +63,8 @@ export default class ForgotPasswordModal extends Modal {
|
||||
className="FormControl"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder={extractText(app.translator.trans('core.forum.forgot_password.email_placeholder'))}
|
||||
placeholder={emailLabel}
|
||||
aria-label={emailLabel}
|
||||
bidi={this.email}
|
||||
disabled={this.loading}
|
||||
/>
|
||||
@@ -84,7 +84,7 @@ export default class ForgotPasswordModal extends Modal {
|
||||
);
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
onsubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
this.loading = true;
|
||||
@@ -98,14 +98,14 @@ export default class ForgotPasswordModal extends Modal {
|
||||
})
|
||||
.then(() => {
|
||||
this.success = true;
|
||||
this.alert = null;
|
||||
this.alertAttrs = null;
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(this.loaded.bind(this));
|
||||
}
|
||||
|
||||
onerror(error) {
|
||||
if (error.status === 404) {
|
||||
onerror(error: RequestError) {
|
||||
if (error.status === 404 && error.alert) {
|
||||
error.alert.content = app.translator.trans('core.forum.forgot_password.not_found_message');
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import app from '../../forum/app';
|
||||
import Modal from '../../common/components/Modal';
|
||||
import Modal, { IInternalModalAttrs } from '../../common/components/Modal';
|
||||
import ForgotPasswordModal from './ForgotPasswordModal';
|
||||
import SignUpModal from './SignUpModal';
|
||||
import Button from '../../common/components/Button';
|
||||
@@ -7,38 +7,34 @@ import LogInButtons from './LogInButtons';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import type Mithril from 'mithril';
|
||||
import RequestError from '../../common/utils/RequestError';
|
||||
|
||||
/**
|
||||
* The `LogInModal` component displays a modal dialog with a login form.
|
||||
*
|
||||
* ### Attrs
|
||||
*
|
||||
* - `identification`
|
||||
* - `password`
|
||||
*/
|
||||
export default class LogInModal extends Modal {
|
||||
oninit(vnode) {
|
||||
export interface ILoginModalAttrs extends IInternalModalAttrs {
|
||||
identification?: string;
|
||||
password?: string;
|
||||
remember?: boolean;
|
||||
}
|
||||
|
||||
export default class LogInModal<CustomAttrs extends ILoginModalAttrs = ILoginModalAttrs> extends Modal<CustomAttrs> {
|
||||
/**
|
||||
* The value of the identification input.
|
||||
*/
|
||||
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);
|
||||
|
||||
/**
|
||||
* The value of the identification input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.identification = Stream(this.attrs.identification || '');
|
||||
|
||||
/**
|
||||
* The value of the password input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.password = Stream(this.attrs.password || '');
|
||||
|
||||
/**
|
||||
* The value of the remember me input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.remember = Stream(!!this.attrs.remember);
|
||||
}
|
||||
|
||||
@@ -61,6 +57,9 @@ export default class LogInModal extends Modal {
|
||||
fields() {
|
||||
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(
|
||||
'identification',
|
||||
<div className="Form-group">
|
||||
@@ -68,7 +67,8 @@ export default class LogInModal extends Modal {
|
||||
className="FormControl"
|
||||
name="identification"
|
||||
type="text"
|
||||
placeholder={extractText(app.translator.trans('core.forum.log_in.username_or_email_placeholder'))}
|
||||
placeholder={identificationLabel}
|
||||
aria-label={identificationLabel}
|
||||
bidi={this.identification}
|
||||
disabled={this.loading}
|
||||
/>
|
||||
@@ -84,7 +84,8 @@ export default class LogInModal extends Modal {
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder={extractText(app.translator.trans('core.forum.log_in.password_placeholder'))}
|
||||
placeholder={passwordLabel}
|
||||
aria-label={passwordLabel}
|
||||
bidi={this.password}
|
||||
disabled={this.loading}
|
||||
/>
|
||||
@@ -140,12 +141,10 @@ export default class LogInModal extends Modal {
|
||||
/**
|
||||
* Open the forgot password modal, prefilling it with an email if the user has
|
||||
* entered one.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
forgotPassword() {
|
||||
const email = this.identification();
|
||||
const attrs = email.indexOf('@') !== -1 ? { email } : undefined;
|
||||
const attrs = email.includes('@') ? { email } : undefined;
|
||||
|
||||
app.modal.show(ForgotPasswordModal, attrs);
|
||||
}
|
||||
@@ -153,13 +152,14 @@ export default class LogInModal extends Modal {
|
||||
/**
|
||||
* Open the sign up modal, prefilling it with an email/username/password if
|
||||
* the user has entered one.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
signUp() {
|
||||
const attrs = { password: this.password() };
|
||||
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);
|
||||
}
|
||||
@@ -168,7 +168,7 @@ export default class LogInModal extends Modal {
|
||||
this.$('[name=' + (this.identification() ? 'password' : 'identification') + ']').select();
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
onsubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
this.loading = true;
|
||||
@@ -182,8 +182,8 @@ export default class LogInModal extends Modal {
|
||||
.then(() => window.location.reload(), this.loaded.bind(this));
|
||||
}
|
||||
|
||||
onerror(error) {
|
||||
if (error.status === 401) {
|
||||
onerror(error: RequestError) {
|
||||
if (error.status === 401 && error.alert) {
|
||||
error.alert.content = app.translator.trans('core.forum.log_in.invalid_login_message');
|
||||
}
|
||||
|
@@ -5,6 +5,7 @@ import Button from '../../common/components/Button';
|
||||
import Link from '../../common/components/Link';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import Discussion from '../../common/models/Discussion';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
/**
|
||||
* The `NotificationList` component displays a list of the logged-in user's
|
||||
@@ -19,14 +20,7 @@ export default class NotificationList extends Component {
|
||||
<div className="NotificationList-header">
|
||||
<h4 className="App-titleControl App-titleControl--text">{app.translator.trans('core.forum.notifications.title')}</h4>
|
||||
|
||||
<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 className="App-primaryControl">{this.controlItems().toArray()}</div>
|
||||
</div>
|
||||
|
||||
<div className="NotificationList-content">{this.content(state)}</div>
|
||||
@@ -34,6 +28,24 @@ 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) {
|
||||
if (state.isLoading()) {
|
||||
return <LoadingIndicator className="LoadingIndicator--block" />;
|
||||
|
@@ -66,8 +66,8 @@ export default class Post extends Component {
|
||||
buttonClassName="Button Button--icon Button--flat"
|
||||
menuClassName="Dropdown-menu--right"
|
||||
icon="fas fa-ellipsis-h"
|
||||
onshow={() => this.$('.Post-actions').addClass('open')}
|
||||
onhide={() => this.$('.Post-actions').removeClass('open')}
|
||||
onshow={() => this.$('.Post-controls').addClass('open')}
|
||||
onhide={() => this.$('.Post-controls').removeClass('open')}
|
||||
accessibleToggleLabel={app.translator.trans('core.forum.post_controls.toggle_dropdown_accessible_label')}
|
||||
>
|
||||
{controls}
|
||||
|
@@ -55,6 +55,6 @@ export default class PostMeta extends Component {
|
||||
* @returns {String}
|
||||
*/
|
||||
getPermalink(post) {
|
||||
return window.location.origin + app.route.post(post);
|
||||
return app.forum.attribute('baseUrl') + app.route.post(post);
|
||||
}
|
||||
}
|
||||
|
@@ -9,8 +9,8 @@ import icon from '../../common/helpers/icon';
|
||||
import SearchState from '../states/SearchState';
|
||||
import DiscussionsSearchSource from './DiscussionsSearchSource';
|
||||
import UsersSearchSource from './UsersSearchSource';
|
||||
import { fireDeprecationWarning } from '../../common/helpers/fireDebugWarning';
|
||||
import type Mithril from 'mithril';
|
||||
import Model from '../../common/Model';
|
||||
|
||||
/**
|
||||
* The `SearchSource` interface defines a section of search results in the
|
||||
@@ -53,14 +53,33 @@ export interface SearchAttrs extends ComponentAttrs {
|
||||
*
|
||||
* - state: SearchState instance.
|
||||
*/
|
||||
export default class Search<T extends SearchAttrs = SearchAttrs> extends Component<T> {
|
||||
export default class Search<T extends SearchAttrs = SearchAttrs> extends Component<T, SearchState> {
|
||||
/**
|
||||
* The minimum query length before sources are searched.
|
||||
*/
|
||||
protected static MIN_SEARCH_LEN = 3;
|
||||
|
||||
/**
|
||||
* The instance of `SearchState` for this component.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
@@ -163,7 +182,7 @@ export default class Search<T extends SearchAttrs = SearchAttrs> extends Compone
|
||||
const maxHeight =
|
||||
window.innerHeight - this.element.querySelector('.Search-input>.FormControl')!.getBoundingClientRect().bottom - resultsElementMargin;
|
||||
|
||||
this.element.querySelector('.Search-results')?.setAttribute('style', `max-height: ${maxHeight}px`);
|
||||
this.element.querySelector<HTMLElement>('.Search-results')?.style?.setProperty('max-height', `${maxHeight}px`);
|
||||
}
|
||||
|
||||
onupdate(vnode: Mithril.VnodeDOM<T, this>) {
|
||||
|
@@ -1,45 +1,47 @@
|
||||
import app from '../../forum/app';
|
||||
import Modal from '../../common/components/Modal';
|
||||
import Modal, { IInternalModalAttrs } from '../../common/components/Modal';
|
||||
import LogInModal from './LogInModal';
|
||||
import Button from '../../common/components/Button';
|
||||
import LogInButtons from './LogInButtons';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import type Mithril from 'mithril';
|
||||
|
||||
/**
|
||||
* The `SignUpModal` component displays a modal dialog with a singup form.
|
||||
*
|
||||
* ### Attrs
|
||||
*
|
||||
* - `username`
|
||||
* - `email`
|
||||
* - `password`
|
||||
* - `token` An email token to sign up with.
|
||||
*/
|
||||
export default class SignUpModal extends Modal {
|
||||
oninit(vnode) {
|
||||
export interface ISignupModalAttrs extends IInternalModalAttrs {
|
||||
username?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
token?: string;
|
||||
provided?: string[];
|
||||
}
|
||||
|
||||
export 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.
|
||||
*/
|
||||
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);
|
||||
|
||||
/**
|
||||
* The value of the username input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.username = Stream(this.attrs.username || '');
|
||||
|
||||
/**
|
||||
* The value of the email input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.email = Stream(this.attrs.email || '');
|
||||
|
||||
/**
|
||||
* The value of the password input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.password = Stream(this.attrs.password || '');
|
||||
}
|
||||
|
||||
@@ -55,17 +57,21 @@ export default class SignUpModal extends Modal {
|
||||
return [<div className="Modal-body">{this.body()}</div>, <div className="Modal-footer">{this.footer()}</div>];
|
||||
}
|
||||
|
||||
isProvided(field) {
|
||||
return this.attrs.provided && this.attrs.provided.indexOf(field) !== -1;
|
||||
isProvided(field: string): boolean {
|
||||
return this.attrs.provided?.includes(field) ?? false;
|
||||
}
|
||||
|
||||
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() {
|
||||
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(
|
||||
'username',
|
||||
<div className="Form-group">
|
||||
@@ -73,7 +79,8 @@ export default class SignUpModal extends Modal {
|
||||
className="FormControl"
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder={extractText(app.translator.trans('core.forum.sign_up.username_placeholder'))}
|
||||
placeholder={usernameLabel}
|
||||
aria-label={usernameLabel}
|
||||
bidi={this.username}
|
||||
disabled={this.loading || this.isProvided('username')}
|
||||
/>
|
||||
@@ -88,7 +95,8 @@ export default class SignUpModal extends Modal {
|
||||
className="FormControl"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder={extractText(app.translator.trans('core.forum.sign_up.email_placeholder'))}
|
||||
placeholder={emailLabel}
|
||||
aria-label={emailLabel}
|
||||
bidi={this.email}
|
||||
disabled={this.loading || this.isProvided('email')}
|
||||
/>
|
||||
@@ -105,7 +113,8 @@ export default class SignUpModal extends Modal {
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
placeholder={extractText(app.translator.trans('core.forum.sign_up.password_placeholder'))}
|
||||
placeholder={passwordLabel}
|
||||
aria-label={passwordLabel}
|
||||
bidi={this.password}
|
||||
disabled={this.loading}
|
||||
/>
|
||||
@@ -156,7 +165,7 @@ export default class SignUpModal extends Modal {
|
||||
}
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
onsubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
this.loading = true;
|
||||
@@ -175,22 +184,16 @@ export default class SignUpModal extends Modal {
|
||||
|
||||
/**
|
||||
* Get the data that should be submitted in the sign-up request.
|
||||
*
|
||||
* @return {Object}
|
||||
* @protected
|
||||
*/
|
||||
submitData() {
|
||||
submitData(): SignupBody {
|
||||
const authData = this.attrs.token ? { token: this.attrs.token } : { password: this.password() };
|
||||
|
||||
const data = {
|
||||
username: this.username(),
|
||||
email: this.email(),
|
||||
...authData,
|
||||
};
|
||||
|
||||
if (this.attrs.token) {
|
||||
data.token = this.attrs.token;
|
||||
} else {
|
||||
data.password = this.password();
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user