mirror of
https://github.com/flarum/framework.git
synced 2024-11-25 08:00:24 +08:00
Convert models to TS
This commit is contained in:
parent
c718d4d4d6
commit
25934833b8
11
framework/core/js/src/@types/global.d.ts
vendored
11
framework/core/js/src/@types/global.d.ts
vendored
|
@ -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 };
|
||||
|
||||
/**
|
||||
|
|
|
@ -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());
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
377
framework/core/js/src/common/Model.ts
Normal file
377
framework/core/js/src/common/Model.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
239
framework/core/js/src/common/Store.ts
Normal file
239
framework/core/js/src/common/Store.ts
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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
framework/core/js/src/common/models/Discussion.ts
Normal file
146
framework/core/js/src/common/models/Discussion.ts
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 | 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) ?? [];
|
||||
}
|
||||
}
|
|
@ -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
framework/core/js/src/common/models/Group.ts
Normal file
25
framework/core/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>('color').call(this);
|
||||
}
|
||||
icon() {
|
||||
return Model.attribute<string>('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
framework/core/js/src/common/models/Notification.ts
Normal file
28
framework/core/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 | 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);
|
||||
}
|
||||
}
|
|
@ -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'),
|
||||
});
|
67
framework/core/js/src/common/models/Post.ts
Normal file
67
framework/core/js/src/common/models/Post.ts
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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
framework/core/js/src/common/models/User.ts
Normal file
164
framework/core/js/src/common/models/User.ts
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';
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
|
@ -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,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;
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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>) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue
Block a user