Fix listItems isSeparator function, add m() children to attrs, work on posts, subtree retainer

This commit is contained in:
David Sevilla Martin 2020-01-05 16:53:13 -05:00
parent 49d2539aef
commit 0de0c83353
No known key found for this signature in database
GPG Key ID: F764F1417E16B15F
19 changed files with 645 additions and 26 deletions

12
js/dist/admin.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

12
js/dist/forum.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
import Mithril from "mithril";
import Mithril from 'mithril';
import Bus from './Bus';
import Translator from './Translator';

View File

@ -1,6 +1,6 @@
import MicroModal from 'micromodal';
import Component from "../Component";
import Component from '../Component';
import Modal from './Modal';
/**
@ -106,7 +106,7 @@ export default class ModalManager extends Component {
* @protected
*/
onready() {
if (this.component && this.component.onready) {
if (this.component?.onready) {
this.component.onready();
}
}

View File

@ -0,0 +1,19 @@
import Component, {ComponentProps} from '../Component';
export interface PlaceholderProps extends ComponentProps {
text: string
}
/**
* The `Placeholder` component displays a muted text with some call to action,
* usually used as an empty state.
*/
export default class Placeholder extends Component<PlaceholderProps> {
view() {
return (
<div className="Placeholder">
<p>{this.props.text}</p>
</div>
);
}
}

View File

@ -1,7 +1,7 @@
import Separator from '../components/Separator';
export function isSeparator(item) {
return item && item.component === Separator;
return item?.tag === Separator;
}
export function withoutUnnecessarySeparators(items) {

View File

@ -0,0 +1,14 @@
import icon from './icon';
import User from '../models/User';
/**
* The `useronline` helper displays a green circle if the user is online
*
* @param {User} user
* @return {Object}
*/
export default function userOnline(user: User) {
if (user.lastSeenAt() && user.isOnline()) {
return <span className="UserOnline">{icon('fas fa-circle')}</span>;
}
}

View File

@ -3,7 +3,7 @@ import stringToColor from '../utils/stringToColor';
import ItemList from '../utils/ItemList';
import computed from '../utils/computed';
import GroupBadge from '../components/GroupBadge';
import Group from "./Group";
import Group from './Group';
export default class User extends Model {
username = Model.attribute('username') as () => string;

View File

@ -39,7 +39,7 @@ export default class ItemList<T = any> {
* Get the content of an item.
*/
get(key: any): T {
return this.items[key].content;
return this.items[key]?.content;
}
/**
@ -66,6 +66,7 @@ export default class ItemList<T = any> {
if (this.items[i] !== null && this.items[i] instanceof Item) {
this.items[i].content = Object(this.items[i].content);
// @ts-ignore
this.items[i].content.itemName = i;
items.push(this.items[i]);
this.items[i].key = items.length;

View File

@ -0,0 +1,30 @@
export default class SubtreeRetainer {
callbacks: Function[];
data = {};
constructor(...callbacks: Function[]) {
this.callbacks = callbacks;
}
check(...callbacks: Function[]) {
this.callbacks.concat(...callbacks);
}
/**
* Return whether the component should redraw.
*/
update(): boolean {
let update = false;
this.callbacks.forEach((callback, i) => {
const result = callback();
if (result !== this.data[i]) {
this.data[i] = result;
update = true;
}
});
return update;
}
}

View File

@ -1,10 +1,29 @@
import m from 'mithril';
import prop from 'mithril/stream';
import Component from '../Component';
export default () => {
const mo = window['m'];
const _m = function (comp, ...args) {
if (!arguments[1]) arguments[1] = {};
if (comp.prototype && comp.prototype instanceof Component) {
let children = args.slice(1);
if (children.length === 1 && Array.isArray(children[0])) {
children = children[0];
}
if (children) {
Object.defineProperty(arguments[1], 'children', {
writable: true,
});
arguments[1].children = (arguments[1].children || []).concat(children);
}
}
const node = mo.apply(this, arguments);
if (!node.attrs) node.attrs = {};

View File

@ -0,0 +1,151 @@
import Post from './Post';
import PostUser from './PostUser';
// import PostMeta from './PostMeta';
// import PostEdited from './PostEdited';
// import EditPostComposer from './EditPostComposer';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
import Button from '../../common/components/Button';
/**
* The `CommentPost` component displays a standard `comment`-typed post. This
* includes a number of item lists (controls, header, and footer) surrounding
* the post's HTML content.
*/
export default class CommentPost extends Post {
/**
* If the post has been hidden, then this flag determines whether or not its
* content has been expanded.
*/
revealContent: boolean = false;
postUser: PostUser;
oninit(vnode) {
super.oninit(vnode);
// Create an instance of the component that displays the post's author so
// that we can force the post to rerender when the user card is shown.
this.postUser = PostUser.component({post: this.props.post});
this.subtree.check(
() => this.postUser.cardVisible,
() => this.isEditing()
);
}
content() {
// Note: we avoid using JSX for the <ul> below because it results in some
// weirdness in Mithril.js 0.1.x (see flarum/core#975). This workaround can
// be reverted when we upgrade to Mithril 1.0.
return super.content().concat([
<header className="Post-header">{m('ul', listItems(this.headerItems().toArray()))}</header>,
<div className="Post-body">
{this.isEditing()
? <div className="Post-preview" config={this.configPreview.bind(this)}/>
: m.trust(this.props.post.contentHtml())}
</div>
]);
}
onupdate(vnode) {
super.onupdate(vnode);
const contentHtml = this.isEditing() ? '' : this.props.post.contentHtml();
// TODO: idk what this is supposed to be
// If the post content has changed since the last render, we'll run through
// all of the <script> tags in the content and evaluate them. This is
// necessary because TextFormatter outputs them for e.g. syntax highlighting.
if (vnode.contentHtml !== contentHtml) {
this.$('.Post-body script').each(function() {
eval.call(window, $(this).text());
});
}
vnode.contentHtml = contentHtml;
}
isEditing() {
return false; // TODO
// return app.composer?.component instanceof EditPostComposer &&
// app.composer.component.props.post === this.props.post;
}
attrs() {
const post = this.props.post;
const attrs = super.attrs();
attrs.className = (attrs.className || '') + ' ' + classNames({
'CommentPost': true,
'Post--hidden': post.isHidden(),
'Post--edited': post.isEdited(),
'revealContent': this.revealContent,
'editing': this.isEditing()
});
return attrs;
}
// TODO change so it works
configPreview(element, isInitialized, context) {
if (isInitialized) return;
// Every 50ms, if the composer content has changed, then update the post's
// body with a preview.
let preview;
const updatePreview = () => {
const content = app.composer.component.content();
if (preview === content) return;
preview = content;
s9e.TextFormatter.preview(preview || '', element);
};
updatePreview();
const updateInterval = setInterval(updatePreview, 50);
context.onunload = () => clearInterval(updateInterval);
}
/**
* Toggle the visibility of a hidden post's content.
*/
toggleContent() {
this.revealContent = !this.revealContent;
}
/**
* Build an item list for the post's header.
*
* @return {ItemList}
*/
headerItems() {
const items = new ItemList();
const post = this.props.post;
const props = {post};
items.add('user', this.postUser, 100);
// items.add('meta', PostMeta.component(props));
if (post.isEdited() && !post.isHidden()) {
items.add('edited', PostEdited.component(props));
}
// If the post is hidden, add a button that allows toggling the visibility
// of the post's content.
if (post.isHidden()) {
items.add('toggle', (
Button.component({
className: 'Button Button--default Button--more',
icon: 'fas fa-ellipsis-h',
onclick: this.toggleContent.bind(this)
})
));
}
return items;
}
}

View File

@ -0,0 +1,118 @@
import Component, {ComponentProps} from '../../common/Component';
import Dropdown from '../../common/components/Dropdown';
import PostControls from '../utils/PostControls';
import listItems from '../../common/helpers/listItems';
import ItemList from '../../common/utils/ItemList';
import SubtreeRetainer from "../../common/utils/SubtreeRetainer";
import PostModel from '../../common/models/Post';
export interface PostProps extends ComponentProps {
post: PostModel
}
/**
* The `Post` component displays a single post. The basic post template just
* includes a controls dropdown; subclasses must implement `content` and `attrs`
* methods.
*
* @abstract
*/
export default class Post<T extends PostProps = PostProps> extends Component<PostProps> {
loading = false;
controlsOpen = false;
subtree: SubtreeRetainer;
oninit(vnode) {
super.oninit(vnode);
/**
* Set up a subtree retainer so that the post will not be redrawn
* unless new data comes in.
*/
this.subtree = new SubtreeRetainer(
() => this.props.post.freshness,
() => {
const user = this.props.post.user();
return user?.freshness;
},
() => this.controlsOpen
);
}
view() {
const controls = PostControls.controls(this.props.post, this).toArray();
const attrs = this.attrs();
attrs.className = classNames('Post', this.loading && 'Post--loading', attrs.className);
return (
<article {...attrs}>
<div>
{this.content()}
<aside className="Post-actions">
<ul>
{listItems(this.actionItems().toArray())}
{controls.length ? <li>
<Dropdown
className="Post-controls"
buttonClassName="Button Button--icon Button--flat"
menuClassName="Dropdown-menu--right"
icon="fas fa-ellipsis-h"
onshow={() => this.$('.Post-actions').addClass('open')}
onhide={() => this.$('.Post-actions').removeClass('open')}>
{controls}
</Dropdown>
</li> : ''}
</ul>
</aside>
<footer className="Post-footer"><ul>{listItems(this.footerItems().toArray())}</ul></footer>
</div>
);
</article>
);
}
onbeforeupdate(vnode) {
super.onbeforeupdate(vnode);
return this.subtree.update();
}
oncreate(vnode) {
super.oncreate(vnode);
const $actions = this.$('.Post-actions');
const $controls = this.$('.Post-controls');
$actions.toggleClass('open', $controls.hasClass('open'));
}
/**
* Get attributes for the post element.
*/
attrs(): ComponentProps {
return {};
}
/**
* Get the post's content.
*/
content() {
return [];
}
/**
* Build an item list for the post's actions.
*/
actionItems() {
return new ItemList();
}
/**
* Build an item list for the post's footer.
*/
footerItems() {
return new ItemList();
}
}

View File

@ -0,0 +1,94 @@
import Component from '../../common/Component';
import UserCard from './UserCard';
import avatar from '../../common/helpers/avatar';
import username from '../../common/helpers/username';
import userOnline from '../../common/helpers/userOnline';
import listItems from '../../common/helpers/listItems';
import {PostProps} from "./Post";
/**
* The `PostUser` component shows the avatar and username of a post's author.
*/
export default class PostUser extends Component<PostProps> {
/**
* Whether or not the user hover card is visible.
*/
cardVisible = false;
view() {
const post = this.props.post;
const user = post.user();
if (!user) {
return (
<div className="PostUser">
<h3>{avatar(user, {className: 'PostUser-avatar'})} {username(user)}</h3>
</div>
);
}
let card = '';
if (!post.isHidden() && this.cardVisible) {
card = UserCard.component({
user,
className: 'UserCard--popover',
controlsButtonClassName: 'Button Button--icon Button--flat'
});
}
return (
<div className="PostUser">
<h3>
<m.route.Link href={app.route.user(user)}>
{avatar(user, {className: 'PostUser-avatar'})}
{userOnline(user)}
{username(user)}
</m.route.Link>
</h3>
<ul className="PostUser-badges badges">
{listItems(user.badges().toArray())}
</ul>
{card}
</div>
);
}
oncreate(vnode) {
super.oncreate(vnode);
let timeout;
this.$()
.on('mouseover', 'h3 a, .UserCard', () => {
clearTimeout(timeout);
timeout = setTimeout(this.showCard.bind(this), 500);
})
.on('mouseout', 'h3 a, .UserCard', () => {
clearTimeout(timeout);
timeout = setTimeout(this.hideCard.bind(this), 250);
});
}
/**
* Show the user card.
*/
showCard() {
this.cardVisible = true;
m.redraw();
setTimeout(() => this.$('.UserCard').addClass('in'));
}
/**
* Hide the user card.
*/
hideCard() {
this.$('.UserCard').removeClass('in')
.one('transitionend webkitTransitionEnd oTransitionEnd', () => {
this.cardVisible = false;
m.redraw();
});
}
}

View File

@ -1,9 +1,9 @@
import UserPage from './UserPage';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import Button from '../../common/components/Button';
// import Placeholder from '../../common/components/Placeholder';
// import CommentPost from './CommentPost';
import Post from "../../common/models/Post";
import Placeholder from '../../common/components/Placeholder';
import CommentPost from './CommentPost';
import Post from '../../common/models/Post';
/**
* The `PostsUserPage` component shows a user's activity feed inside of their
@ -43,8 +43,6 @@ export default class PostsUserPage extends UserPage {
}
content() {
return <p>test</p>;
if (this.posts.length === 0 && ! this.loading) {
return (
<div className="PostsUserPage">

View File

@ -1,7 +1,7 @@
import highlight from '../../common/helpers/highlight';
import avatar from '../../common/helpers/avatar';
import username from '../../common/helpers/username';
import SearchSource from "./SearchSource";
import SearchSource from './SearchSource';
import User from '../../common/models/User';
/**

View File

@ -0,0 +1,175 @@
import {Vnode} from "mithril";
// import EditPostComposer from '../components/EditPostComposer';
import Button from '../../common/components/Button';
import Separator from '../../common/components/Separator';
import ItemList from '../../common/utils/ItemList';
import Post from "../../common/models/Post";
import PostComponent from "../../forum/components/Post";
/**
* The `PostControls` utility constructs a list of buttons for a post which
* perform actions on it.
*/
export default {
/**
* Get a list of controls for a post.
*
* @param {Post} post
* @param {*} context The parent component under which the controls menu will
* be displayed.
* @public
*/
controls(post: Post, context) {
const items = new ItemList();
['user', 'moderation', 'destructive'].forEach(section => {
const controls = this[section + 'Controls'](post, context).toArray();
if (controls.length) {
controls.forEach(item => items.add(item.itemName, item));
items.add(section + 'Separator', Separator.component());
}
});
return items;
},
/**
* Get controls for a post pertaining to the current user (e.g. report).
*
* @param {Post} post
* @param {*} context The parent component under which the controls menu will
* be displayed.
* @protected
*/
userControls(post: Post, context) {
return new ItemList();
},
/**
* Get controls for a post pertaining to moderation (e.g. edit).
*
* @param {Post} post
* @param {*} context The parent component under which the controls menu will
* be displayed.
* @protected
*/
moderationControls(post: Post, context) {
const items = new ItemList();
if (post.contentType() === 'comment' && post.canEdit()) {
if (!post.isHidden()) {
items.add('edit', Button.component({
icon: 'fas fa-pencil-alt',
onclick: this.editAction.bind(post)
}, app.translator.trans('core.forum.post_controls.edit_button')));
}
}
return items;
},
/**
* Get controls for a post that are destructive (e.g. delete).
*
* @param {Post} post
* @param {*} context The parent component under which the controls menu will
* be displayed.
* @protected
*/
destructiveControls(post: Post, context) {
const items = new ItemList();
if (post.contentType() === 'comment' && !post.isHidden()) {
if (post.canHide()) {
items.add('hide', Button.component({
icon: 'far fa-trash-alt',
children: app.translator.trans('core.forum.post_controls.delete_button'),
onclick: this.hideAction.bind(post)
}));
}
} else {
if (post.contentType() === 'comment' && post.canHide()) {
items.add('restore', Button.component({
icon: 'fas fa-reply',
children: app.translator.trans('core.forum.post_controls.restore_button'),
onclick: this.restoreAction.bind(post)
}));
}
if (post.canDelete()) {
items.add('delete', Button.component({
icon: 'fas fa-times',
children: app.translator.trans('core.forum.post_controls.delete_forever_button'),
onclick: this.deleteAction.bind(post, context)
}));
}
}
return items;
},
/**
* Open the composer to edit a post.
*/
editAction(this: Post) {
return new Promise<EditPostComposer>(resolve => {
const component = new EditPostComposer({ post: this });
app.composer.load(component);
app.composer.show();
resolve(component);
});
},
/**
* Hide a post.
*/
hideAction(this: Post) {
this.pushAttributes({ hiddenAt: new Date(), hiddenUser: app.session.user });
return this.save({ isHidden: true }).then(() => m.redraw());
},
/**
* Restore a post.
*/
restoreAction(this: Post) {
this.pushAttributes({ hiddenAt: null, hiddenUser: null });
return this.save({ isHidden: false }).then(() => m.redraw());
},
/**
* Delete a post.
*/
deleteAction(this: Post, context: PostComponent) {
if (context) context.loading = true;
return this.delete()
.then(() => {
const discussion = this.discussion();
discussion.removePost(this.id());
// If this was the last post in the discussion, then we will assume that
// the whole discussion was deleted too.
if (!discussion.postIds().length) {
// If there is a discussion list in the cache, remove this discussion.
if (app.cache.discussionList) {
app.cache.discussionList.removeDiscussion(discussion);
}
if (app.viewingDiscussion(discussion)) {
app.history.back();
}
}
})
.catch(() => {})
.then(() => {
if (context) context.loading = false;
m.redraw();
});
}
};