diff --git a/js/src/@types/global.d.ts b/js/src/@types/global.d.ts index 76b9f3e5a..e3ff2fe27 100644 --- a/js/src/@types/global.d.ts +++ b/js/src/@types/global.d.ts @@ -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 }; /** diff --git a/js/src/admin/AdminApplication.ts b/js/src/admin/AdminApplication.ts index a1c9ffc8c..0b24f1210 100644 --- a/js/src/admin/AdminApplication.ts +++ b/js/src/admin/AdminApplication.ts @@ -44,9 +44,9 @@ export default class AdminApplication extends Application { history = { canGoBack: () => true, getPrevious: () => {}, - backUrl: () => this.forum.attribute('baseUrl'), + backUrl: () => this.forum.attribute('baseUrl'), back: function () { - window.location = this.backUrl(); + window.location.assign(this.backUrl()); }, }; diff --git a/js/src/admin/components/UserListPage.tsx b/js/src/admin/components/UserListPage.tsx index 8de993a49..675104aea 100644 --- a/js/src/admin/components/UserListPage.tsx +++ b/js/src/admin/components/UserListPage.tsx @@ -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[]; - included: Record[]; - 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('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; diff --git a/js/src/common/Application.tsx b/js/src/common/Application.tsx index a8ffbb083..835a75363 100644 --- a/js/src/common/Application.tsx +++ b/js/src/common/Application.tsx @@ -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 | null; + apiDocument: ApiPayload | null; locale: string; locales: Record; - resources: Record[]; + 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 | null { + preloadedApiDocument(): ApiResponseSingle | null; + preloadedApiDocument(): ApiResponsePlural | null; + preloadedApiDocument(): ApiResponse> | 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[]>(this.data.apiDocument) + : this.store.pushPayload>(this.data.apiDocument); this.data.apiDocument = null; @@ -450,7 +455,7 @@ export default class Application { * @param options * @return {Promise} */ - request(originalOptions: FlarumRequestOptions): Promise { + request(originalOptions: FlarumRequestOptions): Promise { const options = this.transformRequestOptions(originalOptions); if (this.requestErrorAlert) this.alerts.dismiss(this.requestErrorAlert); diff --git a/js/src/common/Model.js b/js/src/common/Model.js deleted file mode 100644 index 24937f214..000000000 --- a/js/src/common/Model.js +++ /dev/null @@ -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, - }; - } -} diff --git a/js/src/common/Model.ts b/js/src/common/Model.ts new file mode 100644 index 000000000..b38dd595a --- /dev/null +++ b/js/src/common/Model.ts @@ -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(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, 'url'> & { meta?: any } = {} + ): Promise> { + 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( + 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(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['body'] = {}, options: Omit, 'url'> = {}): Promise { + 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(relationship: string): undefined | ModelIdentifier; + protected rawRelationship(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(name: string): () => T; + static attribute(name: string, transform: (attr: O) => T): () => T; + static attribute(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(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(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(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(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, + }; + } +} diff --git a/js/src/common/Store.js b/js/src/common/Store.js deleted file mode 100644 index ebbb9d9b2..000000000 --- a/js/src/common/Store.js +++ /dev/null @@ -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); - } -} diff --git a/js/src/common/Store.ts b/js/src/common/Store.ts new file mode 100644 index 000000000..342cc19cb --- /dev/null +++ b/js/src/common/Store.ts @@ -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 & { payload: ApiPayloadSingle }; +export type ApiResponsePlural = M[] & { payload: ApiPayloadPlural }; +export type ApiResponse = ApiResponseSingle | ApiResponsePlural; + +interface ApiQueryRequestOptions extends Omit, 'url'> {} + +interface StoreData { + [type: string]: Partial>; +} + +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; + + constructor(models: Record) { + 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(payload: ApiPayloadSingle): ApiResponseSingle; + pushPayload(payload: ApiPayloadPlural): ApiResponseSingle; + pushPayload(payload: ApiPayload): ApiResponse> { + 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>; + + // 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(data: SavedModelData): M | null; + pushObject(data: SavedModelData, allowUnregistered: false): M; + pushObject(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(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(type: string, params: ApiQueryParamsSingle): Promise>; + async find(type: string, params: ApiQueryParamsPlural): Promise>; + async find( + type: string, + id: string, + params: ApiQueryParamsSingle, + options?: ApiQueryRequestOptions + ): Promise>; + async find( + type: string, + ids: string[], + params: ApiQueryParamsPlural, + options?: ApiQueryRequestOptions + ): Promise>; + async find( + type: string, + idOrParams: string | string[] | ApiQueryParams, + query: ApiQueryParams = {}, + options: ApiQueryRequestOptions ? ApiPayloadPlural : ApiPayloadSingle> = {} + ): Promise>> { + 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 ? ApiPayloadPlural : ApiPayloadSingle>( + Object.assign( + { + method: 'GET', + url, + params, + }, + options + ) + ) + .then((payload) => { + if (payloadIsPlural(payload)) { + return this.pushPayload[]>(payload); + } else { + return this.pushPayload>(payload); + } + }); + } + + /** + * Get a record from the store by ID. + */ + getById(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(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(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(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); + } +} diff --git a/js/src/common/helpers/username.tsx b/js/src/common/helpers/username.tsx index 019138c05..a0a807306 100644 --- a/js/src/common/helpers/username.tsx +++ b/js/src/common/helpers/username.tsx @@ -6,7 +6,7 @@ import User from '../models/User'; * The `username` helper displays a user's username in a * 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 {name}; diff --git a/js/src/common/models/Discussion.js b/js/src/common/models/Discussion.js deleted file mode 100644 index 3f91e022a..000000000 --- a/js/src/common/models/Discussion.js +++ /dev/null @@ -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', ); - } - - 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) : []; - }, -}); diff --git a/js/src/common/models/Discussion.ts b/js/src/common/models/Discussion.ts new file mode 100644 index 000000000..1fbf59b9d --- /dev/null +++ b/js/src/common/models/Discussion.ts @@ -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('title').call(this); + } + slug() { + return Model.attribute('slug').call(this); + } + + createdAt() { + return Model.attribute('createdAt', Model.transformDate).call(this); + } + user() { + return Model.hasOne('user').call(this); + } + firstPost() { + return Model.hasOne('firstPost').call(this); + } + + lastPostedAt() { + return Model.attribute('lastPostedAt', Model.transformDate).call(this); + } + lastPostedUser() { + return Model.hasOne('lastPostedUser').call(this); + } + lastPost() { + return Model.hasOne('lastPost').call(this); + } + lastPostNumber() { + return Model.attribute('lastPostNumber').call(this); + } + + commentCount() { + return Model.attribute('commentCount').call(this); + } + replyCount() { + return computed('commentCount', (commentCount) => Math.max(0, (commentCount as number) - 1)).call(this); + } + posts() { + return Model.hasMany('posts').call(this); + } + mostRelevantPost() { + return Model.hasOne('mostRelevantPost').call(this); + } + + lastReadAt() { + return Model.attribute('lastReadAt', Model.transformDate).call(this); + } + lastReadPostNumber() { + return Model.attribute('lastReadPostNumber').call(this); + } + isUnread() { + return computed('unreadCount', (unreadCount) => !!unreadCount).call(this); + } + isRead() { + return computed('unreadCount', (unreadCount) => app.session.user && !unreadCount).call(this); + } + + hiddenAt() { + return Model.attribute('hiddenAt', Model.transformDate).call(this); + } + hiddenUser() { + return Model.hasOne('hiddenUser').call(this); + } + isHidden() { + return computed('hiddenAt', (hiddenAt) => !!hiddenAt).call(this); + } + + canReply() { + return Model.attribute('canReply').call(this); + } + canRename() { + return Model.attribute('canRename').call(this); + } + canHide() { + return Model.attribute('canHide').call(this); + } + canDelete() { + return Model.attribute('canDelete').call(this); + } + + /** + * Remove a post from the discussion's posts relationship. + */ + removePost(id: string): void { + const posts = this.rawRelationship('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 { + const items = new ItemList(); + + 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('posts')?.map((link) => link.id) ?? []; + } +} diff --git a/js/src/common/models/Forum.js b/js/src/common/models/Forum.ts similarity index 100% rename from js/src/common/models/Forum.js rename to js/src/common/models/Forum.ts diff --git a/js/src/common/models/Group.js b/js/src/common/models/Group.js deleted file mode 100644 index 46087032a..000000000 --- a/js/src/common/models/Group.js +++ /dev/null @@ -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; diff --git a/js/src/common/models/Group.ts b/js/src/common/models/Group.ts new file mode 100644 index 000000000..71cc420f2 --- /dev/null +++ b/js/src/common/models/Group.ts @@ -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('nameSingular').call(this); + } + namePlural() { + return Model.attribute('namePlural').call(this); + } + + color() { + return Model.attribute('color').call(this); + } + icon() { + return Model.attribute('icon').call(this); + } + + isHidden() { + return Model.attribute('isHidden').call(this); + } +} diff --git a/js/src/common/models/Notification.js b/js/src/common/models/Notification.js deleted file mode 100644 index fb849ec5e..000000000 --- a/js/src/common/models/Notification.js +++ /dev/null @@ -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'), -}); diff --git a/js/src/common/models/Notification.ts b/js/src/common/models/Notification.ts new file mode 100644 index 000000000..ef763d1bc --- /dev/null +++ b/js/src/common/models/Notification.ts @@ -0,0 +1,28 @@ +import Model from '../Model'; +import User from './User'; + +export default class Notification extends Model { + contentType() { + return Model.attribute('contentType').call(this); + } + content() { + return Model.attribute('content').call(this); + } + createdAt() { + return Model.attribute('createdAt', Model.transformDate).call(this); + } + + isRead() { + return Model.attribute('isRead').call(this); + } + + user() { + return Model.hasOne('user').call(this); + } + fromUser() { + return Model.hasOne('fromUser').call(this); + } + subject() { + return Model.hasOne('subject').call(this); + } +} diff --git a/js/src/common/models/Post.js b/js/src/common/models/Post.js deleted file mode 100644 index 29a122cb9..000000000 --- a/js/src/common/models/Post.js +++ /dev/null @@ -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'), -}); diff --git a/js/src/common/models/Post.ts b/js/src/common/models/Post.ts new file mode 100644 index 000000000..f9f70981c --- /dev/null +++ b/js/src/common/models/Post.ts @@ -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').call(this); + } + discussion() { + return Model.hasOne('discussion').call(this); + } + + createdAt() { + return Model.attribute('createdAt', Model.transformDate).call(this); + } + user() { + return Model.hasOne('user').call(this); + } + + contentType() { + return Model.attribute('contentType').call(this); + } + content() { + return Model.attribute('content').call(this); + } + contentHtml() { + return Model.attribute('contentHtml').call(this); + } + renderFailed() { + return Model.attribute('renderFailed').call(this); + } + contentPlain() { + return computed('contentHtml', getPlainContent as (content: unknown) => string).call(this); + } + + editedAt() { + return Model.attribute('editedAt', Model.transformDate).call(this); + } + editedUser() { + return Model.hasOne('editedUser').call(this); + } + isEdited() { + return computed('editedAt', (editedAt) => !!editedAt).call(this); + } + + hiddenAt() { + return Model.attribute('hiddenAt', Model.transformDate).call(this); + } + hiddenUser() { + return Model.hasOne('hiddenUser').call(this); + } + isHidden() { + return computed('hiddenAt', (hiddenAt) => !!hiddenAt).call(this); + } + + canEdit() { + return Model.attribute('canEdit').call(this); + } + canHide() { + return Model.attribute('canHide').call(this); + } + canDelete() { + return Model.attribute('canDelete').call(this); + } +} diff --git a/js/src/common/models/User.js b/js/src/common/models/User.js deleted file mode 100644 index 97de0d8d3..000000000 --- a/js/src/common/models/User.js +++ /dev/null @@ -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 }); - }, -}); diff --git a/js/src/common/models/User.ts b/js/src/common/models/User.ts new file mode 100644 index 000000000..6d66ef458 --- /dev/null +++ b/js/src/common/models/User.ts @@ -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('username').call(this); + } + slug() { + return Model.attribute('slug').call(this); + } + displayName() { + return Model.attribute('displayName').call(this); + } + + email() { + return Model.attribute('email').call(this); + } + isEmailConfirmed() { + return Model.attribute('isEmailConfirmed').call(this); + } + + password() { + return Model.attribute('password').call(this); + } + + avatarUrl() { + return Model.attribute('avatarUrl').call(this); + } + + preferences() { + return Model.attribute | null>('preferences').call(this); + } + + groups() { + return Model.hasMany('groups').call(this); + } + + joinTime() { + return Model.attribute('joinTime', Model.transformDate).call(this); + } + + lastSeenAt() { + return Model.attribute('lastSeenAt', Model.transformDate).call(this); + } + + markedAllAsReadAt() { + return Model.attribute('markedAllAsReadAt', Model.transformDate).call(this); + } + + unreadNotificationCount() { + return Model.attribute('unreadNotificationCount').call(this); + } + newNotificationCount() { + return Model.attribute('newNotificationCount').call(this); + } + + discussionCount() { + return Model.attribute('discussionCount').call(this); + } + commentCount() { + return Model.attribute('commentCount').call(this); + } + + canEdit() { + return Model.attribute('canEdit').call(this); + } + canEditCredentials() { + return Model.attribute('canEditCredentials').call(this); + } + canEditGroups() { + return Model.attribute('canEditGroups').call(this); + } + canDelete() { + return Model.attribute('canDelete').call(this); + } + + color() { + return computed('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 { + 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; + + // @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): Promise { + const preferences = this.preferences(); + + Object.assign(preferences, newPreferences); + + return this.save({ preferences }); + } +} diff --git a/js/src/common/states/PaginatedListState.ts b/js/src/common/states/PaginatedListState.ts index 5f13baf22..53d08464b 100644 --- a/js/src/common/states/PaginatedListState.ts +++ b/js/src/common/states/PaginatedListState.ts @@ -1,5 +1,6 @@ import app from '../../common/app'; import Model from '../Model'; +import { ApiQueryParamsPlural, ApiResponsePlural } from '../Store'; export interface Page { number: number; @@ -19,6 +20,10 @@ export interface PaginatedListParams { [key: string]: any; } +export interface PaginatedListRequestParams extends Omit { + include?: string | string[]; +} + export default abstract class PaginatedListState { protected location!: PaginationLocation; protected pageSize: number; @@ -39,7 +44,7 @@ export default abstract class PaginatedListState (this.loadingNext = false)); } - protected parseResults(pg: number, results: T[]) { + protected parseResults(pg: number, results: ApiResponsePlural): 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 { - const params = this.requestParams(); - params.page = { - ...params.page, - offset: this.pageSize * (page - 1), + protected loadPage(page = 1): Promise> { + 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(this.type, params); } /** @@ -115,7 +123,7 @@ export default abstract class PaginatedListState { this.initialLoading = true; this.loadingPrev = false; this.loadingNext = false; @@ -147,14 +155,14 @@ export default abstract class PaginatedListState { + .then((results) => { this.pages = []; this.parseResults(this.location.page, results); }) .finally(() => (this.initialLoading = false)); } - public getPages() { + public getPages(): Page[] { return this.pages; } public getLocation(): PaginationLocation { @@ -203,7 +211,7 @@ export default abstract class PaginatedListState(...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 = {}; + 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 unknown)>)[key]; + const value = typeof attr === 'function' ? attr.call(this) : attr; if (dependentValues[key] !== value) { recompute = true; diff --git a/js/src/forum/components/DiscussionPage.tsx b/js/src/forum/components/DiscussionPage.tsx index 19c3f7690..6ae4b1d94 100644 --- a/js/src/forum/components/DiscussionPage.tsx +++ b/js/src/forum/components/DiscussionPage.tsx @@ -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(); 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('discussions', m.route.param('id'), params).then(this.show.bind(this)); } m.redraw(); @@ -195,7 +196,7 @@ export default class DiscussionPage) { app.history.push('discussion', discussion.title()); app.setTitle(discussion.title()); app.setTitleCount(0); @@ -207,7 +208,7 @@ export default class DiscussionPage app.store.getById('posts', record.id)) - .sort((a: Post, b: Post) => a.createdAt() - b.createdAt()) + .map((record) => app.store.getById('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 { + this.stream.goToNumber(m.route.param('near') || (includedPosts[0]?.number() ?? 0), true).then(() => { this.discussion = discussion; app.current.set('discussion', discussion); diff --git a/js/src/forum/components/DiscussionsSearchSource.tsx b/js/src/forum/components/DiscussionsSearchSource.tsx index 4cbd4dbfb..804b623ea 100644 --- a/js/src/forum/components/DiscussionsSearchSource.tsx +++ b/js/src/forum/components/DiscussionsSearchSource.tsx @@ -24,7 +24,7 @@ export default class DiscussionsSearchSource implements SearchSource { include: 'mostRelevantPost', }; - return app.store.find('discussions', params).then((results) => { + return app.store.find('discussions', params).then((results) => { this.results.set(query, results); m.redraw(); }); diff --git a/js/src/forum/components/Search.tsx b/js/src/forum/components/Search.tsx index 169197d13..13c1d838c 100644 --- a/js/src/forum/components/Search.tsx +++ b/js/src/forum/components/Search.tsx @@ -163,7 +163,7 @@ export default class Search 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) { diff --git a/js/src/forum/components/UsersSearchSource.tsx b/js/src/forum/components/UsersSearchSource.tsx index 4d2776b60..73e081b07 100644 --- a/js/src/forum/components/UsersSearchSource.tsx +++ b/js/src/forum/components/UsersSearchSource.tsx @@ -17,7 +17,7 @@ export default class UsersSearchResults implements SearchSource { async search(query: string): Promise { return app.store - .find('users', { + .find('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('users') .filter((user) => [user.username(), user.displayName()].some((value) => value.toLowerCase().substr(0, query.length) === query)) ) .filter((e, i, arr) => arr.lastIndexOf(e) === i) diff --git a/js/src/forum/states/DiscussionListState.ts b/js/src/forum/states/DiscussionListState.ts index 7e3715cd6..8621561b3 100644 --- a/js/src/forum/states/DiscussionListState.ts +++ b/js/src/forum/states/DiscussionListState.ts @@ -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; - sort?: string; -} +import { ApiQueryParamsPlural, ApiResponsePlural } from '../../common/Store'; export interface DiscussionListParams extends PaginatedListParams { sort?: string; @@ -23,14 +18,13 @@ export default class DiscussionListState

{ - const preloadedDiscussions = app.preloadedApiDocument() as Discussion[] | null; + protected loadPage(page: number = 1): Promise> { + const preloadedDiscussions = app.preloadedApiDocument(); if (preloadedDiscussions) { this.initialLoading = false;