1
0
mirror of https://github.com/flarum/core.git synced 2025-08-05 07:57:46 +02:00

Drop mixin-like attributes and convert to typescript

This commit is contained in:
Clark Winkelmann
2020-10-25 14:29:41 +01:00
parent 7055f6d941
commit df77ccf7ac
12 changed files with 190 additions and 175 deletions

2
js/shims.d.ts vendored
View File

@@ -4,6 +4,7 @@ import Mithril from 'mithril';
// Other third-party libs // Other third-party libs
import * as _dayjs from 'dayjs'; import * as _dayjs from 'dayjs';
import * as _$ from 'jquery'; import * as _$ from 'jquery';
import * as _ColorThief from 'color-thief-browser';
// Globals from flarum/core // Globals from flarum/core
import Application from './src/common/Application'; import Application from './src/common/Application';
@@ -22,6 +23,7 @@ declare global {
const $: typeof _$; const $: typeof _$;
const m: Mithril.Static; const m: Mithril.Static;
const dayjs: typeof _dayjs; const dayjs: typeof _dayjs;
const ColorThief: _ColorThief;
} }
/** /**

View File

@@ -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<any> {
meta?: any;
}
/** /**
* The `Model` class represents a local data resource. It provides methods to * The `Model` class represents a local data resource. It provides methods to
* persist changes via the API. * persist changes via the API.
@@ -5,55 +19,58 @@
* @abstract * @abstract
*/ */
export default class Model { 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 {Object} data A resource object from the API.
* @param {Store} store The data store that this model should be persisted to. * @param {Store} store The data store that this model should be persisted to.
* @public * @public
*/ */
constructor(data = {}, store = null) { constructor(data: ModelData = {}, store = null) {
/**
* The resource object from the API.
*
* @type {Object}
* @public
*/
this.data = data; 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; this.store = store;
} }
/** /**
* Get the model's ID. * Get the model's ID.
* *
* @return {Integer} * @return {String}
* @public * @public
* @final * @final
*/ */
id() { id(): string | undefined {
return this.data.id; return this.data.id;
} }
@@ -121,8 +138,8 @@ export default class Model {
* @return {Promise} * @return {Promise}
* @public * @public
*/ */
save(attributes, options = {}) { save(attributes, options: SaveOptions = {}) {
const data = { const data: ModelData = {
type: this.data.type, type: this.data.type,
id: this.data.id, id: this.data.id,
attributes, attributes,
@@ -152,7 +169,7 @@ export default class Model {
this.pushData(data); this.pushData(data);
const request = { data }; const request: any = { data };
if (options.meta) request.meta = options.meta; if (options.meta) request.meta = options.meta;
return app return app
@@ -220,11 +237,11 @@ export default class Model {
* @return {String} * @return {String}
* @protected * @protected
*/ */
apiEndpoint() { apiEndpoint(): string {
return '/' + this.data.type + (this.exists ? '/' + this.data.id : ''); return '/' + this.data.type + (this.exists ? '/' + this.data.id : '');
} }
copyData() { copyData(): ModelData {
return JSON.parse(JSON.stringify(this.data)); return JSON.parse(JSON.stringify(this.data));
} }
@@ -236,8 +253,8 @@ export default class Model {
* @return {*} * @return {*}
* @public * @public
*/ */
static attribute(name, transform) { static attribute<T>(name: string, transform?: Function) {
return function () { return function (this: Model): T | null | undefined {
const value = this.data.attributes && this.data.attributes[name]; const value = this.data.attributes && this.data.attributes[name];
return transform ? transform(value) : value; 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. * has not been loaded; or the model if it has been loaded.
* @public * @public
*/ */
static hasOne(name) { static hasOne<T>(name: string) {
return function () { return function (this: Model): T | null | false {
if (this.data.relationships) { if (this.data.relationships) {
const relationship = this.data.relationships[name]; const relationship = this.data.relationships[name];
@@ -278,8 +295,8 @@ export default class Model {
* loaded, and undefined for those that have not. * loaded, and undefined for those that have not.
* @public * @public
*/ */
static hasMany(name) { static hasMany<T>(name: string) {
return function () { return function (this: Model): T[] | false {
if (this.data.relationships) { if (this.data.relationships) {
const relationship = this.data.relationships[name]; const relationship = this.data.relationships[name];
@@ -299,7 +316,7 @@ export default class Model {
* @return {Date|null} * @return {Date|null}
* @public * @public
*/ */
static transformDate(value) { static transformDate(value: string): Date | null {
return value ? new Date(value) : null; return value ? new Date(value) : null;
} }
@@ -310,7 +327,7 @@ export default class Model {
* @return {Object} * @return {Object}
* @protected * @protected
*/ */
static getIdentifier(model) { static getIdentifier(model: Model) {
return { return {
type: model.data.type, type: model.data.type,
id: model.data.id, id: model.data.id,

View File

@@ -2,40 +2,40 @@ import Model from '../Model';
import computed from '../utils/computed'; import computed from '../utils/computed';
import ItemList from '../utils/ItemList'; import ItemList from '../utils/ItemList';
import Badge from '../components/Badge'; 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<string>('title');
slug = Model.attribute<string>('slug');
Object.assign(Discussion.prototype, { createdAt = Model.attribute<Date>('createdAt', Model.transformDate);
title: Model.attribute('title'), user = Model.hasOne<User>('user');
slug: Model.attribute('slug'), firstPost = Model.hasOne<Post>('firstPost');
createdAt: Model.attribute('createdAt', Model.transformDate), lastPostedAt = Model.attribute<Date>('lastPostedAt', Model.transformDate);
user: Model.hasOne('user'), lastPostedUser = Model.hasOne<User>('lastPostedUser');
firstPost: Model.hasOne('firstPost'), lastPost = Model.hasOne<Post>('lastPost');
lastPostNumber = Model.attribute<number>('lastPostNumber');
lastPostedAt: Model.attribute('lastPostedAt', Model.transformDate), commentCount = Model.attribute<number>('commentCount');
lastPostedUser: Model.hasOne('lastPostedUser'), replyCount = computed<number>('commentCount', (commentCount) => Math.max(0, commentCount - 1));
lastPost: Model.hasOne('lastPost'), posts = Model.hasMany<Post>('posts');
lastPostNumber: Model.attribute('lastPostNumber'), mostRelevantPost = Model.hasOne<Post>('mostRelevantPost');
commentCount: Model.attribute('commentCount'), lastReadAt = Model.attribute<Date>('lastReadAt', Model.transformDate);
replyCount: computed('commentCount', (commentCount) => Math.max(0, commentCount - 1)), lastReadPostNumber = Model.attribute<number>('lastReadPostNumber');
posts: Model.hasMany('posts'), isUnread = computed<boolean>('unreadCount', (unreadCount) => !!unreadCount);
mostRelevantPost: Model.hasOne('mostRelevantPost'), isRead = computed<boolean>('unreadCount', (unreadCount) => app.session.user && !unreadCount);
lastReadAt: Model.attribute('lastReadAt', Model.transformDate), hiddenAt = Model.attribute<Date>('hiddenAt', Model.transformDate);
lastReadPostNumber: Model.attribute('lastReadPostNumber'), hiddenUser = Model.hasOne<User>('hiddenUser');
isUnread: computed('unreadCount', (unreadCount) => !!unreadCount), isHidden = computed<boolean>('hiddenAt', (hiddenAt) => !!hiddenAt);
isRead: computed('unreadCount', (unreadCount) => app.session.user && !unreadCount),
hiddenAt: Model.attribute('hiddenAt', Model.transformDate), canReply = Model.attribute<boolean>('canReply');
hiddenUser: Model.hasOne('hiddenUser'), canRename = Model.attribute<boolean>('canRename');
isHidden: computed('hiddenAt', (hiddenAt) => !!hiddenAt), canHide = Model.attribute<boolean>('canHide');
canDelete = Model.attribute<boolean>('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. * 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 * Get the estimated number of unread posts in this discussion for the current
@@ -64,7 +64,7 @@ Object.assign(Discussion.prototype, {
* @return {Integer} * @return {Integer}
* @public * @public
*/ */
unreadCount() { unreadCount(): number {
const user = app.session.user; const user = app.session.user;
if (user && user.markedAllAsReadAt() < this.lastPostedAt()) { if (user && user.markedAllAsReadAt() < this.lastPostedAt()) {
@@ -75,7 +75,7 @@ Object.assign(Discussion.prototype, {
} }
return 0; return 0;
}, }
/** /**
* Get the Badge components that apply to this discussion. * Get the Badge components that apply to this discussion.
@@ -83,7 +83,7 @@ Object.assign(Discussion.prototype, {
* @return {ItemList} * @return {ItemList}
* @public * @public
*/ */
badges() { badges(): ItemList {
const items = new ItemList(); const items = new ItemList();
if (this.isHidden()) { if (this.isHidden()) {
@@ -91,7 +91,7 @@ Object.assign(Discussion.prototype, {
} }
return items; return items;
}, }
/** /**
* Get a list of all of the post IDs in this discussion. * Get a list of all of the post IDs in this discussion.
@@ -99,9 +99,9 @@ Object.assign(Discussion.prototype, {
* @return {Array} * @return {Array}
* @public * @public
*/ */
postIds() { postIds(): string[] {
const posts = this.data.relationships.posts; const posts = this.data.relationships.posts;
return posts ? posts.data.map((link) => link.id) : []; return posts ? posts.data.map((link) => link.id) : [];
}, }
}); }

View File

@@ -1,17 +0,0 @@
import Model from '../Model';
class Group extends Model {}
Object.assign(Group.prototype, {
nameSingular: Model.attribute('nameSingular'),
namePlural: Model.attribute('namePlural'),
color: Model.attribute('color'),
icon: Model.attribute('icon'),
isHidden: Model.attribute('isHidden'),
});
Group.ADMINISTRATOR_ID = '1';
Group.GUEST_ID = '2';
Group.MEMBER_ID = '3';
export default Group;

View File

@@ -0,0 +1,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<string>('nameSingular');
namePlural = Model.attribute<string>('namePlural');
color = Model.attribute<string>('color');
icon = Model.attribute<string>('icon');
isHidden = Model.attribute<boolean>('isHidden');
}

View File

@@ -1,15 +0,0 @@
import Model from '../Model';
export default class Notification extends Model {}
Object.assign(Notification.prototype, {
contentType: Model.attribute('contentType'),
content: Model.attribute('content'),
createdAt: Model.attribute('createdAt', Model.transformDate),
isRead: Model.attribute('isRead'),
user: Model.hasOne('user'),
fromUser: Model.hasOne('fromUser'),
subject: Model.hasOne('subject'),
});

View File

@@ -0,0 +1,14 @@
import Model from '../Model';
import User from './User';
export default class Notification extends Model {
contentType = Model.attribute<string>('contentType');
content = Model.attribute<any>('content');
createdAt = Model.attribute<Date>('createdAt', Model.transformDate);
isRead = Model.attribute<boolean>('isRead');
user = Model.hasOne<User>('user');
fromUser = Model.hasOne<User>('fromUser');
subject = Model.hasOne<any>('subject');
}

View File

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

View File

@@ -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>('number');
discussion = Model.hasOne<Discussion>('discussion');
createdAt = Model.attribute<Date>('createdAt', Model.transformDate);
user = Model.hasOne<User>('user');
contentType = Model.attribute<string>('contentType');
content = Model.attribute<string>('content');
contentHtml = Model.attribute<string>('contentHtml');
contentPlain = computed<string>('contentHtml', getPlainContent);
editedAt = Model.attribute<Date>('editedAt', Model.transformDate);
editedUser = Model.hasOne<User>('editedUser');
isEdited = computed<boolean>('editedAt', (editedAt) => !!editedAt);
hiddenAt = Model.attribute<Date>('hiddenAt', Model.transformDate);
hiddenUser = Model.hasOne<User>('hiddenUser');
isHidden = computed<boolean>('hiddenAt', (hiddenAt) => !!hiddenAt);
canEdit = Model.attribute<boolean>('canEdit');
canHide = Model.attribute<boolean>('canHide');
canDelete = Model.attribute<boolean>('canDelete');
}

View File

@@ -5,34 +5,33 @@ import stringToColor from '../utils/stringToColor';
import ItemList from '../utils/ItemList'; import ItemList from '../utils/ItemList';
import computed from '../utils/computed'; import computed from '../utils/computed';
import GroupBadge from '../components/GroupBadge'; import GroupBadge from '../components/GroupBadge';
import Group from './Group';
export default class User extends Model {} export default class User extends Model {
username = Model.attribute<string>('username');
displayName = Model.attribute<string>('displayName');
email = Model.attribute<string>('email');
isEmailConfirmed = Model.attribute<boolean>('isEmailConfirmed');
password = Model.attribute<string>('password');
Object.assign(User.prototype, { avatarUrl = Model.attribute<string>('avatarUrl');
username: Model.attribute('username'), preferences = Model.attribute<any>('preferences');
displayName: Model.attribute('displayName'), groups = Model.hasMany<Group>('groups');
email: Model.attribute('email'),
isEmailConfirmed: Model.attribute('isEmailConfirmed'),
password: Model.attribute('password'),
avatarUrl: Model.attribute('avatarUrl'), joinTime = Model.attribute<Date>('joinTime', Model.transformDate);
preferences: Model.attribute('preferences'), lastSeenAt = Model.attribute<Date>('lastSeenAt', Model.transformDate);
groups: Model.hasMany('groups'), markedAllAsReadAt = Model.attribute<Date>('markedAllAsReadAt', Model.transformDate);
unreadNotificationCount = Model.attribute<number>('unreadNotificationCount');
newNotificationCount = Model.attribute<number>('newNotificationCount');
joinTime: Model.attribute('joinTime', Model.transformDate), discussionCount = Model.attribute<number>('discussionCount');
lastSeenAt: Model.attribute('lastSeenAt', Model.transformDate), commentCount = Model.attribute<number>('commentCount');
markedAllAsReadAt: Model.attribute('markedAllAsReadAt', Model.transformDate),
unreadNotificationCount: Model.attribute('unreadNotificationCount'),
newNotificationCount: Model.attribute('newNotificationCount'),
discussionCount: Model.attribute('discussionCount'), canEdit = Model.attribute<boolean>('canEdit');
commentCount: Model.attribute('commentCount'), canDelete = Model.attribute<boolean>('canDelete');
canEdit: Model.attribute('canEdit'), avatarColor = null;
canDelete: Model.attribute('canDelete'), color = computed<string>('username', 'avatarUrl', 'avatarColor', (username, avatarUrl, avatarColor) => {
avatarColor: null,
color: computed('username', 'avatarUrl', 'avatarColor', function (username, avatarUrl, avatarColor) {
// If we've already calculated and cached the dominant color of the user's // 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 // 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 // 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); return '#' + stringToColor(username);
}), });
/** /**
* Check whether or not the user has been seen in the last 5 minutes. * Check whether or not the user has been seen in the last 5 minutes.
@@ -53,16 +52,16 @@ Object.assign(User.prototype, {
* @return {Boolean} * @return {Boolean}
* @public * @public
*/ */
isOnline() { isOnline(): boolean {
return dayjs().subtract(5, 'minutes').isBefore(this.lastSeenAt()); return dayjs().subtract(5, 'minutes').isBefore(this.lastSeenAt());
}, }
/** /**
* Get the Badge components that apply to this user. * Get the Badge components that apply to this user.
* *
* @return {ItemList} * @return {ItemList}
*/ */
badges() { badges(): ItemList {
const items = new ItemList(); const items = new ItemList();
const groups = this.groups(); const groups = this.groups();
@@ -73,7 +72,7 @@ Object.assign(User.prototype, {
} }
return items; return items;
}, }
/** /**
* Calculate the dominant color of the user's avatar. The dominant color will * 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.crossOrigin = 'anonymous';
image.src = this.avatarUrl(); image.src = this.avatarUrl();
}, }
/** /**
* Update the user's preferences. * Update the user's preferences.
@@ -107,5 +106,5 @@ Object.assign(User.prototype, {
Object.assign(preferences, newPreferences); Object.assign(preferences, newPreferences);
return this.save({ preferences }); return this.save({ preferences });
}, }
}); }

View File

@@ -1,3 +1,5 @@
import Model from '../Model';
/** /**
* The `computed` utility creates a function that will cache its output until * The `computed` utility creates a function that will cache its output until
* any of the dependent values are dirty. * any of the dependent values are dirty.
@@ -7,14 +9,14 @@
* dependent values. * dependent values.
* @return {Function} * @return {Function}
*/ */
export default function computed(...dependentKeys) { export default function computed<T, M = Model>(...dependentKeys: any[]) {
const keys = dependentKeys.slice(0, -1); const keys = dependentKeys.slice(0, -1);
const compute = dependentKeys.slice(-1)[0]; const compute = dependentKeys.slice(-1)[0];
const dependentValues = {}; const dependentValues = {};
let computedValue; let computedValue;
return function () { return function (this: M): T {
let recompute = false; let recompute = false;
// Read all of the dependent values. If any of them have changed since last // Read all of the dependent values. If any of them have changed since last