From bbc914340499793249137e6f4b017a34c6a3e827 Mon Sep 17 00:00:00 2001 From: Alexander Skvortsov Date: Fri, 19 Nov 2021 18:45:34 -0500 Subject: [PATCH 01/18] Convert models to TS --- js/src/@types/global.d.ts | 11 + js/src/admin/AdminApplication.ts | 4 +- js/src/admin/components/UserListPage.tsx | 24 +- js/src/common/Application.tsx | 21 +- js/src/common/Model.js | 323 --------------- js/src/common/Model.ts | 377 ++++++++++++++++++ js/src/common/Store.js | 171 -------- js/src/common/Store.ts | 239 +++++++++++ js/src/common/helpers/username.tsx | 2 +- js/src/common/models/Discussion.js | 108 ----- js/src/common/models/Discussion.ts | 146 +++++++ js/src/common/models/{Forum.js => Forum.ts} | 0 js/src/common/models/Group.js | 17 - js/src/common/models/Group.ts | 25 ++ js/src/common/models/Notification.js | 15 - js/src/common/models/Notification.ts | 28 ++ js/src/common/models/Post.js | 31 -- js/src/common/models/Post.ts | 67 ++++ js/src/common/models/User.js | 124 ------ js/src/common/models/User.ts | 164 ++++++++ js/src/common/states/PaginatedListState.ts | 48 ++- .../common/utils/{computed.js => computed.ts} | 17 +- js/src/forum/components/DiscussionPage.tsx | 16 +- .../components/DiscussionsSearchSource.tsx | 2 +- js/src/forum/components/Search.tsx | 2 +- js/src/forum/components/UsersSearchSource.tsx | 4 +- js/src/forum/states/DiscussionListState.ts | 20 +- 27 files changed, 1138 insertions(+), 868 deletions(-) delete mode 100644 js/src/common/Model.js create mode 100644 js/src/common/Model.ts delete mode 100644 js/src/common/Store.js create mode 100644 js/src/common/Store.ts delete mode 100644 js/src/common/models/Discussion.js create mode 100644 js/src/common/models/Discussion.ts rename js/src/common/models/{Forum.js => Forum.ts} (100%) delete mode 100644 js/src/common/models/Group.js create mode 100644 js/src/common/models/Group.ts delete mode 100644 js/src/common/models/Notification.js create mode 100644 js/src/common/models/Notification.ts delete mode 100644 js/src/common/models/Post.js create mode 100644 js/src/common/models/Post.ts delete mode 100644 js/src/common/models/User.js create mode 100644 js/src/common/models/User.ts rename js/src/common/utils/{computed.js => computed.ts} (60%) diff --git a/js/src/@types/global.d.ts b/js/src/@types/global.d.ts index 76b9f3e5a4..e3ff2fe27b 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 a1c9ffc8c4..0b24f12101 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 8de993a498..675104aeaf 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; -}; - -type ApiPayload = { - data: Record[]; - included: Record[]; - links: { - first: string; - next?: string; - }; + content: (user: User) => Mithril.Children; }; -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 a8ffbb083b..835a753632 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 24937f2142..0000000000 --- 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 0000000000..b38dd595a3 --- /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 ebbb9d9b25..0000000000 --- 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 0000000000..342cc19cbf --- /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 019138c05e..a0a8073064 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 3f91e022ae..0000000000 --- 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 0000000000..1fbf59b9d2 --- /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 46087032a9..0000000000 --- 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 0000000000..71cc420f27 --- /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 fb849ec5e4..0000000000 --- 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 0000000000..ef763d1bc1 --- /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 29a122cb93..0000000000 --- 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 0000000000..f9f70981ce --- /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 97de0d8d30..0000000000 --- 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 0000000000..6d66ef458e --- /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 5f13baf22a..53d08464b9 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 19c3f7690b..6ae4b1d944 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 4cbd4dbfb9..804b623ea7 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 169197d137..13c1d838ce 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 4d2776b60f..73e081b071 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 7e3715cd60..8621561b3d 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; From 3a8d640dab6bf730d2ae1eb3a6f930d464e48d96 Mon Sep 17 00:00:00 2001 From: Alexander Skvortsov Date: Sun, 21 Nov 2021 19:11:18 -0500 Subject: [PATCH 02/18] Clean up model nullability --- js/src/common/Model.ts | 10 ++++- js/src/common/helpers/avatar.tsx | 4 +- js/src/common/models/Discussion.ts | 36 +++++++++--------- js/src/common/models/Group.ts | 4 +- js/src/common/models/Notification.ts | 6 +-- js/src/common/models/Post.ts | 32 +++++++++------- js/src/common/models/User.ts | 37 ++++++++++--------- .../components/DiscussionsSearchSource.tsx | 2 +- 8 files changed, 72 insertions(+), 59 deletions(-) diff --git a/js/src/common/Model.ts b/js/src/common/Model.ts index b38dd595a3..9195c31535 100644 --- a/js/src/common/Model.ts +++ b/js/src/common/Model.ts @@ -310,6 +310,8 @@ export default abstract class Model { * 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; + static hasOne(name: string): () => M | null | false; static hasOne(name: string): () => M | false { return function (this: Model) { if (this.data.relationships) { @@ -358,8 +360,12 @@ export default abstract class Model { /** * Transform the given value into a Date object. */ - static transformDate(value: string | null): Date | null { - return value ? new Date(value) : null; + static transformDate(value: string): Date; + static transformDate(value: string | null): Date | null; + static transformDate(value: string | undefined): Date | undefined; + static transformDate(value: string | null | undefined): Date | null | undefined; + static transformDate(value: string | null | undefined): Date | null | undefined { + return value != null ? new Date(value) : value; } /** diff --git a/js/src/common/helpers/avatar.tsx b/js/src/common/helpers/avatar.tsx index 4de36fc143..fb60bcd4d3 100644 --- a/js/src/common/helpers/avatar.tsx +++ b/js/src/common/helpers/avatar.tsx @@ -24,8 +24,8 @@ export default function avatar(user: User, attrs: ComponentAttrs = {}): Mithril. // uploaded image, or the first letter of their username if they haven't // uploaded one. if (user) { - const username: string = user.displayName() || '?'; - const avatarUrl: string = user.avatarUrl(); + const username = user.displayName() || '?'; + const avatarUrl = user.avatarUrl(); if (hasTitle) attrs.title = attrs.title || username; diff --git a/js/src/common/models/Discussion.ts b/js/src/common/models/Discussion.ts index 1fbf59b9d2..e6a95e25bb 100644 --- a/js/src/common/models/Discussion.ts +++ b/js/src/common/models/Discussion.ts @@ -16,46 +16,46 @@ export default class Discussion extends Model { } createdAt() { - return Model.attribute('createdAt', Model.transformDate).call(this); + return Model.attribute('createdAt', Model.transformDate).call(this); } user() { - return Model.hasOne('user').call(this); + return Model.hasOne('user').call(this); } firstPost() { - return Model.hasOne('firstPost').call(this); + return Model.hasOne('firstPost').call(this); } lastPostedAt() { - return Model.attribute('lastPostedAt', Model.transformDate).call(this); + return Model.attribute('lastPostedAt', Model.transformDate).call(this); } lastPostedUser() { - return Model.hasOne('lastPostedUser').call(this); + return Model.hasOne('lastPostedUser').call(this); } lastPost() { - return Model.hasOne('lastPost').call(this); + return Model.hasOne('lastPost').call(this); } lastPostNumber() { - return Model.attribute('lastPostNumber').call(this); + return Model.attribute('lastPostNumber').call(this); } commentCount() { - return Model.attribute('commentCount').call(this); + return Model.attribute('commentCount').call(this); } replyCount() { - return computed('commentCount', (commentCount) => Math.max(0, (commentCount as number) - 1)).call(this); + return computed('commentCount', (commentCount) => Math.max(0, (commentCount as number ?? 0) - 1)).call(this); } posts() { return Model.hasMany('posts').call(this); } mostRelevantPost() { - return Model.hasOne('mostRelevantPost').call(this); + return Model.hasOne('mostRelevantPost').call(this); } lastReadAt() { - return Model.attribute('lastReadAt', Model.transformDate).call(this); + return Model.attribute('lastReadAt', Model.transformDate).call(this); } lastReadPostNumber() { - return Model.attribute('lastReadPostNumber').call(this); + return Model.attribute('lastReadPostNumber').call(this); } isUnread() { return computed('unreadCount', (unreadCount) => !!unreadCount).call(this); @@ -65,26 +65,26 @@ export default class Discussion extends Model { } hiddenAt() { - return Model.attribute('hiddenAt', Model.transformDate).call(this); + return Model.attribute('hiddenAt', Model.transformDate).call(this); } hiddenUser() { - return Model.hasOne('hiddenUser').call(this); + return Model.hasOne('hiddenUser').call(this); } isHidden() { return computed('hiddenAt', (hiddenAt) => !!hiddenAt).call(this); } canReply() { - return Model.attribute('canReply').call(this); + return Model.attribute('canReply').call(this); } canRename() { - return Model.attribute('canRename').call(this); + return Model.attribute('canRename').call(this); } canHide() { - return Model.attribute('canHide').call(this); + return Model.attribute('canHide').call(this); } canDelete() { - return Model.attribute('canDelete').call(this); + return Model.attribute('canDelete').call(this); } /** diff --git a/js/src/common/models/Group.ts b/js/src/common/models/Group.ts index 71cc420f27..1100c572fa 100644 --- a/js/src/common/models/Group.ts +++ b/js/src/common/models/Group.ts @@ -13,10 +13,10 @@ export default class Group extends Model { } color() { - return Model.attribute('color').call(this); + return Model.attribute('color').call(this); } icon() { - return Model.attribute('icon').call(this); + return Model.attribute('icon').call(this); } isHidden() { diff --git a/js/src/common/models/Notification.ts b/js/src/common/models/Notification.ts index ef763d1bc1..3a2b714c95 100644 --- a/js/src/common/models/Notification.ts +++ b/js/src/common/models/Notification.ts @@ -9,7 +9,7 @@ export default class Notification extends Model { return Model.attribute('content').call(this); } createdAt() { - return Model.attribute('createdAt', Model.transformDate).call(this); + return Model.attribute('createdAt', Model.transformDate).call(this); } isRead() { @@ -20,9 +20,9 @@ export default class Notification extends Model { return Model.hasOne('user').call(this); } fromUser() { - return Model.hasOne('fromUser').call(this); + return Model.hasOne('fromUser').call(this); } subject() { - return Model.hasOne('subject').call(this); + return Model.hasOne('subject').call(this); } } diff --git a/js/src/common/models/Post.ts b/js/src/common/models/Post.ts index f9f70981ce..a487a22db3 100644 --- a/js/src/common/models/Post.ts +++ b/js/src/common/models/Post.ts @@ -13,55 +13,61 @@ export default class Post extends Model { } createdAt() { - return Model.attribute('createdAt', Model.transformDate).call(this); + return Model.attribute('createdAt', Model.transformDate).call(this); } user() { return Model.hasOne('user').call(this); } contentType() { - return Model.attribute('contentType').call(this); + return Model.attribute('contentType').call(this); } content() { - return Model.attribute('content').call(this); + return Model.attribute('content').call(this); } contentHtml() { - return Model.attribute('contentHtml').call(this); + return Model.attribute('contentHtml').call(this); } renderFailed() { - return Model.attribute('renderFailed').call(this); + return Model.attribute('renderFailed').call(this); } contentPlain() { - return computed('contentHtml', getPlainContent as (content: unknown) => string).call(this); + return computed('contentHtml', (content) => { + if (typeof content === 'string') { + return getPlainContent(content); + } + + return content as (null | undefined); + }).call(this); } editedAt() { - return Model.attribute('editedAt', Model.transformDate).call(this); + return Model.attribute('editedAt', Model.transformDate).call(this); } editedUser() { - return Model.hasOne('editedUser').call(this); + return Model.hasOne('editedUser').call(this); } isEdited() { return computed('editedAt', (editedAt) => !!editedAt).call(this); } hiddenAt() { - return Model.attribute('hiddenAt', Model.transformDate).call(this); + return Model.attribute('hiddenAt', Model.transformDate).call(this); } hiddenUser() { - return Model.hasOne('hiddenUser').call(this); + return Model.hasOne('hiddenUser').call(this); } isHidden() { return computed('hiddenAt', (hiddenAt) => !!hiddenAt).call(this); } canEdit() { - return Model.attribute('canEdit').call(this); + return Model.attribute('canEdit').call(this); } canHide() { - return Model.attribute('canHide').call(this); + return Model.attribute('canHide').call(this); } canDelete() { - return Model.attribute('canDelete').call(this); + return Model.attribute('canDelete').call(this); } } diff --git a/js/src/common/models/User.ts b/js/src/common/models/User.ts index 6d66ef458e..e6ea1b2659 100644 --- a/js/src/common/models/User.ts +++ b/js/src/common/models/User.ts @@ -6,6 +6,7 @@ import ItemList from '../utils/ItemList'; import computed from '../utils/computed'; import GroupBadge from '../components/GroupBadge'; import Mithril from 'mithril'; +import Group from './Group'; export default class User extends Model { username() { @@ -19,65 +20,65 @@ export default class User extends Model { } email() { - return Model.attribute('email').call(this); + return Model.attribute('email').call(this); } isEmailConfirmed() { - return Model.attribute('isEmailConfirmed').call(this); + return Model.attribute('isEmailConfirmed').call(this); } password() { - return Model.attribute('password').call(this); + return Model.attribute('password').call(this); } avatarUrl() { - return Model.attribute('avatarUrl').call(this); + return Model.attribute('avatarUrl').call(this); } preferences() { - return Model.attribute | null>('preferences').call(this); + return Model.attribute | null | undefined>('preferences').call(this); } groups() { - return Model.hasMany('groups').call(this); + return Model.hasMany('groups').call(this); } joinTime() { - return Model.attribute('joinTime', Model.transformDate).call(this); + return Model.attribute('joinTime', Model.transformDate).call(this); } lastSeenAt() { - return Model.attribute('lastSeenAt', Model.transformDate).call(this); + return Model.attribute('lastSeenAt', Model.transformDate).call(this); } markedAllAsReadAt() { - return Model.attribute('markedAllAsReadAt', Model.transformDate).call(this); + return Model.attribute('markedAllAsReadAt', Model.transformDate).call(this); } unreadNotificationCount() { - return Model.attribute('unreadNotificationCount').call(this); + return Model.attribute('unreadNotificationCount').call(this); } newNotificationCount() { - return Model.attribute('newNotificationCount').call(this); + return Model.attribute('newNotificationCount').call(this); } discussionCount() { - return Model.attribute('discussionCount').call(this); + return Model.attribute('discussionCount').call(this); } commentCount() { - return Model.attribute('commentCount').call(this); + return Model.attribute('commentCount').call(this); } canEdit() { - return Model.attribute('canEdit').call(this); + return Model.attribute('canEdit').call(this); } canEditCredentials() { - return Model.attribute('canEditCredentials').call(this); + return Model.attribute('canEditCredentials').call(this); } canEditGroups() { - return Model.attribute('canEditGroups').call(this); + return Model.attribute('canEditGroups').call(this); } canDelete() { - return Model.attribute('canDelete').call(this); + return Model.attribute('canDelete').call(this); } color() { @@ -148,7 +149,7 @@ export default class User extends Model { m.redraw(); }; image.crossOrigin = 'anonymous'; - image.src = this.avatarUrl(); + image.src = this.avatarUrl() ?? ''; } /** diff --git a/js/src/forum/components/DiscussionsSearchSource.tsx b/js/src/forum/components/DiscussionsSearchSource.tsx index 804b623ea7..5807566a79 100644 --- a/js/src/forum/components/DiscussionsSearchSource.tsx +++ b/js/src/forum/components/DiscussionsSearchSource.tsx @@ -40,7 +40,7 @@ export default class DiscussionsSearchSource implements SearchSource {

  • {highlight(discussion.title(), query)}
    - {mostRelevantPost ?
    {highlight(mostRelevantPost.contentPlain(), query, 100)}
    : ''} + {mostRelevantPost ?
    {highlight(mostRelevantPost.contentPlain() ?? '', query, 100)}
    : ''}
  • ); From 09a55258a0a173e8314bc835299964a5d0169c38 Mon Sep 17 00:00:00 2001 From: Alexander Skvortsov Date: Sun, 21 Nov 2021 19:30:57 -0500 Subject: [PATCH 03/18] format --- js/src/common/models/Discussion.ts | 6 +++--- js/src/common/models/Post.ts | 2 +- js/src/forum/components/DiscussionsSearchSource.tsx | 6 +++++- js/src/forum/components/Search.tsx | 2 +- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/js/src/common/models/Discussion.ts b/js/src/common/models/Discussion.ts index e6a95e25bb..277ab0df50 100644 --- a/js/src/common/models/Discussion.ts +++ b/js/src/common/models/Discussion.ts @@ -42,7 +42,7 @@ export default class Discussion extends Model { return Model.attribute('commentCount').call(this); } replyCount() { - return computed('commentCount', (commentCount) => Math.max(0, (commentCount as number ?? 0) - 1)).call(this); + return computed('commentCount', (commentCount) => Math.max(0, ((commentCount as number) ?? 0) - 1)).call(this); } posts() { return Model.hasMany('posts').call(this); @@ -112,9 +112,9 @@ export default class Discussion extends Model { * user. */ unreadCount(): number { - const user = app.session.user; + const user: User = app.session.user; - if (user && user.markedAllAsReadAt().getTime() < this.lastPostedAt()?.getTime()!) { + if (user && (user.markedAllAsReadAt()?.getTime() ?? 0) < this.lastPostedAt()?.getTime()!) { const unreadCount = Math.max(0, (this.lastPostNumber() ?? 0) - (this.lastReadPostNumber() || 0)); // If posts have been deleted, it's possible that the unread count could exceed the // actual post count. As such, we take the min of the two to ensure this isn't an issue. diff --git a/js/src/common/models/Post.ts b/js/src/common/models/Post.ts index a487a22db3..074914181a 100644 --- a/js/src/common/models/Post.ts +++ b/js/src/common/models/Post.ts @@ -37,7 +37,7 @@ export default class Post extends Model { return getPlainContent(content); } - return content as (null | undefined); + return content as null | undefined; }).call(this); } diff --git a/js/src/forum/components/DiscussionsSearchSource.tsx b/js/src/forum/components/DiscussionsSearchSource.tsx index 5807566a79..d362ce0d52 100644 --- a/js/src/forum/components/DiscussionsSearchSource.tsx +++ b/js/src/forum/components/DiscussionsSearchSource.tsx @@ -40,7 +40,11 @@ export default class DiscussionsSearchSource implements SearchSource {
  • {highlight(discussion.title(), query)}
    - {mostRelevantPost ?
    {highlight(mostRelevantPost.contentPlain() ?? '', query, 100)}
    : ''} + {mostRelevantPost ? ( +
    {highlight(mostRelevantPost.contentPlain() ?? '', query, 100)}
    + ) : ( + '' + )}
  • ); diff --git a/js/src/forum/components/Search.tsx b/js/src/forum/components/Search.tsx index 13c1d838ce..65b833c41f 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') as HTMLElement).style?.setProperty('max-height', `${maxHeight}px`); + this.element.querySelector('.Search-results')?.style?.setProperty('max-height', `${maxHeight}px`); } onupdate(vnode: Mithril.VnodeDOM) { From ab2620147ab876dcdbb205743883c1b09691b0b3 Mon Sep 17 00:00:00 2001 From: Alexander Skvortsov Date: Sun, 21 Nov 2021 19:50:38 -0500 Subject: [PATCH 04/18] Drop unnecessary JSDocs --- js/src/common/Model.ts | 4 ---- js/src/common/Store.ts | 6 ++---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/js/src/common/Model.ts b/js/src/common/Model.ts index 9195c31535..06566b68bc 100644 --- a/js/src/common/Model.ts +++ b/js/src/common/Model.ts @@ -71,7 +71,6 @@ export default abstract class Model { /** * @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; @@ -150,7 +149,6 @@ export default abstract class Model { * Merge new attributes into this model locally. * * @param attributes The attributes to merge. - * @public */ pushAttributes(attributes: ModelAttributes) { this.pushData({ attributes }); @@ -161,7 +159,6 @@ export default abstract class Model { * * @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, @@ -337,7 +334,6 @@ export default abstract class Model { * @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) { diff --git a/js/src/common/Store.ts b/js/src/common/Store.ts index 342cc19cbf..a03405a958 100644 --- a/js/src/common/Store.ts +++ b/js/src/common/Store.ts @@ -223,10 +223,8 @@ export default class Store { /** * Create a new record of the given type. * - * @param {String} type The resource type - * @param {Object} [data] Any data to initialize the model with - * @return {Model} - * @public + * @param type The resource type + * @param data Any data to initialize the model with */ createRecord(type: string, data: ModelData = {}): M { data.type = data.type || type; From b85aa403cc18c9ee18cffb774f71ebc70c5de521 Mon Sep 17 00:00:00 2001 From: Alexander Skvortsov Date: Sun, 21 Nov 2021 19:54:37 -0500 Subject: [PATCH 05/18] Remove unnecessary nonnull assertions --- js/src/forum/components/DiscussionPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/src/forum/components/DiscussionPage.tsx b/js/src/forum/components/DiscussionPage.tsx index 6ae4b1d944..0e99a517c1 100644 --- a/js/src/forum/components/DiscussionPage.tsx +++ b/js/src/forum/components/DiscussionPage.tsx @@ -222,7 +222,7 @@ export default class DiscussionPage app.store.getById('posts', record.id)) - .sort((a?: Post, b?: Post) => a?.createdAt()?.getTime()! - b?.createdAt()?.getTime()!) + .sort((a?: Post, b?: Post) => (a?.createdAt()?.getTime() ?? 0) - (b?.createdAt()?.getTime() ?? 0)) .slice(0, 20); } From b0504597da5da8a65ef57df95286f831824e0bc5 Mon Sep 17 00:00:00 2001 From: Alexander Skvortsov Date: Thu, 25 Nov 2021 15:26:56 -0500 Subject: [PATCH 06/18] Review changes, make Model.store non-nullable, include meta in APIPayload signatures --- js/src/common/Model.ts | 24 ++++++++---------------- js/src/common/Store.ts | 6 ++++++ 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/js/src/common/Model.ts b/js/src/common/Model.ts index 06566b68bc..160ec0eb04 100644 --- a/js/src/common/Model.ts +++ b/js/src/common/Model.ts @@ -2,16 +2,16 @@ import app from '../common/app'; import { FlarumRequestOptions } from './Application'; import Store, { ApiPayloadSingle, ApiResponseSingle } from './Store'; -interface ModelIdentifier { +export interface ModelIdentifier { type: string; id: string; } -interface ModelAttributes { +export interface ModelAttributes { [key: string]: unknown; } -interface ModelRelationships { +export interface ModelRelationships { [relationship: string]: { data: ModelIdentifier | ModelIdentifier[]; }; @@ -66,13 +66,13 @@ export default abstract class Model { /** * The data store that this resource should be persisted to. */ - protected store: Store | null; + protected store: Store; /** * @param data A resource object from the API. * @param store The data store that this model should be persisted to. */ - constructor(data: ModelData = {}, store = null) { + constructor(data: ModelData = {}, store = app.store) { this.data = data; this.store = store; } @@ -218,10 +218,6 @@ export default abstract class Model { // 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); }, @@ -257,11 +253,7 @@ export default abstract class Model { .then(() => { this.exists = false; - if (this.store) { - this.store.remove(this); - } else { - throw new Error('Tried to delete a model without a store!'); - } + this.store.remove(this); }); } @@ -319,7 +311,7 @@ export default abstract class Model { } if (relationshipData) { - return app.store.getById(relationshipData.type, relationshipData.id) as M; + return this.store.getById(relationshipData.type, relationshipData.id) as M; } } @@ -345,7 +337,7 @@ export default abstract class Model { } if (relationshipData) { - return relationshipData.map((data) => app.store.getById(data.type, data.id)); + return relationshipData.map((data) => this.store.getById(data.type, data.id)); } } diff --git a/js/src/common/Store.ts b/js/src/common/Store.ts index a03405a958..6fb375101c 100644 --- a/js/src/common/Store.ts +++ b/js/src/common/Store.ts @@ -6,6 +6,9 @@ export interface ApiQueryParamsSingle { fields?: string[]; include?: string; bySlug?: boolean; + meta?: { + [key: string]: any; + }; } export interface ApiQueryParamsPlural { @@ -22,6 +25,9 @@ export interface ApiQueryParamsPlural { size?: number; }; sort?: string; + meta?: { + [key: string]: any; + }; } export type ApiQueryParams = ApiQueryParamsPlural | ApiQueryParamsSingle; From 0bdb018ad42f9b52aa948759281ddecfac1b16b4 Mon Sep 17 00:00:00 2001 From: Alexander Skvortsov Date: Wed, 1 Dec 2021 16:04:15 -0500 Subject: [PATCH 07/18] Add meta to ApiPayload interfaces --- js/src/common/Model.ts | 4 ++-- js/src/common/Store.ts | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/js/src/common/Model.ts b/js/src/common/Model.ts index 160ec0eb04..e5ddcb190e 100644 --- a/js/src/common/Model.ts +++ b/js/src/common/Model.ts @@ -1,6 +1,6 @@ import app from '../common/app'; import { FlarumRequestOptions } from './Application'; -import Store, { ApiPayloadSingle, ApiResponseSingle } from './Store'; +import Store, { ApiPayloadSingle, ApiResponseSingle, MetaInformation } from './Store'; export interface ModelIdentifier { type: string; @@ -162,7 +162,7 @@ export default abstract class Model { */ save( attributes: SaveAttributes, - options: Omit, 'url'> & { meta?: any } = {} + options: Omit, 'url'> & { meta?: MetaInformation } = {} ): Promise> { const data: ModelData & { id?: string } = { type: this.data.type, diff --git a/js/src/common/Store.ts b/js/src/common/Store.ts index 6fb375101c..57f7320d7f 100644 --- a/js/src/common/Store.ts +++ b/js/src/common/Store.ts @@ -2,13 +2,15 @@ import app from '../common/app'; import { FlarumRequestOptions } from './Application'; import Model, { ModelData, SavedModelData } from './Model'; +export interface MetaInformation { + [key: string]: any; +} + export interface ApiQueryParamsSingle { fields?: string[]; include?: string; bySlug?: boolean; - meta?: { - [key: string]: any; - }; + meta?: MetaInformation; } export interface ApiQueryParamsPlural { @@ -25,9 +27,7 @@ export interface ApiQueryParamsPlural { size?: number; }; sort?: string; - meta?: { - [key: string]: any; - }; + meta?: MetaInformation; } export type ApiQueryParams = ApiQueryParamsPlural | ApiQueryParamsSingle; @@ -35,6 +35,7 @@ export type ApiQueryParams = ApiQueryParamsPlural | ApiQueryParamsSingle; export interface ApiPayloadSingle { data: SavedModelData; included?: SavedModelData[]; + meta?: MetaInformation; } export interface ApiPayloadPlural { @@ -45,6 +46,7 @@ export interface ApiPayloadPlural { next?: string; prev?: string; }; + meta?: MetaInformation; } export type ApiPayload = ApiPayloadSingle | ApiPayloadPlural; From 44efff342db4ed0939d47b4a49d27f4df588a0d8 Mon Sep 17 00:00:00 2001 From: Alexander Skvortsov <38059171+askvortsov1@users.noreply.github.com> Date: Sun, 12 Dec 2021 14:55:38 -0500 Subject: [PATCH 08/18] Update js/src/common/Model.ts Co-authored-by: David Wheatley --- js/src/common/Model.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/js/src/common/Model.ts b/js/src/common/Model.ts index e5ddcb190e..a255e6d1f3 100644 --- a/js/src/common/Model.ts +++ b/js/src/common/Model.ts @@ -204,14 +204,12 @@ export default abstract class Model { return app .request( - Object.assign( - { - method: this.exists ? 'PATCH' : 'POST', - url: app.forum.attribute('apiUrl') + this.apiEndpoint(), - body: request, - }, - options - ) + { + 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 From d82073c3a966fc610cc0e8fe4106e5202445fc9e Mon Sep 17 00:00:00 2001 From: Alexander Skvortsov <38059171+askvortsov1@users.noreply.github.com> Date: Sun, 12 Dec 2021 14:55:46 -0500 Subject: [PATCH 09/18] Update js/src/common/Model.ts Co-authored-by: David Wheatley --- js/src/common/Model.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/js/src/common/Model.ts b/js/src/common/Model.ts index a255e6d1f3..a8693cad82 100644 --- a/js/src/common/Model.ts +++ b/js/src/common/Model.ts @@ -239,14 +239,12 @@ export default abstract class Model { return app .request( - Object.assign( - { - method: 'DELETE', - url: app.forum.attribute('apiUrl') + this.apiEndpoint(), - body, - }, - options - ) + { + method: 'DELETE', + url: app.forum.attribute('apiUrl') + this.apiEndpoint(), + body, + ...options, + }, ) .then(() => { this.exists = false; From 53180a38ac1ea39463059df9f82097c52a06586f Mon Sep 17 00:00:00 2001 From: Alexander Skvortsov <38059171+askvortsov1@users.noreply.github.com> Date: Sun, 12 Dec 2021 14:56:01 -0500 Subject: [PATCH 10/18] Update js/src/common/Store.ts Co-authored-by: David Wheatley --- js/src/common/Store.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/js/src/common/Store.ts b/js/src/common/Store.ts index 57f7320d7f..d4a7785a59 100644 --- a/js/src/common/Store.ts +++ b/js/src/common/Store.ts @@ -175,14 +175,12 @@ export default class Store { return app .request ? ApiPayloadPlural : ApiPayloadSingle>( - Object.assign( - { - method: 'GET', - url, - params, - }, - options - ) + { + method: 'GET', + url, + params, + ...options, + } ) .then((payload) => { if (payloadIsPlural(payload)) { From 3bca30121bfe188b61cfbc54c621458fc0ba4003 Mon Sep 17 00:00:00 2001 From: Alexander Skvortsov <38059171+askvortsov1@users.noreply.github.com> Date: Sun, 12 Dec 2021 14:56:21 -0500 Subject: [PATCH 11/18] Update js/src/common/models/Discussion.ts Co-authored-by: David Wheatley --- js/src/common/models/Discussion.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/src/common/models/Discussion.ts b/js/src/common/models/Discussion.ts index 277ab0df50..6a13193e49 100644 --- a/js/src/common/models/Discussion.ts +++ b/js/src/common/models/Discussion.ts @@ -131,7 +131,7 @@ export default class Discussion extends Model { 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') })); + items.add('hidden', ); } return items; From 528c964d947cadd8a08535a9b93100b58634b71f Mon Sep 17 00:00:00 2001 From: Alexander Skvortsov <38059171+askvortsov1@users.noreply.github.com> Date: Sun, 12 Dec 2021 14:56:27 -0500 Subject: [PATCH 12/18] Update js/src/common/models/User.ts Co-authored-by: David Wheatley --- js/src/common/models/User.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/src/common/models/User.ts b/js/src/common/models/User.ts index e6ea1b2659..af5a0a823a 100644 --- a/js/src/common/models/User.ts +++ b/js/src/common/models/User.ts @@ -116,7 +116,7 @@ export default class User extends Model { if (groups) { groups.forEach((group) => { - items.add(`group${group?.id()}`, GroupBadge.component({ group })); + items.add(`group${group?.id()}`, ); }); } From 4bd5bc87ee3cf21d5c30b7e077513c8196e6695d Mon Sep 17 00:00:00 2001 From: Alexander Skvortsov <38059171+askvortsov1@users.noreply.github.com> Date: Sun, 12 Dec 2021 14:56:47 -0500 Subject: [PATCH 13/18] Update js/src/common/models/User.ts Co-authored-by: David Wheatley --- js/src/common/models/User.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/js/src/common/models/User.ts b/js/src/common/models/User.ts index af5a0a823a..c1f6a98306 100644 --- a/js/src/common/models/User.ts +++ b/js/src/common/models/User.ts @@ -131,8 +131,7 @@ export default class User extends Model { const image = new Image(); const user = this; - // @ts-expect-error This shouldn't be failing. - image.onload = function (this: HTMLImageElement) { + image.addEventListener('load', function (this: HTMLImageElement) { try { const colorThief = new ColorThief(); user.avatarColor = colorThief.getColor(this); @@ -147,7 +146,7 @@ export default class User extends Model { } user.freshness = new Date(); m.redraw(); - }; + }); image.crossOrigin = 'anonymous'; image.src = this.avatarUrl() ?? ''; } From 4444e7c788ae1d02c67c94c02c757f5d2aeae0b9 Mon Sep 17 00:00:00 2001 From: Alexander Skvortsov Date: Sun, 12 Dec 2021 15:18:20 -0500 Subject: [PATCH 14/18] Rename Discussion, User files to allow jsx --- js/src/common/models/{Discussion.ts => Discussion.tsx} | 2 +- js/src/common/models/{User.ts => User.tsx} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename js/src/common/models/{Discussion.ts => Discussion.tsx} (98%) rename js/src/common/models/{User.ts => User.tsx} (100%) diff --git a/js/src/common/models/Discussion.ts b/js/src/common/models/Discussion.tsx similarity index 98% rename from js/src/common/models/Discussion.ts rename to js/src/common/models/Discussion.tsx index 6a13193e49..f1146a5d79 100644 --- a/js/src/common/models/Discussion.ts +++ b/js/src/common/models/Discussion.tsx @@ -131,7 +131,7 @@ export default class Discussion extends Model { const items = new ItemList(); if (this.isHidden()) { - items.add('hidden', ); + items.add('hidden', ); } return items; diff --git a/js/src/common/models/User.ts b/js/src/common/models/User.tsx similarity index 100% rename from js/src/common/models/User.ts rename to js/src/common/models/User.tsx From 306b3a9e8b827ac965ee858cec19ea26260833e7 Mon Sep 17 00:00:00 2001 From: Alexander Skvortsov Date: Sun, 12 Dec 2021 15:39:06 -0500 Subject: [PATCH 15/18] Type-safe session instantiation --- js/src/common/Application.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/src/common/Application.tsx b/js/src/common/Application.tsx index 835a753632..3dc7b8259d 100644 --- a/js/src/common/Application.tsx +++ b/js/src/common/Application.tsx @@ -258,7 +258,7 @@ export default class Application { this.forum = this.store.getById('forums', '1')!; - this.session = new Session(this.store.getById('users', String(this.data.session.userId)), this.data.session.csrfToken); + this.session = new Session(this.store.getById('users', String(this.data.session.userId)) ?? null, this.data.session.csrfToken); this.mount(); From a2c8407dd488fc71084932488005d40ea327f4c2 Mon Sep 17 00:00:00 2001 From: Alexander Skvortsov Date: Sun, 12 Dec 2021 15:39:28 -0500 Subject: [PATCH 16/18] `params` arguments for id-based `app.store.find` should be optional --- js/src/common/Store.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/src/common/Store.ts b/js/src/common/Store.ts index d4a7785a59..a576fa3207 100644 --- a/js/src/common/Store.ts +++ b/js/src/common/Store.ts @@ -147,13 +147,13 @@ export default class Store { async find( type: string, id: string, - params: ApiQueryParamsSingle, + params?: ApiQueryParamsSingle, options?: ApiQueryRequestOptions ): Promise>; async find( type: string, ids: string[], - params: ApiQueryParamsPlural, + params?: ApiQueryParamsPlural, options?: ApiQueryRequestOptions ): Promise>; async find( From 4759395186547e6bf3bcbd9198189eaae71e20aa Mon Sep 17 00:00:00 2001 From: Alexander Skvortsov Date: Sun, 12 Dec 2021 15:39:45 -0500 Subject: [PATCH 17/18] Post's discussion should always be present --- js/src/common/models/Post.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/src/common/models/Post.ts b/js/src/common/models/Post.ts index 074914181a..48aaa0ca64 100644 --- a/js/src/common/models/Post.ts +++ b/js/src/common/models/Post.ts @@ -9,7 +9,7 @@ export default class Post extends Model { return Model.attribute('number').call(this); } discussion() { - return Model.hasOne('discussion').call(this); + return Model.hasOne('discussion').call(this) as Discussion; } createdAt() { From f26ad3e32d0ac41e95293e866434753e333a8bd5 Mon Sep 17 00:00:00 2001 From: Alexander Skvortsov Date: Sun, 12 Dec 2021 15:46:46 -0500 Subject: [PATCH 18/18] Minor typefixes, fomat --- js/src/common/Model.ts | 28 ++++++++----------- js/src/common/Store.ts | 14 ++++------ js/src/common/models/Discussion.tsx | 4 +-- .../components/DiscussionsSearchSource.tsx | 2 +- 4 files changed, 21 insertions(+), 27 deletions(-) diff --git a/js/src/common/Model.ts b/js/src/common/Model.ts index a8693cad82..9cea14335a 100644 --- a/js/src/common/Model.ts +++ b/js/src/common/Model.ts @@ -203,14 +203,12 @@ export default abstract class Model { }; return app - .request( - { - method: this.exists ? 'PATCH' : 'POST', - url: app.forum.attribute('apiUrl') + this.apiEndpoint(), - body: request, - ...options, - }, - ) + .request({ + 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 @@ -238,14 +236,12 @@ export default abstract class Model { if (!this.exists) return Promise.resolve(); return app - .request( - { - method: 'DELETE', - url: app.forum.attribute('apiUrl') + this.apiEndpoint(), - body, - ...options, - }, - ) + .request({ + method: 'DELETE', + url: app.forum.attribute('apiUrl') + this.apiEndpoint(), + body, + ...options, + }) .then(() => { this.exists = false; diff --git a/js/src/common/Store.ts b/js/src/common/Store.ts index a576fa3207..02636daeb8 100644 --- a/js/src/common/Store.ts +++ b/js/src/common/Store.ts @@ -174,14 +174,12 @@ export default class Store { } return app - .request ? ApiPayloadPlural : ApiPayloadSingle>( - { - method: 'GET', - url, - params, - ...options, - } - ) + .request ? ApiPayloadPlural : ApiPayloadSingle>({ + method: 'GET', + url, + params, + ...options, + }) .then((payload) => { if (payloadIsPlural(payload)) { return this.pushPayload[]>(payload); diff --git a/js/src/common/models/Discussion.tsx b/js/src/common/models/Discussion.tsx index f1146a5d79..53517cdbe7 100644 --- a/js/src/common/models/Discussion.tsx +++ b/js/src/common/models/Discussion.tsx @@ -61,7 +61,7 @@ export default class Discussion extends Model { return computed('unreadCount', (unreadCount) => !!unreadCount).call(this); } isRead() { - return computed('unreadCount', (unreadCount) => app.session.user && !unreadCount).call(this); + return computed('unreadCount', (unreadCount) => !!(app.session.user && !unreadCount)).call(this); } hiddenAt() { @@ -112,7 +112,7 @@ export default class Discussion extends Model { * user. */ unreadCount(): number { - const user: User = app.session.user; + const user = app.session.user; if (user && (user.markedAllAsReadAt()?.getTime() ?? 0) < this.lastPostedAt()?.getTime()!) { const unreadCount = Math.max(0, (this.lastPostNumber() ?? 0) - (this.lastReadPostNumber() || 0)); diff --git a/js/src/forum/components/DiscussionsSearchSource.tsx b/js/src/forum/components/DiscussionsSearchSource.tsx index d362ce0d52..a9889a8043 100644 --- a/js/src/forum/components/DiscussionsSearchSource.tsx +++ b/js/src/forum/components/DiscussionsSearchSource.tsx @@ -38,7 +38,7 @@ export default class DiscussionsSearchSource implements SearchSource { return (
  • - +
    {highlight(discussion.title(), query)}
    {mostRelevantPost ? (
    {highlight(mostRelevantPost.contentPlain() ?? '', query, 100)}