diff --git a/js/shims.d.ts b/js/shims.d.ts index 493c6ede7..f75b6a5c9 100644 --- a/js/shims.d.ts +++ b/js/shims.d.ts @@ -4,6 +4,7 @@ import Mithril from 'mithril'; // Other third-party libs import * as _dayjs from 'dayjs'; import * as _$ from 'jquery'; +import * as _ColorThief from 'color-thief-browser'; // Globals from flarum/core import Application from './src/common/Application'; @@ -22,6 +23,7 @@ declare global { const $: typeof _$; const m: Mithril.Static; const dayjs: typeof _dayjs; + const ColorThief: _ColorThief; } /** diff --git a/js/src/common/Model.js b/js/src/common/Model.ts similarity index 82% rename from js/src/common/Model.js rename to js/src/common/Model.ts index ecda6aebb..ff94b6113 100644 --- a/js/src/common/Model.js +++ b/js/src/common/Model.ts @@ -1,3 +1,17 @@ +import Store from './Store'; +import Mithril from 'mithril'; + +interface ModelData { + type?: string; + id?: string; + attributes?: any; + relationships?: any; +} + +interface SaveOptions extends Mithril.RequestOptions { + meta?: any; +} + /** * The `Model` class represents a local data resource. It provides methods to * persist changes via the API. @@ -5,55 +19,58 @@ * @abstract */ export default class Model { + /** + * The resource object from the API. + * + * @type {Object} + * @public + */ + data: ModelData = {}; + + /** + * The time at which the model's data was last updated. Watching the value + * of this property is a fast way to retain/cache a subtree if data hasn't + * changed. + * + * @type {Date} + * @public + */ + freshness: Date = new Date(); + + /** + * Whether or not the resource exists on the server. + * + * @type {Boolean} + * @public + */ + exists: boolean = false; + + /** + * The data store that this resource should be persisted to. + * + * @type {Store} + * @protected + */ + store?: Store = null; + /** * @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 - */ + constructor(data: ModelData = {}, store = null) { 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} + * @return {String} * @public * @final */ - id() { + id(): string | undefined { return this.data.id; } @@ -121,8 +138,8 @@ export default class Model { * @return {Promise} * @public */ - save(attributes, options = {}) { - const data = { + save(attributes, options: SaveOptions = {}) { + const data: ModelData = { type: this.data.type, id: this.data.id, attributes, @@ -152,7 +169,7 @@ export default class Model { this.pushData(data); - const request = { data }; + const request: any = { data }; if (options.meta) request.meta = options.meta; return app @@ -220,11 +237,11 @@ export default class Model { * @return {String} * @protected */ - apiEndpoint() { + apiEndpoint(): string { return '/' + this.data.type + (this.exists ? '/' + this.data.id : ''); } - copyData() { + copyData(): ModelData { return JSON.parse(JSON.stringify(this.data)); } @@ -236,8 +253,8 @@ export default class Model { * @return {*} * @public */ - static attribute(name, transform) { - return function () { + static attribute(name: string, transform?: Function) { + return function (this: Model): T | null | undefined { const value = this.data.attributes && this.data.attributes[name]; return transform ? transform(value) : value; @@ -254,8 +271,8 @@ export default class Model { * has not been loaded; or the model if it has been loaded. * @public */ - static hasOne(name) { - return function () { + static hasOne(name: string) { + return function (this: Model): T | null | false { if (this.data.relationships) { const relationship = this.data.relationships[name]; @@ -278,8 +295,8 @@ export default class Model { * loaded, and undefined for those that have not. * @public */ - static hasMany(name) { - return function () { + static hasMany(name: string) { + return function (this: Model): T[] | false { if (this.data.relationships) { const relationship = this.data.relationships[name]; @@ -299,7 +316,7 @@ export default class Model { * @return {Date|null} * @public */ - static transformDate(value) { + static transformDate(value: string): Date | null { return value ? new Date(value) : null; } @@ -310,7 +327,7 @@ export default class Model { * @return {Object} * @protected */ - static getIdentifier(model) { + static getIdentifier(model: Model) { return { type: model.data.type, id: model.data.id, diff --git a/js/src/common/models/Discussion.js b/js/src/common/models/Discussion.tsx similarity index 51% rename from js/src/common/models/Discussion.js rename to js/src/common/models/Discussion.tsx index 1e7c8ae09..8e94212ab 100644 --- a/js/src/common/models/Discussion.js +++ b/js/src/common/models/Discussion.tsx @@ -2,40 +2,40 @@ import Model from '../Model'; import computed from '../utils/computed'; import ItemList from '../utils/ItemList'; import Badge from '../components/Badge'; +import User from './User'; +import Post from './Post'; -export default class Discussion extends Model {} +export default class Discussion extends Model { + title = Model.attribute('title'); + slug = Model.attribute('slug'); -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'); - 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'); - 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'); - 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); - 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); - 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'), + 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. @@ -55,7 +55,7 @@ Object.assign(Discussion.prototype, { } }); } - }, + } /** * Get the estimated number of unread posts in this discussion for the current @@ -64,7 +64,7 @@ Object.assign(Discussion.prototype, { * @return {Integer} * @public */ - unreadCount() { + unreadCount(): number { const user = app.session.user; if (user && user.markedAllAsReadAt() < this.lastPostedAt()) { @@ -75,7 +75,7 @@ Object.assign(Discussion.prototype, { } return 0; - }, + } /** * Get the Badge components that apply to this discussion. @@ -83,7 +83,7 @@ Object.assign(Discussion.prototype, { * @return {ItemList} * @public */ - badges() { + badges(): ItemList { const items = new ItemList(); if (this.isHidden()) { @@ -91,7 +91,7 @@ Object.assign(Discussion.prototype, { } return items; - }, + } /** * Get a list of all of the post IDs in this discussion. @@ -99,9 +99,9 @@ Object.assign(Discussion.prototype, { * @return {Array} * @public */ - postIds() { + postIds(): string[] { const posts = this.data.relationships.posts; return posts ? posts.data.map((link) => link.id) : []; - }, -}); + } +} diff --git a/js/src/common/models/Forum.js b/js/src/common/models/Forum.ts similarity index 100% rename from js/src/common/models/Forum.js rename to js/src/common/models/Forum.ts diff --git a/js/src/common/models/Group.js b/js/src/common/models/Group.js deleted file mode 100644 index 46087032a..000000000 --- a/js/src/common/models/Group.js +++ /dev/null @@ -1,17 +0,0 @@ -import Model from '../Model'; - -class Group extends Model {} - -Object.assign(Group.prototype, { - nameSingular: Model.attribute('nameSingular'), - namePlural: Model.attribute('namePlural'), - color: Model.attribute('color'), - icon: Model.attribute('icon'), - isHidden: Model.attribute('isHidden'), -}); - -Group.ADMINISTRATOR_ID = '1'; -Group.GUEST_ID = '2'; -Group.MEMBER_ID = '3'; - -export default Group; diff --git a/js/src/common/models/Group.ts b/js/src/common/models/Group.ts new file mode 100644 index 000000000..252b972e5 --- /dev/null +++ b/js/src/common/models/Group.ts @@ -0,0 +1,13 @@ +import Model from '../Model'; + +export default class Group extends Model { + static ADMINISTRATOR_ID = '1'; + static GUEST_ID = '2'; + static MEMBER_ID = '3'; + + nameSingular = Model.attribute('nameSingular'); + namePlural = Model.attribute('namePlural'); + color = Model.attribute('color'); + icon = Model.attribute('icon'); + isHidden = Model.attribute('isHidden'); +} diff --git a/js/src/common/models/Notification.js b/js/src/common/models/Notification.js deleted file mode 100644 index fb849ec5e..000000000 --- a/js/src/common/models/Notification.js +++ /dev/null @@ -1,15 +0,0 @@ -import Model from '../Model'; - -export default class Notification extends Model {} - -Object.assign(Notification.prototype, { - contentType: Model.attribute('contentType'), - content: Model.attribute('content'), - createdAt: Model.attribute('createdAt', Model.transformDate), - - isRead: Model.attribute('isRead'), - - user: Model.hasOne('user'), - fromUser: Model.hasOne('fromUser'), - subject: Model.hasOne('subject'), -}); diff --git a/js/src/common/models/Notification.ts b/js/src/common/models/Notification.ts new file mode 100644 index 000000000..72509d258 --- /dev/null +++ b/js/src/common/models/Notification.ts @@ -0,0 +1,14 @@ +import Model from '../Model'; +import User from './User'; + +export default class Notification extends Model { + 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/Post.js b/js/src/common/models/Post.js deleted file mode 100644 index 5941ffe5e..000000000 --- a/js/src/common/models/Post.js +++ /dev/null @@ -1,29 +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'), - contentPlain: computed('contentHtml', getPlainContent), - - editedAt: Model.attribute('editedAt', Model.transformDate), - editedUser: Model.hasOne('editedUser'), - isEdited: computed('editedAt', (editedAt) => !!editedAt), - - hiddenAt: Model.attribute('hiddenAt', Model.transformDate), - hiddenUser: Model.hasOne('hiddenUser'), - isHidden: computed('hiddenAt', (hiddenAt) => !!hiddenAt), - - canEdit: Model.attribute('canEdit'), - canHide: Model.attribute('canHide'), - canDelete: Model.attribute('canDelete'), -}); diff --git a/js/src/common/models/Post.ts b/js/src/common/models/Post.ts new file mode 100644 index 000000000..ae2e9de61 --- /dev/null +++ b/js/src/common/models/Post.ts @@ -0,0 +1,29 @@ +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 = 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'); + 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/User.js b/js/src/common/models/User.ts similarity index 61% rename from js/src/common/models/User.js rename to js/src/common/models/User.ts index 54d8f5071..3e4f33b8e 100644 --- a/js/src/common/models/User.js +++ b/js/src/common/models/User.ts @@ -5,34 +5,33 @@ import stringToColor from '../utils/stringToColor'; import ItemList from '../utils/ItemList'; import computed from '../utils/computed'; import GroupBadge from '../components/GroupBadge'; +import Group from './Group'; -export default class User extends Model {} +export default class User extends Model { + username = Model.attribute('username'); + displayName = Model.attribute('displayName'); + email = Model.attribute('email'); + isEmailConfirmed = Model.attribute('isEmailConfirmed'); + password = Model.attribute('password'); -Object.assign(User.prototype, { - username: Model.attribute('username'), - 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'); - 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'); - 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'); - discussionCount: Model.attribute('discussionCount'), - commentCount: Model.attribute('commentCount'), + canEdit = Model.attribute('canEdit'); + canDelete = Model.attribute('canDelete'); - canEdit: Model.attribute('canEdit'), - canDelete: Model.attribute('canDelete'), - - avatarColor: null, - color: computed('username', 'avatarUrl', 'avatarColor', function (username, avatarUrl, avatarColor) { + avatarColor = null; + color = computed('username', 'avatarUrl', 'avatarColor', (username, 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 @@ -45,7 +44,7 @@ Object.assign(User.prototype, { } return '#' + stringToColor(username); - }), + }); /** * Check whether or not the user has been seen in the last 5 minutes. @@ -53,16 +52,16 @@ Object.assign(User.prototype, { * @return {Boolean} * @public */ - isOnline() { + isOnline(): boolean { return dayjs().subtract(5, 'minutes').isBefore(this.lastSeenAt()); - }, + } /** * Get the Badge components that apply to this user. * * @return {ItemList} */ - badges() { + badges(): ItemList { const items = new ItemList(); const groups = this.groups(); @@ -73,7 +72,7 @@ Object.assign(User.prototype, { } return items; - }, + } /** * Calculate the dominant color of the user's avatar. The dominant color will @@ -93,7 +92,7 @@ Object.assign(User.prototype, { }; image.crossOrigin = 'anonymous'; image.src = this.avatarUrl(); - }, + } /** * Update the user's preferences. @@ -107,5 +106,5 @@ Object.assign(User.prototype, { Object.assign(preferences, newPreferences); return this.save({ preferences }); - }, -}); + } +} diff --git a/js/src/common/utils/computed.js b/js/src/common/utils/computed.ts similarity index 88% rename from js/src/common/utils/computed.js rename to js/src/common/utils/computed.ts index 261f7b30b..783bd29f4 100644 --- a/js/src/common/utils/computed.js +++ b/js/src/common/utils/computed.ts @@ -1,3 +1,5 @@ +import Model from '../Model'; + /** * The `computed` utility creates a function that will cache its output until * any of the dependent values are dirty. @@ -7,14 +9,14 @@ * dependent values. * @return {Function} */ -export default function computed(...dependentKeys) { +export default function computed(...dependentKeys: any[]) { const keys = dependentKeys.slice(0, -1); const compute = dependentKeys.slice(-1)[0]; const dependentValues = {}; let computedValue; - return function () { + return function (this: M): T { let recompute = false; // Read all of the dependent values. If any of them have changed since last