Convert models to TS

This commit is contained in:
Alexander Skvortsov 2021-11-19 18:45:34 -05:00
parent c718d4d4d6
commit 25934833b8
27 changed files with 1138 additions and 868 deletions

View File

@ -46,6 +46,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 };
/**

View File

@ -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());
},
};

View File

@ -1,3 +1,5 @@
import type Mithril from 'mithril';
import app from '../../admin/app';
import EditUserModal from '../../common/components/EditUserModal';
@ -14,7 +16,6 @@ import classList from '../../common/utils/classList';
import extractText from '../../common/utils/extractText';
import AdminPage from './AdminPage';
import Mithril from 'mithril';
type ColumnData = {
/**
@ -24,20 +25,9 @@ type ColumnData = {
/**
* 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;

View File

@ -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('users', String(this.data.session.userId)), 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;
@ -450,7 +455,7 @@ 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);

View File

@ -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,
};
}
}

View File

@ -0,0 +1,377 @@
import app from '../common/app';
import { FlarumRequestOptions } from './Application';
import Store, { ApiPayloadSingle, ApiResponseSingle } from './Store';
interface ModelIdentifier {
type: string;
id: string;
}
interface ModelAttributes {
[key: string]: unknown;
}
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;
interface SaveRelationships {
[relationship: string]: Model | Model[];
}
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 | null;
/**
* @param data A resource object from the API.
* @param store The data store that this model should be persisted to.
* @public
*/
constructor(data: ModelData = {}, store = null) {
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) {
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.
* @public
*/
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.
* @public
*/
save(
attributes: SaveAttributes,
options: Omit<FlarumRequestOptions<ApiPayloadSingle>, 'url'> & { meta?: any } = {}
): 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>(
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) => {
if (!this.store) {
throw new Error('Model has no store');
}
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(
Object.assign(
{
method: 'DELETE',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
body,
},
options
)
)
.then(() => {
this.exists = false;
if (this.store) {
this.store.remove(this);
} else {
throw new Error('Tried to delete a model without a store!');
}
});
}
/**
* 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 {
return function (this: Model) {
if (this.data.relationships) {
const relationshipData = this.data.relationships[name]?.data;
if (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 app.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.
* @public
*/
static hasMany<M extends Model>(name: string): () => (M | undefined)[] | false {
return function (this: Model) {
if (this.data.relationships) {
const relationshipData = this.data.relationships[name]?.data;
if (!(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) => app.store.getById<M>(data.type, data.id));
}
}
return false;
};
}
/**
* Transform the given value into a Date object.
*/
static transformDate(value: string | null): Date | null {
return value ? new Date(value) : null;
}
/**
* Get a resource identifier object for the given model.
*/
protected static getIdentifier(model: Model): ModelIdentifier;
protected static getIdentifier(model?: Model): ModelIdentifier | null {
if (!model || !('id' in model.data)) return null;
return {
type: model.data.type,
id: model.data.id,
};
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,239 @@
import app from '../common/app';
import { FlarumRequestOptions } from './Application';
import Model, { ModelData, SavedModelData } from './Model';
export interface ApiQueryParamsSingle {
fields?: string[];
include?: string;
bySlug?: boolean;
}
export interface ApiQueryParamsPlural {
fields?: string[];
include?: string;
filter?: {
q: string;
[key: string]: string;
};
page?: {
offset?: number;
number?: number;
limit?: number;
size?: number;
};
sort?: string;
}
export type ApiQueryParams = ApiQueryParamsPlural | ApiQueryParamsSingle;
export interface ApiPayloadSingle {
data: SavedModelData;
included?: SavedModelData[];
}
export interface ApiPayloadPlural {
data: SavedModelData[];
included?: SavedModelData[];
links?: {
first: string;
next?: string;
prev?: string;
};
}
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) {
return null;
}
throw new Error(`Cannot push object of type ${data.type}, as that type has not yet been registered in the store.`);
}
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>(
Object.assign(
{
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 {String} type The resource type
* @param {Object} [data] Any data to initialize the model with
* @return {Model}
* @public
*/
createRecord<M extends Model>(type: string, data: ModelData = {}): M {
data.type = data.type || type;
// @ts-expect-error this will complain about initializing abstract models,
// but we can safely assume that all models registered with the store are
// not abstract.
return new this.models[type](data, this);
}
}

View File

@ -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>;

View File

@ -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) : [];
},
});

View 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 | null, string | null>('createdAt', Model.transformDate).call(this);
}
user() {
return Model.hasOne<User>('user').call(this);
}
firstPost() {
return Model.hasOne<Post>('firstPost').call(this);
}
lastPostedAt() {
return Model.attribute<Date | null, string | null>('lastPostedAt', Model.transformDate).call(this);
}
lastPostedUser() {
return Model.hasOne<User>('lastPostedUser').call(this);
}
lastPost() {
return Model.hasOne<Post>('lastPost').call(this);
}
lastPostNumber() {
return Model.attribute<number | null>('lastPostNumber').call(this);
}
commentCount() {
return Model.attribute<number | null>('commentCount').call(this);
}
replyCount() {
return computed<number, this>('commentCount', (commentCount) => Math.max(0, (commentCount as number) - 1)).call(this);
}
posts() {
return Model.hasMany<Post>('posts').call(this);
}
mostRelevantPost() {
return Model.hasOne<Post>('mostRelevantPost').call(this);
}
lastReadAt() {
return Model.attribute<Date | null, string | null>('lastReadAt', Model.transformDate).call(this);
}
lastReadPostNumber() {
return Model.attribute<number | null>('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<Date | null, string | null>('hiddenAt', Model.transformDate).call(this);
}
hiddenUser() {
return Model.hasOne<User>('hiddenUser').call(this);
}
isHidden() {
return computed<boolean, this>('hiddenAt', (hiddenAt) => !!hiddenAt).call(this);
}
canReply() {
return Model.attribute<boolean | null>('canReply').call(this);
}
canRename() {
return Model.attribute<boolean | null>('canRename').call(this);
}
canHide() {
return Model.attribute<boolean | null>('canHide').call(this);
}
canDelete() {
return Model.attribute<boolean | null>('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() < 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.component({ type: 'hidden', icon: 'fas fa-trash', label: app.translator.trans('core.lib.badge.hidden_tooltip') }));
}
return items;
}
/**
* Get a list of all of the post IDs in this discussion.
*/
postIds(): string[] {
return this.rawRelationship<Post[]>('posts')?.map((link) => link.id) ?? [];
}
}

View File

@ -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;

View 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>('color').call(this);
}
icon() {
return Model.attribute<string>('icon').call(this);
}
isHidden() {
return Model.attribute<boolean>('isHidden').call(this);
}
}

View File

@ -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'),
});

View 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 | null, string | null>('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>('fromUser').call(this);
}
subject() {
return Model.hasOne('subject').call(this);
}
}

View File

@ -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'),
});

View File

@ -0,0 +1,67 @@
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);
}
createdAt() {
return Model.attribute<Date | null, string>('createdAt', Model.transformDate).call(this);
}
user() {
return Model.hasOne<User>('user').call(this);
}
contentType() {
return Model.attribute<string>('contentType').call(this);
}
content() {
return Model.attribute<string>('content').call(this);
}
contentHtml() {
return Model.attribute<string>('contentHtml').call(this);
}
renderFailed() {
return Model.attribute<boolean>('renderFailed').call(this);
}
contentPlain() {
return computed<string>('contentHtml', getPlainContent as (content: unknown) => string).call(this);
}
editedAt() {
return Model.attribute<Date | null, string>('editedAt', Model.transformDate).call(this);
}
editedUser() {
return Model.hasOne<User>('editedUser').call(this);
}
isEdited() {
return computed<boolean>('editedAt', (editedAt) => !!editedAt).call(this);
}
hiddenAt() {
return Model.attribute<Date | null, string>('hiddenAt', Model.transformDate).call(this);
}
hiddenUser() {
return Model.hasOne<User>('hiddenUser').call(this);
}
isHidden() {
return computed<boolean>('hiddenAt', (hiddenAt) => !!hiddenAt).call(this);
}
canEdit() {
return Model.attribute<boolean>('canEdit').call(this);
}
canHide() {
return Model.attribute<boolean>('canHide').call(this);
}
canDelete() {
return Model.attribute<boolean>('canDelete').call(this);
}
}

View File

@ -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 });
},
});

View 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';
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 | null>('email').call(this);
}
isEmailConfirmed() {
return Model.attribute<boolean | null>('isEmailConfirmed').call(this);
}
password() {
return Model.attribute<string | null>('password').call(this);
}
avatarUrl() {
return Model.attribute<string>('avatarUrl').call(this);
}
preferences() {
return Model.attribute<Record<string, any> | null>('preferences').call(this);
}
groups() {
return Model.hasMany('groups').call(this);
}
joinTime() {
return Model.attribute<Date | null, string | null>('joinTime', Model.transformDate).call(this);
}
lastSeenAt() {
return Model.attribute<Date | null, string | null>('lastSeenAt', Model.transformDate).call(this);
}
markedAllAsReadAt() {
return Model.attribute<Date | null, string | null>('markedAllAsReadAt', Model.transformDate).call(this);
}
unreadNotificationCount() {
return Model.attribute<number | null>('unreadNotificationCount').call(this);
}
newNotificationCount() {
return Model.attribute<number | null>('newNotificationCount').call(this);
}
discussionCount() {
return Model.attribute<number | null>('discussionCount').call(this);
}
commentCount() {
return Model.attribute<number | null>('commentCount').call(this);
}
canEdit() {
return Model.attribute<boolean | null>('canEdit').call(this);
}
canEditCredentials() {
return Model.attribute<boolean | null>('canEditCredentials').call(this);
}
canEditGroups() {
return Model.attribute<boolean | null>('canEditGroups').call(this);
}
canDelete() {
return Model.attribute<boolean | null>('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.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;
// @ts-expect-error This shouldn't be failing.
image.onload = function (this: HTMLImageElement) {
try {
const colorThief = new ColorThief();
user.avatarColor = colorThief.getColor(this);
} catch (e) {
// Completely white avatars throw errors due to a glitch in color thief
// See https://github.com/lokesh/color-thief/issues/40
if (e instanceof TypeError) {
user.avatarColor = [255, 255, 255];
} else {
throw e;
}
}
user.freshness = new Date();
m.redraw();
};
image.crossOrigin = 'anonymous';
image.src = this.avatarUrl();
}
/**
* Update the user's preferences.
*/
savePreferences(newPreferences: Record<string, unknown>): Promise<this> {
const preferences = this.preferences();
Object.assign(preferences, newPreferences);
return this.save({ preferences });
}
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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()! - b?.createdAt()?.getTime()!)
.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);

View File

@ -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();
});

View File

@ -163,7 +163,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('.Search-results') as HTMLElement).style?.setProperty('max-height', `${maxHeight}px`);
}
onupdate(vnode: Mithril.VnodeDOM<T, this>) {

View File

@ -17,7 +17,7 @@ export default class UsersSearchResults implements SearchSource {
async search(query: string): Promise<void> {
return app.store
.find('users', {
.find<User[]>('users', {
filter: { q: query },
page: { limit: 5 },
})
@ -33,7 +33,7 @@ export default class UsersSearchResults implements SearchSource {
const results = (this.results.get(query) || [])
.concat(
app.store
.all('users')
.all<User>('users')
.filter((user) => [user.username(), user.displayName()].some((value) => value.toLowerCase().substr(0, query.length) === query))
)
.filter((e, i, arr) => arr.lastIndexOf(e) === i)

View File

@ -1,12 +1,7 @@
import app from '../../forum/app';
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 { ApiQueryParamsPlural, ApiResponsePlural } from '../../common/Store';
export interface DiscussionListParams extends PaginatedListParams {
sort?: string;
@ -23,14 +18,13 @@ export default class DiscussionListState<P extends DiscussionListParams = Discus
return 'discussions';
}
requestParams(): IRequestParams {
const params: IRequestParams = {
requestParams(): PaginatedListRequestParams {
const params = {
include: ['user', 'lastPostedUser'],
filter: this.params.filter || {},
sort: this.sortMap()[this.params.sort ?? ''],
};
params.sort = this.sortMap()[this.params.sort ?? ''];
if (this.params.q) {
params.filter.q = this.params.q;
params.include.push('mostRelevantPost', 'mostRelevantPost.user');
@ -39,8 +33,8 @@ export default class DiscussionListState<P extends DiscussionListParams = Discus
return params;
}
protected loadPage(page: number = 1): Promise<Discussion[]> {
const preloadedDiscussions = app.preloadedApiDocument() as Discussion[] | null;
protected loadPage(page: number = 1): Promise<ApiResponsePlural<Discussion>> {
const preloadedDiscussions = app.preloadedApiDocument<Discussion[]>();
if (preloadedDiscussions) {
this.initialLoading = false;