1
0
mirror of https://github.com/flarum/core.git synced 2025-07-11 20:06:23 +02:00

Mithril 2 update (#2255)

* Update frontend to Mithril 2

- Update Mithril version to v2.0.4
- Add Typescript typings for Mithril
- Rename "props" to "attrs"; "initProps" to "initAttrs"; "m.prop" to "m.stream"; "m.withAttr" to "utils/withAttr".
- Use Mithril 2's new lifecycle hooks
- SubtreeRetainer has been rewritten to be more useful for the new system
- Utils for forcing page re-initializations have been added (force attr in links, setRouteWithForcedRefresh util)
- Other mechanical changes, following the upgrade guide
- Remove some of the custom stuff in our Component base class
- Introduce "fragments" for non-components that control their own DOM
- Remove Mithril patches, introduce a few new ones (route attrs in <a>; 
- Redesign AlertManagerState `show` with 3 overloads: `show(children)`, `show(attrs, children)`, `show(componentClass, attrs, children)`
- The `affixedSidebar` util has been replaced with an `AffixedSidebar` component

Challenges:
- `children` and `tag` are now reserved, and can not be used as attr names
- Behavior of links to current page changed in Mithril. If moving to a page that is handled by the same component, the page component WILL NOT be re-initialized by default. Additional code to keep track of the current url is needed (See IndexPage, DiscussionPage, and UserPage for examples)
- Native Promise rejections are shown on console when not handled
- Instances of components can no longer be stored. The state pattern should be used instead.

Refs #1821.

Co-authored-by: Alexander Skvortsov <sasha.skvortsov109@gmail.com>
Co-authored-by: Matthew Kilgore <tankerkiller125@gmail.com>
Co-authored-by: Franz Liedke <franz@develophp.org>
This commit is contained in:
David Sevilla Martín
2020-09-23 22:40:37 -04:00
committed by GitHub
parent 1321b8cc28
commit 71f3379fcc
127 changed files with 2411 additions and 2074 deletions

11
js/package-lock.json generated
View File

@ -1075,6 +1075,11 @@
"integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==",
"dev": true "dev": true
}, },
"@types/mithril": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/mithril/-/mithril-2.0.3.tgz",
"integrity": "sha512-cZHOdO2IiXYeyjeDYdbOisSdfaJRzfmRo3zVzgu33IWTMA0KEQObp9fdvqcuYdPz93iJ1yCl19GcEjo/9yv+yA=="
},
"@types/parse-json": { "@types/parse-json": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@ -3807,9 +3812,9 @@
} }
}, },
"mithril": { "mithril": {
"version": "0.2.8", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/mithril/-/mithril-0.2.8.tgz", "resolved": "https://registry.npmjs.org/mithril/-/mithril-2.0.4.tgz",
"integrity": "sha512-9XuGnVmS2OyFexUuP/CcJFFJjHLM+RGYBxyVRNyQ6khbMfDJIF/xyZ4zq18ZRfPagpFmWUFpjHd5ZqPULGZyNg==" "integrity": "sha512-mgw+DMZlhMS4PpprF6dl7ZoeZq5GGcAuWnrg5e12MvaGauc4jzWsDZtVGRCktsiQczOEUr2K5teKbE5k44RlOg=="
}, },
"mixin-deep": { "mixin-deep": {
"version": "1.3.2", "version": "1.3.2",

View File

@ -3,6 +3,7 @@
"name": "@flarum/core", "name": "@flarum/core",
"dependencies": { "dependencies": {
"@babel/preset-typescript": "^7.10.1", "@babel/preset-typescript": "^7.10.1",
"@types/mithril": "^2.0.3",
"bootstrap": "^3.4.1", "bootstrap": "^3.4.1",
"classnames": "^2.2.5", "classnames": "^2.2.5",
"color-thief-browser": "^2.0.2", "color-thief-browser": "^2.0.2",
@ -13,7 +14,7 @@
"jquery.hotkeys": "^0.1.0", "jquery.hotkeys": "^0.1.0",
"lodash-es": "^4.17.14", "lodash-es": "^4.17.14",
"m.attrs.bidi": "github:tobscure/m.attrs.bidi", "m.attrs.bidi": "github:tobscure/m.attrs.bidi",
"mithril": "^0.2.8", "mithril": "^2.0.4",
"punycode": "^2.1.1", "punycode": "^2.1.1",
"spin.js": "^3.1.0", "spin.js": "^3.1.0",
"webpack": "^4.43.0", "webpack": "^4.43.0",

48
js/shims.d.ts vendored Normal file
View File

@ -0,0 +1,48 @@
// Mithril
import * as Mithril from 'mithril';
import Stream from 'mithril/stream';
// Other third-party libs
import * as _dayjs from 'dayjs';
import * as _$ from 'jquery';
// Globals from flarum/core
import Application from './src/common/Application';
/**
* Helpers that flarum/core patches into Mithril
*/
interface m extends Mithril.Static {
prop: typeof Stream;
}
/**
* Export Mithril typings globally.
*
* This lets us use these typings without an extra import everywhere we use
* Mithril in a TypeScript file.
*/
export as namespace Mithril;
/**
* flarum/core exposes several extensions globally:
*
* - jQuery for convenient DOM manipulation
* - Mithril for VDOM and components
* - dayjs for date/time operations
*
* Since these are already part of the global namespace, extensions won't need
* to (and should not) bundle these themselves.
*/
declare global {
const $: typeof _$;
const m: m;
const dayjs: typeof _dayjs;
}
/**
* All global variables owned by flarum/core.
*/
declare global {
const app: Application;
}

View File

@ -27,13 +27,18 @@ export default class AdminApplication extends Application {
* @inheritdoc * @inheritdoc
*/ */
mount() { mount() {
m.mount(document.getElementById('app-navigation'), Navigation.component({ className: 'App-backControl', drawer: true })); m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
m.mount(document.getElementById('header-navigation'), Navigation.component()); m.mount(document.getElementById('header-navigation'), Navigation);
m.mount(document.getElementById('header-primary'), HeaderPrimary.component()); m.mount(document.getElementById('header-primary'), HeaderPrimary);
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component()); m.mount(document.getElementById('header-secondary'), HeaderSecondary);
m.mount(document.getElementById('admin-navigation'), AdminNav.component()); m.mount(document.getElementById('admin-navigation'), AdminNav);
// Mithril does not render the home route on https://example.com/admin, so
// we need to go to https://example.com/admin#/ explicitly.
if (!document.location.hash) document.location.hash = '#/';
m.route.prefix = '#';
m.route.mode = 'hash';
super.mount(); super.mount();
// If an extension has just been enabled, then we will run its settings // If an extension has just been enabled, then we will run its settings

View File

@ -10,11 +10,7 @@
import LinkButton from '../../common/components/LinkButton'; import LinkButton from '../../common/components/LinkButton';
export default class AdminLinkButton extends LinkButton { export default class AdminLinkButton extends LinkButton {
getButtonContent() { getButtonContent(children) {
const content = super.getButtonContent(); return [...super.getButtonContent(children), <div className="AdminLinkButton-description">{this.attrs.description}</div>];
content.push(<div className="AdminLinkButton-description">{this.props.description}</div>);
return content;
} }
} }

View File

@ -31,62 +31,74 @@ export default class AdminNav extends Component {
items.add( items.add(
'dashboard', 'dashboard',
AdminLinkButton.component({ AdminLinkButton.component(
{
href: app.route('dashboard'), href: app.route('dashboard'),
icon: 'far fa-chart-bar', icon: 'far fa-chart-bar',
children: app.translator.trans('core.admin.nav.dashboard_button'),
description: app.translator.trans('core.admin.nav.dashboard_text'), description: app.translator.trans('core.admin.nav.dashboard_text'),
}) },
app.translator.trans('core.admin.nav.dashboard_button')
)
); );
items.add( items.add(
'basics', 'basics',
AdminLinkButton.component({ AdminLinkButton.component(
{
href: app.route('basics'), href: app.route('basics'),
icon: 'fas fa-pencil-alt', icon: 'fas fa-pencil-alt',
children: app.translator.trans('core.admin.nav.basics_button'),
description: app.translator.trans('core.admin.nav.basics_text'), description: app.translator.trans('core.admin.nav.basics_text'),
}) },
app.translator.trans('core.admin.nav.basics_button')
)
); );
items.add( items.add(
'mail', 'mail',
AdminLinkButton.component({ AdminLinkButton.component(
{
href: app.route('mail'), href: app.route('mail'),
icon: 'fas fa-envelope', icon: 'fas fa-envelope',
children: app.translator.trans('core.admin.nav.email_button'),
description: app.translator.trans('core.admin.nav.email_text'), description: app.translator.trans('core.admin.nav.email_text'),
}) },
app.translator.trans('core.admin.nav.email_button')
)
); );
items.add( items.add(
'permissions', 'permissions',
AdminLinkButton.component({ AdminLinkButton.component(
{
href: app.route('permissions'), href: app.route('permissions'),
icon: 'fas fa-key', icon: 'fas fa-key',
children: app.translator.trans('core.admin.nav.permissions_button'),
description: app.translator.trans('core.admin.nav.permissions_text'), description: app.translator.trans('core.admin.nav.permissions_text'),
}) },
app.translator.trans('core.admin.nav.permissions_button')
)
); );
items.add( items.add(
'appearance', 'appearance',
AdminLinkButton.component({ AdminLinkButton.component(
{
href: app.route('appearance'), href: app.route('appearance'),
icon: 'fas fa-paint-brush', icon: 'fas fa-paint-brush',
children: app.translator.trans('core.admin.nav.appearance_button'),
description: app.translator.trans('core.admin.nav.appearance_text'), description: app.translator.trans('core.admin.nav.appearance_text'),
}) },
app.translator.trans('core.admin.nav.appearance_button')
)
); );
items.add( items.add(
'extensions', 'extensions',
AdminLinkButton.component({ AdminLinkButton.component(
{
href: app.route('extensions'), href: app.route('extensions'),
icon: 'fas fa-puzzle-piece', icon: 'fas fa-puzzle-piece',
children: app.translator.trans('core.admin.nav.extensions_button'),
description: app.translator.trans('core.admin.nav.extensions_text'), description: app.translator.trans('core.admin.nav.extensions_text'),
}) },
app.translator.trans('core.admin.nav.extensions_button')
)
); );
return items; return items;

View File

@ -6,15 +6,16 @@ import EditCustomHeaderModal from './EditCustomHeaderModal';
import EditCustomFooterModal from './EditCustomFooterModal'; import EditCustomFooterModal from './EditCustomFooterModal';
import UploadImageButton from './UploadImageButton'; import UploadImageButton from './UploadImageButton';
import saveSettings from '../utils/saveSettings'; import saveSettings from '../utils/saveSettings';
import withAttr from '../../common/utils/withAttr';
export default class AppearancePage extends Page { export default class AppearancePage extends Page {
init() { oninit(vnode) {
super.init(); super.oninit(vnode);
this.primaryColor = m.prop(app.data.settings.theme_primary_color); this.primaryColor = m.stream(app.data.settings.theme_primary_color);
this.secondaryColor = m.prop(app.data.settings.theme_secondary_color); this.secondaryColor = m.stream(app.data.settings.theme_secondary_color);
this.darkMode = m.prop(app.data.settings.theme_dark_mode); this.darkMode = m.stream(app.data.settings.theme_dark_mode);
this.coloredHeader = m.prop(app.data.settings.theme_colored_header); this.coloredHeader = m.stream(app.data.settings.theme_colored_header);
} }
view() { view() {
@ -27,40 +28,34 @@ export default class AppearancePage extends Page {
<div className="helpText">{app.translator.trans('core.admin.appearance.colors_text')}</div> <div className="helpText">{app.translator.trans('core.admin.appearance.colors_text')}</div>
<div className="AppearancePage-colors-input"> <div className="AppearancePage-colors-input">
<input <input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.primaryColor} />
className="FormControl" <input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.secondaryColor} />
type="text"
placeholder="#aaaaaa"
value={this.primaryColor()}
onchange={m.withAttr('value', this.primaryColor)}
/>
<input
className="FormControl"
type="text"
placeholder="#aaaaaa"
value={this.secondaryColor()}
onchange={m.withAttr('value', this.secondaryColor)}
/>
</div> </div>
{Switch.component({ {Switch.component(
{
state: this.darkMode(), state: this.darkMode(),
children: app.translator.trans('core.admin.appearance.dark_mode_label'),
onchange: this.darkMode, onchange: this.darkMode,
})} },
app.translator.trans('core.admin.appearance.dark_mode_label')
)}
{Switch.component({ {Switch.component(
{
state: this.coloredHeader(), state: this.coloredHeader(),
children: app.translator.trans('core.admin.appearance.colored_header_label'),
onchange: this.coloredHeader, onchange: this.coloredHeader,
})} },
app.translator.trans('core.admin.appearance.colored_header_label')
)}
{Button.component({ {Button.component(
{
className: 'Button Button--primary', className: 'Button Button--primary',
type: 'submit', type: 'submit',
children: app.translator.trans('core.admin.appearance.submit_button'),
loading: this.loading, loading: this.loading,
})} },
app.translator.trans('core.admin.appearance.submit_button')
)}
</fieldset> </fieldset>
</form> </form>
@ -79,31 +74,37 @@ export default class AppearancePage extends Page {
<fieldset> <fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_header_heading')}</legend> <legend>{app.translator.trans('core.admin.appearance.custom_header_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_header_text')}</div> <div className="helpText">{app.translator.trans('core.admin.appearance.custom_header_text')}</div>
{Button.component({ {Button.component(
{
className: 'Button', className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_header_button'),
onclick: () => app.modal.show(EditCustomHeaderModal), onclick: () => app.modal.show(EditCustomHeaderModal),
})} },
app.translator.trans('core.admin.appearance.edit_header_button')
)}
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_footer_heading')}</legend> <legend>{app.translator.trans('core.admin.appearance.custom_footer_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_footer_text')}</div> <div className="helpText">{app.translator.trans('core.admin.appearance.custom_footer_text')}</div>
{Button.component({ {Button.component(
{
className: 'Button', className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_footer_button'),
onclick: () => app.modal.show(EditCustomFooterModal), onclick: () => app.modal.show(EditCustomFooterModal),
})} },
app.translator.trans('core.admin.appearance.edit_footer_button')
)}
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend> <legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_styles_text')}</div> <div className="helpText">{app.translator.trans('core.admin.appearance.custom_styles_text')}</div>
{Button.component({ {Button.component(
{
className: 'Button', className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_css_button'),
onclick: () => app.modal.show(EditCustomCssModal), onclick: () => app.modal.show(EditCustomCssModal),
})} },
app.translator.trans('core.admin.appearance.edit_css_button')
)}
</fieldset> </fieldset>
</div> </div>
</div> </div>

View File

@ -5,10 +5,11 @@ import Button from '../../common/components/Button';
import saveSettings from '../utils/saveSettings'; import saveSettings from '../utils/saveSettings';
import ItemList from '../../common/utils/ItemList'; import ItemList from '../../common/utils/ItemList';
import Switch from '../../common/components/Switch'; import Switch from '../../common/components/Switch';
import withAttr from '../../common/utils/withAttr';
export default class BasicsPage extends Page { export default class BasicsPage extends Page {
init() { oninit(vnode) {
super.init(); super.oninit(vnode);
this.loading = false; this.loading = false;
@ -25,7 +26,7 @@ export default class BasicsPage extends Page {
this.values = {}; this.values = {};
const settings = app.data.settings; const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = m.prop(settings[key]))); this.fields.forEach((key) => (this.values[key] = m.stream(settings[key])));
this.localeOptions = {}; this.localeOptions = {};
const locales = app.data.locales; const locales = app.data.locales;
@ -49,45 +50,51 @@ export default class BasicsPage extends Page {
<div className="BasicsPage"> <div className="BasicsPage">
<div className="container"> <div className="container">
<form onsubmit={this.onsubmit.bind(this)}> <form onsubmit={this.onsubmit.bind(this)}>
{FieldSet.component({ {FieldSet.component(
{
label: app.translator.trans('core.admin.basics.forum_title_heading'), label: app.translator.trans('core.admin.basics.forum_title_heading'),
children: [<input className="FormControl" value={this.values.forum_title()} oninput={m.withAttr('value', this.values.forum_title)} />], },
})} [<input className="FormControl" bidi={this.values.forum_title} />]
)}
{FieldSet.component({ {FieldSet.component(
{
label: app.translator.trans('core.admin.basics.forum_description_heading'), label: app.translator.trans('core.admin.basics.forum_description_heading'),
children: [ },
[
<div className="helpText">{app.translator.trans('core.admin.basics.forum_description_text')}</div>, <div className="helpText">{app.translator.trans('core.admin.basics.forum_description_text')}</div>,
<textarea <textarea className="FormControl" bidi={this.values.forum_description} />,
className="FormControl" ]
value={this.values.forum_description()} )}
oninput={m.withAttr('value', this.values.forum_description)}
/>,
],
})}
{Object.keys(this.localeOptions).length > 1 {Object.keys(this.localeOptions).length > 1
? FieldSet.component({ ? FieldSet.component(
{
label: app.translator.trans('core.admin.basics.default_language_heading'), label: app.translator.trans('core.admin.basics.default_language_heading'),
children: [ },
[
Select.component({ Select.component({
options: this.localeOptions, options: this.localeOptions,
value: this.values.default_locale(), value: this.values.default_locale(),
onchange: this.values.default_locale, onchange: this.values.default_locale,
}), }),
Switch.component({ Switch.component(
{
state: this.values.show_language_selector(), state: this.values.show_language_selector(),
onchange: this.values.show_language_selector, onchange: this.values.show_language_selector,
children: app.translator.trans('core.admin.basics.show_language_selector_label'), },
}), app.translator.trans('core.admin.basics.show_language_selector_label')
], ),
}) ]
)
: ''} : ''}
{FieldSet.component({ {FieldSet.component(
{
label: app.translator.trans('core.admin.basics.home_page_heading'), label: app.translator.trans('core.admin.basics.home_page_heading'),
className: 'BasicsPage-homePage', className: 'BasicsPage-homePage',
children: [ },
[
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>, <div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>,
this.homePageItems() this.homePageItems()
.toArray() .toArray()
@ -98,51 +105,52 @@ export default class BasicsPage extends Page {
name="homePage" name="homePage"
value={path} value={path}
checked={this.values.default_route() === path} checked={this.values.default_route() === path}
onclick={m.withAttr('value', this.values.default_route)} onclick={withAttr('value', this.values.default_route)}
/> />
{label} {label}
</label> </label>
)), )),
], ]
})} )}
{FieldSet.component({ {FieldSet.component(
{
label: app.translator.trans('core.admin.basics.welcome_banner_heading'), label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
className: 'BasicsPage-welcomeBanner', className: 'BasicsPage-welcomeBanner',
children: [ },
[
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>, <div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>,
<div className="BasicsPage-welcomeBanner-input"> <div className="BasicsPage-welcomeBanner-input">
<input className="FormControl" value={this.values.welcome_title()} oninput={m.withAttr('value', this.values.welcome_title)} /> <input className="FormControl" bidi={this.values.welcome_title} />
<textarea <textarea className="FormControl" bidi={this.values.welcome_message} />
className="FormControl"
value={this.values.welcome_message()}
oninput={m.withAttr('value', this.values.welcome_message)}
/>
</div>, </div>,
], ]
})} )}
{Object.keys(this.displayNameOptions).length > 1 {Object.keys(this.displayNameOptions).length > 1
? FieldSet.component({ ? FieldSet.component(
{
label: app.translator.trans('core.admin.basics.display_name_heading'), label: app.translator.trans('core.admin.basics.display_name_heading'),
children: [ },
[
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>, <div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>,
Select.component({ Select.component({
options: this.displayNameOptions, options: this.displayNameOptions,
value: this.values.display_name_driver(), bidi: this.values.display_name_driver,
onchange: this.values.display_name_driver,
}), }),
], ]
}) )
: ''} : ''}
{Button.component({ {Button.component(
{
type: 'submit', type: 'submit',
className: 'Button Button--primary', className: 'Button Button--primary',
children: app.translator.trans('core.admin.basics.submit_button'),
loading: this.loading, loading: this.loading,
disabled: !this.changed(), disabled: !this.changed(),
})} },
app.translator.trans('core.admin.basics.submit_button')
)}
</form> </form>
</div> </div>
</div> </div>
@ -185,10 +193,7 @@ export default class BasicsPage extends Page {
saveSettings(settings) saveSettings(settings)
.then(() => { .then(() => {
this.successAlert = app.alerts.show({ this.successAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.basics.saved_message'));
type: 'success',
children: app.translator.trans('core.admin.basics.saved_message'),
});
}) })
.catch(() => {}) .catch(() => {})
.then(() => { .then(() => {

View File

@ -4,20 +4,23 @@ import Badge from '../../common/components/Badge';
import Group from '../../common/models/Group'; import Group from '../../common/models/Group';
import ItemList from '../../common/utils/ItemList'; import ItemList from '../../common/utils/ItemList';
import Switch from '../../common/components/Switch'; import Switch from '../../common/components/Switch';
import withAttr from '../../common/utils/withAttr';
/** /**
* The `EditGroupModal` component shows a modal dialog which allows the user * The `EditGroupModal` component shows a modal dialog which allows the user
* to create or edit a group. * to create or edit a group.
*/ */
export default class EditGroupModal extends Modal { export default class EditGroupModal extends Modal {
init() { oninit(vnode) {
this.group = this.props.group || app.store.createRecord('groups'); super.oninit(vnode);
this.nameSingular = m.prop(this.group.nameSingular() || ''); this.group = this.attrs.group || app.store.createRecord('groups');
this.namePlural = m.prop(this.group.namePlural() || '');
this.icon = m.prop(this.group.icon() || ''); this.nameSingular = m.stream(this.group.nameSingular() || '');
this.color = m.prop(this.group.color() || ''); this.namePlural = m.stream(this.group.namePlural() || '');
this.isHidden = m.prop(this.group.isHidden() || false); this.icon = m.stream(this.group.icon() || '');
this.color = m.stream(this.group.color() || '');
this.isHidden = m.stream(this.group.isHidden() || false);
} }
className() { className() {
@ -53,18 +56,8 @@ export default class EditGroupModal extends Modal {
<div className="Form-group"> <div className="Form-group">
<label>{app.translator.trans('core.admin.edit_group.name_label')}</label> <label>{app.translator.trans('core.admin.edit_group.name_label')}</label>
<div className="EditGroupModal-name-input"> <div className="EditGroupModal-name-input">
<input <input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.singular_placeholder')} bidi={this.nameSingular} />
className="FormControl" <input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.plural_placeholder')} bidi={this.namePlural} />
placeholder={app.translator.trans('core.admin.edit_group.singular_placeholder')}
value={this.nameSingular()}
oninput={m.withAttr('value', this.nameSingular)}
/>
<input
className="FormControl"
placeholder={app.translator.trans('core.admin.edit_group.plural_placeholder')}
value={this.namePlural()}
oninput={m.withAttr('value', this.namePlural)}
/>
</div> </div>
</div>, </div>,
30 30
@ -74,7 +67,7 @@ export default class EditGroupModal extends Modal {
'color', 'color',
<div className="Form-group"> <div className="Form-group">
<label>{app.translator.trans('core.admin.edit_group.color_label')}</label> <label>{app.translator.trans('core.admin.edit_group.color_label')}</label>
<input className="FormControl" placeholder="#aaaaaa" value={this.color()} oninput={m.withAttr('value', this.color)} /> <input className="FormControl" placeholder="#aaaaaa" bidi={this.color} />
</div>, </div>,
20 20
); );
@ -86,7 +79,7 @@ export default class EditGroupModal extends Modal {
<div className="helpText"> <div className="helpText">
{app.translator.trans('core.admin.edit_group.icon_text', { a: <a href="https://fontawesome.com/icons?m=free" tabindex="-1" /> })} {app.translator.trans('core.admin.edit_group.icon_text', { a: <a href="https://fontawesome.com/icons?m=free" tabindex="-1" /> })}
</div> </div>
<input className="FormControl" placeholder="fas fa-bolt" value={this.icon()} oninput={m.withAttr('value', this.icon)} /> <input className="FormControl" placeholder="fas fa-bolt" bidi={this.icon} />
</div>, </div>,
10 10
); );
@ -94,11 +87,13 @@ export default class EditGroupModal extends Modal {
items.add( items.add(
'hidden', 'hidden',
<div className="Form-group"> <div className="Form-group">
{Switch.component({ {Switch.component(
{
state: !!Number(this.isHidden()), state: !!Number(this.isHidden()),
children: app.translator.trans('core.admin.edit_group.hide_label'),
onchange: this.isHidden, onchange: this.isHidden,
})} },
app.translator.trans('core.admin.edit_group.hide_label')
)}
</div>, </div>,
10 10
); );
@ -106,12 +101,14 @@ export default class EditGroupModal extends Modal {
items.add( items.add(
'submit', 'submit',
<div className="Form-group"> <div className="Form-group">
{Button.component({ {Button.component(
{
type: 'submit', type: 'submit',
className: 'Button Button--primary EditGroupModal-save', className: 'Button Button--primary EditGroupModal-save',
loading: this.loading, loading: this.loading,
children: app.translator.trans('core.admin.edit_group.submit_button'), },
})} app.translator.trans('core.admin.edit_group.submit_button')
)}
{this.group.exists && this.group.id() !== Group.ADMINISTRATOR_ID ? ( {this.group.exists && this.group.id() !== Group.ADMINISTRATOR_ID ? (
<button type="button" className="Button EditGroupModal-delete" onclick={this.deleteGroup.bind(this)}> <button type="button" className="Button EditGroupModal-delete" onclick={this.deleteGroup.bind(this)}>
{app.translator.trans('core.admin.edit_group.delete_button')} {app.translator.trans('core.admin.edit_group.delete_button')}

View File

@ -12,12 +12,14 @@ export default class ExtensionsPage extends Page {
<div className="ExtensionsPage"> <div className="ExtensionsPage">
<div className="ExtensionsPage-header"> <div className="ExtensionsPage-header">
<div className="container"> <div className="container">
{Button.component({ {Button.component(
children: app.translator.trans('core.admin.extensions.add_button'), {
icon: 'fas fa-plus', icon: 'fas fa-plus',
className: 'Button Button--primary', className: 'Button Button--primary',
onclick: () => app.modal.show(AddExtensionModal), onclick: () => app.modal.show(AddExtensionModal),
})} },
app.translator.trans('core.admin.extensions.add_button')
)}
</div> </div>
</div> </div>
@ -72,20 +74,22 @@ export default class ExtensionsPage extends Page {
if (app.extensionSettings[name]) { if (app.extensionSettings[name]) {
items.add( items.add(
'settings', 'settings',
Button.component({ Button.component(
{
icon: 'fas fa-cog', icon: 'fas fa-cog',
children: app.translator.trans('core.admin.extensions.settings_button'),
onclick: app.extensionSettings[name], onclick: app.extensionSettings[name],
}) },
app.translator.trans('core.admin.extensions.settings_button')
)
); );
} }
if (!enabled) { if (!enabled) {
items.add( items.add(
'uninstall', 'uninstall',
Button.component({ Button.component(
{
icon: 'far fa-trash-alt', icon: 'far fa-trash-alt',
children: app.translator.trans('core.admin.extensions.uninstall_button'),
onclick: () => { onclick: () => {
app app
.request({ .request({
@ -96,7 +100,9 @@ export default class ExtensionsPage extends Page {
app.modal.show(LoadingModal); app.modal.show(LoadingModal);
}, },
}) },
app.translator.trans('core.admin.extensions.uninstall_button')
)
); );
} }
@ -116,7 +122,7 @@ export default class ExtensionsPage extends Page {
.request({ .request({
url: app.forum.attribute('apiUrl') + '/extensions/' + id, url: app.forum.attribute('apiUrl') + '/extensions/' + id,
method: 'PATCH', method: 'PATCH',
data: { enabled: !enabled }, body: { enabled: !enabled },
}) })
.then(() => { .then(() => {
if (!enabled) localStorage.setItem('enabledExtension', id); if (!enabled) localStorage.setItem('enabledExtension', id);

View File

@ -11,13 +11,6 @@ export default class HeaderSecondary extends Component {
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>; return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
} }
config(isInitialized, context) {
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
}
/** /**
* Build an item list for the controls. * Build an item list for the controls.
* *

View File

@ -5,10 +5,11 @@ import Alert from '../../common/components/Alert';
import Select from '../../common/components/Select'; import Select from '../../common/components/Select';
import LoadingIndicator from '../../common/components/LoadingIndicator'; import LoadingIndicator from '../../common/components/LoadingIndicator';
import saveSettings from '../utils/saveSettings'; import saveSettings from '../utils/saveSettings';
import withAttr from '../../common/utils/withAttr';
export default class MailPage extends Page { export default class MailPage extends Page {
init() { oninit(vnode) {
super.init(); super.oninit(vnode);
this.saving = false; this.saving = false;
this.sendingTest = false; this.sendingTest = false;
@ -24,7 +25,7 @@ export default class MailPage extends Page {
this.status = { sending: false, errors: {} }; this.status = { sending: false, errors: {} };
const settings = app.data.settings; const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = m.prop(settings[key]))); this.fields.forEach((key) => (this.values[key] = m.stream(settings[key])));
app app
.request({ .request({
@ -39,7 +40,7 @@ export default class MailPage extends Page {
for (const driver in this.driverFields) { for (const driver in this.driverFields) {
for (const field in this.driverFields[driver]) { for (const field in this.driverFields[driver]) {
this.fields.push(field); this.fields.push(field);
this.values[field] = m.prop(settings[field]); this.values[field] = m.stream(settings[field]);
} }
} }
@ -69,23 +70,27 @@ export default class MailPage extends Page {
<h2>{app.translator.trans('core.admin.email.heading')}</h2> <h2>{app.translator.trans('core.admin.email.heading')}</h2>
<div className="helpText">{app.translator.trans('core.admin.email.text')}</div> <div className="helpText">{app.translator.trans('core.admin.email.text')}</div>
{FieldSet.component({ {FieldSet.component(
{
label: app.translator.trans('core.admin.email.addresses_heading'), label: app.translator.trans('core.admin.email.addresses_heading'),
className: 'MailPage-MailSettings', className: 'MailPage-MailSettings',
children: [ },
[
<div className="MailPage-MailSettings-input"> <div className="MailPage-MailSettings-input">
<label> <label>
{app.translator.trans('core.admin.email.from_label')} {app.translator.trans('core.admin.email.from_label')}
<input className="FormControl" value={this.values.mail_from() || ''} oninput={m.withAttr('value', this.values.mail_from)} /> <input className="FormControl" bidi={this.values.mail_from} />
</label> </label>
</div>, </div>,
], ]
})} )}
{FieldSet.component({ {FieldSet.component(
{
label: app.translator.trans('core.admin.email.driver_heading'), label: app.translator.trans('core.admin.email.driver_heading'),
className: 'MailPage-MailSettings', className: 'MailPage-MailSettings',
children: [ },
[
<div className="MailPage-MailSettings-input"> <div className="MailPage-MailSettings-input">
<label> <label>
{app.translator.trans('core.admin.email.driver_label')} {app.translator.trans('core.admin.email.driver_label')}
@ -96,20 +101,24 @@ export default class MailPage extends Page {
/> />
</label> </label>
</div>, </div>,
], ]
})} )}
{this.status.sending || {this.status.sending ||
Alert.component({ Alert.component(
children: app.translator.trans('core.admin.email.not_sending_message'), {
dismissible: false, dismissible: false,
})} },
app.translator.trans('core.admin.email.not_sending_message')
)}
{fieldKeys.length > 0 && {fieldKeys.length > 0 &&
FieldSet.component({ FieldSet.component(
{
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`), label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
className: 'MailPage-MailSettings', className: 'MailPage-MailSettings',
children: [ },
[
<div className="MailPage-MailSettings-input"> <div className="MailPage-MailSettings-input">
{fieldKeys.map((field) => [ {fieldKeys.map((field) => [
<label> <label>
@ -119,31 +128,37 @@ export default class MailPage extends Page {
this.status.errors[field] && <p className="ValidationError">{this.status.errors[field]}</p>, this.status.errors[field] && <p className="ValidationError">{this.status.errors[field]}</p>,
])} ])}
</div>, </div>,
], ]
})} )}
<FieldSet> <FieldSet>
{Button.component({ {Button.component(
{
type: 'submit', type: 'submit',
className: 'Button Button--primary', className: 'Button Button--primary',
children: app.translator.trans('core.admin.email.submit_button'),
disabled: !this.changed(), disabled: !this.changed(),
})} },
app.translator.trans('core.admin.email.submit_button')
)}
</FieldSet> </FieldSet>
{FieldSet.component({ {FieldSet.component(
{
label: app.translator.trans('core.admin.email.send_test_mail_heading'), label: app.translator.trans('core.admin.email.send_test_mail_heading'),
className: 'MailPage-MailSettings', className: 'MailPage-MailSettings',
children: [ },
[
<div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user.email() })}</div>, <div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user.email() })}</div>,
Button.component({ Button.component(
{
className: 'Button Button--primary', className: 'Button Button--primary',
children: app.translator.trans('core.admin.email.send_test_mail_button'),
disabled: this.sendingTest || this.changed(), disabled: this.sendingTest || this.changed(),
onclick: () => this.sendTestEmail(), onclick: () => this.sendTestEmail(),
}), },
], app.translator.trans('core.admin.email.send_test_mail_button')
})} ),
]
)}
</form> </form>
</div> </div>
</div> </div>
@ -156,7 +171,7 @@ export default class MailPage extends Page {
const prop = this.values[name]; const prop = this.values[name];
if (typeof field === 'string') { if (typeof field === 'string') {
return <input className="FormControl" value={prop() || ''} oninput={m.withAttr('value', prop)} />; return <input className="FormControl" bidi={prop} />;
} else { } else {
return <Select value={prop()} options={field} onchange={prop} />; return <Select value={prop()} options={field} onchange={prop} />;
} }
@ -179,10 +194,7 @@ export default class MailPage extends Page {
}) })
.then((response) => { .then((response) => {
this.sendingTest = false; this.sendingTest = false;
this.testEmailSuccessAlert = app.alerts.show({ this.testEmailSuccessAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.email.send_test_mail_success'));
type: 'success',
children: app.translator.trans('core.admin.email.send_test_mail_success'),
});
}) })
.catch((error) => { .catch((error) => {
this.sendingTest = false; this.sendingTest = false;
@ -205,10 +217,7 @@ export default class MailPage extends Page {
saveSettings(settings) saveSettings(settings)
.then(() => { .then(() => {
this.successAlert = app.alerts.show({ this.successAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.basics.saved_message'));
type: 'success',
children: app.translator.trans('core.admin.basics.saved_message'),
});
}) })
.catch(() => {}) .catch(() => {})
.then(() => { .then(() => {

View File

@ -32,101 +32,109 @@ function filterByRequiredPermissions(groupIds, permission) {
} }
export default class PermissionDropdown extends Dropdown { export default class PermissionDropdown extends Dropdown {
static initProps(props) { static initAttrs(attrs) {
super.initProps(props); super.initAttrs(attrs);
props.className = 'PermissionDropdown'; attrs.className = 'PermissionDropdown';
props.buttonClassName = 'Button Button--text'; attrs.buttonClassName = 'Button Button--text';
} }
view() { view(vnode) {
this.props.children = []; const children = [];
let groupIds = app.data.permissions[this.props.permission] || []; let groupIds = app.data.permissions[this.attrs.permission] || [];
groupIds = filterByRequiredPermissions(groupIds, this.props.permission); groupIds = filterByRequiredPermissions(groupIds, this.attrs.permission);
const everyone = groupIds.indexOf(Group.GUEST_ID) !== -1; const everyone = groupIds.indexOf(Group.GUEST_ID) !== -1;
const members = groupIds.indexOf(Group.MEMBER_ID) !== -1; const members = groupIds.indexOf(Group.MEMBER_ID) !== -1;
const adminGroup = app.store.getById('groups', Group.ADMINISTRATOR_ID); const adminGroup = app.store.getById('groups', Group.ADMINISTRATOR_ID);
if (everyone) { if (everyone) {
this.props.label = Badge.component({ icon: 'fas fa-globe' }); this.attrs.label = Badge.component({ icon: 'fas fa-globe' });
} else if (members) { } else if (members) {
this.props.label = Badge.component({ icon: 'fas fa-user' }); this.attrs.label = Badge.component({ icon: 'fas fa-user' });
} else { } else {
this.props.label = [badgeForId(Group.ADMINISTRATOR_ID), groupIds.map(badgeForId)]; this.attrs.label = [badgeForId(Group.ADMINISTRATOR_ID), groupIds.map(badgeForId)];
} }
if (this.showing) { if (this.showing) {
if (this.props.allowGuest) { if (this.attrs.allowGuest) {
this.props.children.push( children.push(
Button.component({ Button.component(
children: [Badge.component({ icon: 'fas fa-globe' }), ' ', app.translator.trans('core.admin.permissions_controls.everyone_button')], {
icon: everyone ? 'fas fa-check' : true, icon: everyone ? 'fas fa-check' : true,
onclick: () => this.save([Group.GUEST_ID]), onclick: () => this.save([Group.GUEST_ID]),
disabled: this.isGroupDisabled(Group.GUEST_ID), disabled: this.isGroupDisabled(Group.GUEST_ID),
}) },
[Badge.component({ icon: 'fas fa-globe' }), ' ', app.translator.trans('core.admin.permissions_controls.everyone_button')]
)
); );
} }
this.props.children.push( children.push(
Button.component({ Button.component(
children: [Badge.component({ icon: 'fas fa-user' }), ' ', app.translator.trans('core.admin.permissions_controls.members_button')], {
icon: members ? 'fas fa-check' : true, icon: members ? 'fas fa-check' : true,
onclick: () => this.save([Group.MEMBER_ID]), onclick: () => this.save([Group.MEMBER_ID]),
disabled: this.isGroupDisabled(Group.MEMBER_ID), disabled: this.isGroupDisabled(Group.MEMBER_ID),
}), },
[Badge.component({ icon: 'fas fa-user' }), ' ', app.translator.trans('core.admin.permissions_controls.members_button')]
),
Separator.component(), Separator.component(),
Button.component({ Button.component(
children: [badgeForId(adminGroup.id()), ' ', adminGroup.namePlural()], {
icon: !everyone && !members ? 'fas fa-check' : true, icon: !everyone && !members ? 'fas fa-check' : true,
disabled: !everyone && !members, disabled: !everyone && !members,
onclick: (e) => { onclick: (e) => {
if (e.shiftKey) e.stopPropagation(); if (e.shiftKey) e.stopPropagation();
this.save([]); this.save([]);
}, },
}) },
[badgeForId(adminGroup.id()), ' ', adminGroup.namePlural()]
)
); );
[].push.apply( [].push.apply(
this.props.children, children,
app.store app.store
.all('groups') .all('groups')
.filter((group) => [Group.ADMINISTRATOR_ID, Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1) .filter((group) => [Group.ADMINISTRATOR_ID, Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
.map((group) => .map((group) =>
Button.component({ Button.component(
children: [badgeForId(group.id()), ' ', group.namePlural()], {
icon: groupIds.indexOf(group.id()) !== -1 ? 'fas fa-check' : true, icon: groupIds.indexOf(group.id()) !== -1 ? 'fas fa-check' : true,
onclick: (e) => { onclick: (e) => {
if (e.shiftKey) e.stopPropagation(); if (e.shiftKey) e.stopPropagation();
this.toggle(group.id()); this.toggle(group.id());
}, },
disabled: this.isGroupDisabled(group.id()) && this.isGroupDisabled(Group.MEMBER_ID) && this.isGroupDisabled(Group.GUEST_ID), disabled: this.isGroupDisabled(group.id()) && this.isGroupDisabled(Group.MEMBER_ID) && this.isGroupDisabled(Group.GUEST_ID),
}) },
[badgeForId(group.id()), ' ', group.namePlural()]
)
) )
); );
} }
return super.view(); return super.view({ ...vnode, children });
} }
save(groupIds) { save(groupIds) {
const permission = this.props.permission; const permission = this.attrs.permission;
app.data.permissions[permission] = groupIds; app.data.permissions[permission] = groupIds;
app.request({ app.request({
method: 'POST', method: 'POST',
url: app.forum.attribute('apiUrl') + '/permission', url: app.forum.attribute('apiUrl') + '/permission',
data: { permission, groupIds }, body: { permission, groupIds },
}); });
} }
toggle(groupId) { toggle(groupId) {
const permission = this.props.permission; const permission = this.attrs.permission;
let groupIds = app.data.permissions[permission] || []; let groupIds = app.data.permissions[permission] || [];
@ -143,6 +151,6 @@ export default class PermissionDropdown extends Dropdown {
} }
isGroupDisabled(id) { isGroupDisabled(id) {
return filterByRequiredPermissions([id], this.props.permission).indexOf(id) === -1; return filterByRequiredPermissions([id], this.attrs.permission).indexOf(id) === -1;
} }
} }

View File

@ -6,7 +6,9 @@ import ItemList from '../../common/utils/ItemList';
import icon from '../../common/helpers/icon'; import icon from '../../common/helpers/icon';
export default class PermissionGrid extends Component { export default class PermissionGrid extends Component {
init() { oninit(vnode) {
super.oninit(vnode);
this.permissions = this.permissionItems().toArray(); this.permissions = this.permissionItems().toArray();
} }

View File

@ -9,18 +9,16 @@ import ItemList from '../../common/utils/ItemList';
* avatar/name, with a dropdown of session controls. * avatar/name, with a dropdown of session controls.
*/ */
export default class SessionDropdown extends Dropdown { export default class SessionDropdown extends Dropdown {
static initProps(props) { static initAttrs(attrs) {
super.initProps(props); super.initAttrs(attrs);
props.className = 'SessionDropdown'; attrs.className = 'SessionDropdown';
props.buttonClassName = 'Button Button--user Button--flat'; attrs.buttonClassName = 'Button Button--user Button--flat';
props.menuClassName = 'Dropdown-menu--right'; attrs.menuClassName = 'Dropdown-menu--right';
} }
view() { view(vnode) {
this.props.children = this.items().toArray(); return super.view({ ...vnode, children: this.items().toArray() });
return super.view();
} }
getButtonContent() { getButtonContent() {
@ -39,11 +37,13 @@ export default class SessionDropdown extends Dropdown {
items.add( items.add(
'logOut', 'logOut',
Button.component({ Button.component(
{
icon: 'fas fa-sign-out-alt', icon: 'fas fa-sign-out-alt',
children: app.translator.trans('core.admin.header.log_out_button'),
onclick: app.session.logout.bind(app.session), onclick: app.session.logout.bind(app.session),
}), },
app.translator.trans('core.admin.header.log_out_button')
),
-100 -100
); );

View File

@ -3,23 +3,30 @@ import Button from '../../common/components/Button';
import saveSettings from '../utils/saveSettings'; import saveSettings from '../utils/saveSettings';
export default class SettingDropdown extends SelectDropdown { export default class SettingDropdown extends SelectDropdown {
static initProps(props) { static initAttrs(attrs) {
super.initProps(props); super.initAttrs(attrs);
props.className = 'SettingDropdown'; attrs.className = 'SettingDropdown';
props.buttonClassName = 'Button Button--text'; attrs.buttonClassName = 'Button Button--text';
props.caretIcon = 'fas fa-caret-down'; attrs.caretIcon = 'fas fa-caret-down';
props.defaultLabel = 'Custom'; attrs.defaultLabel = 'Custom';
}
props.children = props.options.map(({ value, label }) => { view(vnode) {
const active = app.data.settings[props.key] === value; return super.view({
...vnode,
children: this.attrs.options.map(({ value, label }) => {
const active = app.data.settings[this.attrs.key] === value;
return Button.component({ return Button.component(
children: label, {
icon: active ? 'fas fa-check' : true, icon: active ? 'fas fa-check' : true,
onclick: saveSettings.bind(this, { [props.key]: value }), onclick: saveSettings.bind(this, { [this.attrs.key]: value }),
active, active,
}); },
label
);
}),
}); });
} }
} }

View File

@ -3,7 +3,9 @@ import Button from '../../common/components/Button';
import saveSettings from '../utils/saveSettings'; import saveSettings from '../utils/saveSettings';
export default class SettingsModal extends Modal { export default class SettingsModal extends Modal {
init() { oninit(vnode) {
super.oninit(vnode);
this.settings = {}; this.settings = {};
this.loading = false; this.loading = false;
} }
@ -33,7 +35,7 @@ export default class SettingsModal extends Modal {
} }
setting(key, fallback = '') { setting(key, fallback = '') {
this.settings[key] = this.settings[key] || m.prop(app.data.settings[key] || fallback); this.settings[key] = this.settings[key] || m.stream(app.data.settings[key] || fallback);
return this.settings[key]; return this.settings[key];
} }

View File

@ -1,32 +1,28 @@
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
export default class UploadImageButton extends Button { export default class UploadImageButton extends Button {
init() { loading = false;
this.loading = false;
}
view() { view(vnode) {
this.props.loading = this.loading; this.attrs.loading = this.loading;
this.props.className = (this.props.className || '') + ' Button'; this.attrs.className = (this.attrs.className || '') + ' Button';
if (app.data.settings[this.props.name + '_path']) { if (app.data.settings[this.attrs.name + '_path']) {
this.props.onclick = this.remove.bind(this); this.attrs.onclick = this.remove.bind(this);
this.props.children = app.translator.trans('core.admin.upload_image.remove_button');
return ( return (
<div> <div>
<p> <p>
<img src={app.forum.attribute(this.props.name + 'Url')} alt="" /> <img src={app.forum.attribute(this.attrs.name + 'Url')} alt="" />
</p> </p>
<p>{super.view()}</p> <p>{super.view({ ...vnode, children: app.translator.trans('core.admin.upload_image.remove_button') })}</p>
</div> </div>
); );
} else { } else {
this.props.onclick = this.upload.bind(this); this.attrs.onclick = this.upload.bind(this);
this.props.children = app.translator.trans('core.admin.upload_image.upload_button');
} }
return super.view(); return super.view({ ...vnode, children: app.translator.trans('core.admin.upload_image.upload_button') });
} }
/** /**
@ -42,8 +38,8 @@ export default class UploadImageButton extends Button {
.hide() .hide()
.click() .click()
.on('change', (e) => { .on('change', (e) => {
const data = new FormData(); const body = new FormData();
data.append(this.props.name, $(e.target)[0].files[0]); body.append(this.attrs.name, $(e.target)[0].files[0]);
this.loading = true; this.loading = true;
m.redraw(); m.redraw();
@ -53,7 +49,7 @@ export default class UploadImageButton extends Button {
method: 'POST', method: 'POST',
url: this.resourceUrl(), url: this.resourceUrl(),
serialize: (raw) => raw, serialize: (raw) => raw,
data, body,
}) })
.then(this.success.bind(this), this.failure.bind(this)); .then(this.success.bind(this), this.failure.bind(this));
}); });
@ -75,7 +71,7 @@ export default class UploadImageButton extends Button {
} }
resourceUrl() { resourceUrl() {
return app.forum.attribute('apiUrl') + '/' + this.props.name; return app.forum.attribute('apiUrl') + '/' + this.attrs.name;
} }
/** /**

View File

@ -12,11 +12,11 @@ import MailPage from './components/MailPage';
*/ */
export default function (app) { export default function (app) {
app.routes = { app.routes = {
dashboard: { path: '/', component: DashboardPage.component() }, dashboard: { path: '/', component: DashboardPage },
basics: { path: '/basics', component: BasicsPage.component() }, basics: { path: '/basics', component: BasicsPage },
permissions: { path: '/permissions', component: PermissionsPage.component() }, permissions: { path: '/permissions', component: PermissionsPage },
appearance: { path: '/appearance', component: AppearancePage.component() }, appearance: { path: '/appearance', component: AppearancePage },
extensions: { path: '/extensions', component: ExtensionsPage.component() }, extensions: { path: '/extensions', component: ExtensionsPage },
mail: { path: '/mail', component: MailPage.component() }, mail: { path: '/mail', component: MailPage },
}; };
} }

View File

@ -7,7 +7,7 @@ export default function saveSettings(settings) {
.request({ .request({
method: 'POST', method: 'POST',
url: app.forum.attribute('apiUrl') + '/settings', url: app.forum.attribute('apiUrl') + '/settings',
data: settings, body: settings,
}) })
.catch((error) => { .catch((error) => {
app.data.settings = oldSettings; app.data.settings = oldSettings;

View File

@ -189,8 +189,9 @@ export default class Application {
} }
mount(basePath = '') { mount(basePath = '') {
m.mount(document.getElementById('modal'), <ModalManager state={this.modal} />); // An object with a callable view property is used in order to pass arguments to the component; see https://mithril.js.org/mount.html
m.mount(document.getElementById('alerts'), <AlertManager state={this.alerts} />); m.mount(document.getElementById('modal'), { view: () => ModalManager.component({ state: this.modal }) });
m.mount(document.getElementById('alerts'), { view: () => AlertManager.component({ state: this.alerts }) });
this.drawer = new Drawer(); this.drawer = new Drawer();
@ -263,7 +264,7 @@ export default class Application {
updateTitle() { updateTitle() {
const count = this.titleCount ? `(${this.titleCount}) ` : ''; const count = this.titleCount ? `(${this.titleCount}) ` : '';
const pageTitleWithSeparator = this.title && m.route() !== '/' ? this.title + ' - ' : ''; const pageTitleWithSeparator = this.title && m.route.get() !== '/' ? this.title + ' - ' : '';
const title = this.forum.attribute('title'); const title = this.forum.attribute('title');
document.title = count + pageTitleWithSeparator + title; document.title = count + pageTitleWithSeparator + title;
} }
@ -342,16 +343,14 @@ export default class Application {
// Now make the request. If it's a failure, inspect the error that was // Now make the request. If it's a failure, inspect the error that was
// returned and show an alert containing its contents. // returned and show an alert containing its contents.
const deferred = m.deferred(); return m.request(options).then(
(response) => response,
m.request(options).then(
(response) => deferred.resolve(response),
(error) => { (error) => {
let children; let content;
switch (error.status) { switch (error.status) {
case 422: case 422:
children = error.response.errors content = error.response.errors
.map((error) => [error.detail, <br />]) .map((error) => [error.detail, <br />])
.reduce((a, b) => a.concat(b), []) .reduce((a, b) => a.concat(b), [])
.slice(0, -1); .slice(0, -1);
@ -359,30 +358,31 @@ export default class Application {
case 401: case 401:
case 403: case 403:
children = app.translator.trans('core.lib.error.permission_denied_message'); content = app.translator.trans('core.lib.error.permission_denied_message');
break; break;
case 404: case 404:
case 410: case 410:
children = app.translator.trans('core.lib.error.not_found_message'); content = app.translator.trans('core.lib.error.not_found_message');
break; break;
case 429: case 429:
children = app.translator.trans('core.lib.error.rate_limit_exceeded_message'); content = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
break; break;
default: default:
children = app.translator.trans('core.lib.error.generic_message'); content = app.translator.trans('core.lib.error.generic_message');
} }
const isDebug = app.forum.attribute('debug'); const isDebug = app.forum.attribute('debug');
// contains a formatted errors if possible, response must be an JSON API array of errors // contains a formatted errors if possible, response must be an JSON API array of errors
// the details property is decoded to transform escaped characters such as '\n' // the details property is decoded to transform escaped characters such as '\n'
const formattedError = error.response && Array.isArray(error.response.errors) && error.response.errors.map((e) => decodeURI(e.detail)); const errors = error.response && error.response.errors;
const formattedError = Array.isArray(errors) && errors[0] && errors[0].detail && errors.map((e) => decodeURI(e.detail));
error.alert = { error.alert = {
type: 'error', type: 'error',
children, content,
controls: isDebug && [ controls: isDebug && [
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error, formattedError)}> <Button className="Button Button--link" onclick={this.showDebug.bind(this, error, formattedError)}>
Debug Debug
@ -404,14 +404,12 @@ export default class Application {
console.groupEnd(); console.groupEnd();
} }
this.requestErrorAlert = this.alerts.show(error.alert); this.requestErrorAlert = this.alerts.show(error.alert, error.alert.content);
} }
deferred.reject(error); return Promise.reject(error);
} }
); );
return deferred.promise;
} }
/** /**
@ -434,9 +432,19 @@ export default class Application {
* @public * @public
*/ */
route(name, params = {}) { route(name, params = {}) {
const url = this.routes[name].path.replace(/:([^\/]+)/g, (m, key) => extract(params, key)); const route = this.routes[name];
const queryString = m.route.buildQueryString(params);
const prefix = m.route.mode === 'pathname' ? app.forum.attribute('basePath') : ''; if (!route) throw new Error(`Route '${name}' does not exist`);
const url = route.path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
// Remove falsy values in params to avoid having urls like '/?sort&q'
for (const key in params) {
if (params.hasOwnProperty(key) && !params[key]) delete params[key];
}
const queryString = m.buildQueryString(params);
const prefix = m.route.prefix === '' ? this.forum.attribute('basePath') : '';
return prefix + url + (queryString ? '?' + queryString : ''); return prefix + url + (queryString ? '?' + queryString : '');
} }

View File

@ -1,221 +0,0 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
/**
* The `Component` class defines a user interface 'building block'. A component
* can generate a virtual DOM to be rendered on each redraw.
*
* An instance's virtual DOM can be retrieved directly using the {@link
* Component#render} method.
*
* @example
* this.myComponentInstance = new MyComponent({foo: 'bar'});
* return m('div', this.myComponentInstance.render());
*
* Alternatively, components can be nested, letting Mithril take care of
* instance persistence. For this, the static {@link Component.component} method
* can be used.
*
* @example
* return m('div', MyComponent.component({foo: 'bar'));
*
* @see https://lhorie.github.io/mithril/mithril.component.html
* @abstract
*/
export default class Component {
/**
* @param {Object} props
* @param {Array|Object} children
* @public
*/
constructor(props = {}, children = null) {
if (children) props.children = children;
this.constructor.initProps(props);
/**
* The properties passed into the component.
*
* @type {Object}
*/
this.props = props;
/**
* The root DOM element for the component.
*
* @type DOMElement
* @public
*/
this.element = null;
/**
* Whether or not to retain the component's subtree on redraw.
*
* @type {boolean}
* @public
*/
this.retain = false;
this.init();
}
/**
* Called when the component is constructed.
*
* @protected
*/
init() {}
/**
* Called when the component is destroyed, i.e. after a redraw where it is no
* longer a part of the view.
*
* @see https://lhorie.github.io/mithril/mithril.component.html#unloading-components
* @param {Object} e
* @public
*/
onunload() {}
/**
* Get the renderable virtual DOM that represents the component's view.
*
* This should NOT be overridden by subclasses. Subclasses wishing to define
* their virtual DOM should override Component#view instead.
*
* @example
* this.myComponentInstance = new MyComponent({foo: 'bar'});
* return m('div', this.myComponentInstance.render());
*
* @returns {Object}
* @final
* @public
*/
render() {
const vdom = this.retain ? { subtree: 'retain' } : this.view();
// Override the root element's config attribute with our own function, which
// will set the component instance's element property to the root DOM
// element, and then run the component class' config method.
vdom.attrs = vdom.attrs || {};
const originalConfig = vdom.attrs.config;
vdom.attrs.config = (...args) => {
this.element = args[0];
this.config.apply(this, args.slice(1));
if (originalConfig) originalConfig.apply(this, args);
};
return vdom;
}
/**
* Returns a jQuery object for this component's element. If you pass in a
* selector string, this method will return a jQuery object, using the current
* element as its buffer.
*
* For example, calling `component.$('li')` will return a jQuery object
* containing all of the `li` elements inside the DOM element of this
* component.
*
* @param {String} [selector] a jQuery-compatible selector string
* @returns {jQuery} the jQuery object for the DOM node
* @final
* @public
*/
$(selector) {
const $element = $(this.element);
return selector ? $element.find(selector) : $element;
}
/**
* Called after the component's root element is redrawn. This hook can be used
* to perform any actions on the DOM, both on the initial draw and any
* subsequent redraws. See Mithril's documentation for more information.
*
* @see https://lhorie.github.io/mithril/mithril.html#the-config-attribute
* @param {Boolean} isInitialized
* @param {Object} context
* @param {Object} vdom
* @public
*/
config() {}
/**
* Get the virtual DOM that represents the component's view.
*
* @return {Object} The virtual DOM
* @protected
*/
view() {
throw new Error('Component#view must be implemented by subclass');
}
/**
* Get a Mithril component object for this component, preloaded with props.
*
* @see https://lhorie.github.io/mithril/mithril.component.html
* @param {Object} [props] Properties to set on the component
* @param children
* @return {Object} The Mithril component object
* @property {function} controller
* @property {function} view
* @property {Object} component The class of this component
* @property {Object} props The props that were passed to the component
* @public
*/
static component(props = {}, children = null) {
const componentProps = Object.assign({}, props);
if (children) componentProps.children = children;
this.initProps(componentProps);
// Set up a function for Mithril to get the component's view. It will accept
// the component's controller (which happens to be the component itself, in
// our case), update its props with the ones supplied, and then render the view.
const view = (component) => {
component.props = componentProps;
return component.render();
};
// Mithril uses this property on the view function to cache component
// controllers between redraws, thus persisting component state.
view.$original = this.prototype.view;
// Our output object consists of a controller constructor + a view function
// which Mithril will use to instantiate and render the component. We also
// attach a reference to the props that were passed through and the
// component's class for reference.
const output = {
controller: this.bind(undefined, componentProps),
view: view,
props: componentProps,
component: this,
};
// If a `key` prop was set, then we'll assume that we want that to actually
// show up as an attribute on the component object so that Mithril's key
// algorithm can be applied.
if (componentProps.key) {
output.attrs = { key: componentProps.key };
}
return output;
}
/**
* Initialize the component's props.
*
* @param {Object} props
* @public
*/
static initProps(props) {}
}

136
js/src/common/Component.ts Normal file
View File

@ -0,0 +1,136 @@
import * as Mithril from 'mithril';
export type ComponentAttrs = {
className?: string;
[key: string]: any;
};
/**
* The `Component` class defines a user interface 'building block'. A component
* generates a virtual DOM to be rendered on each redraw.
*
* Essentially, this is a wrapper for Mithril's components that adds several useful features:
*
* - In the `oninit` and `onbeforeupdate` lifecycle hooks, we store vnode attrs in `this.attrs.
* This allows us to use attrs across components without having to pass the vnode to every single
* method.
* - The static `initAttrs` method allows a convenient way to provide defaults (or to otherwise modify)
* the attrs that have been passed into a component.
* - When the component is created in the DOM, we store its DOM element under `this.element`; this lets
* us use jQuery to modify child DOM state from internal methods via the `this.$()` method.
* - A convenience `component` method, which serves as an alternative to hyperscript and JSX.
*
* As with other Mithril components, components extending Component can be initialized
* and nested using JSX, hyperscript, or a combination of both. The `component` method can also
* be used.
*
* @example
* return m('div', <MyComponent foo="bar"><p>Hello World</p></MyComponent>);
*
* @example
* return m('div', MyComponent.component({foo: 'bar'), m('p', 'Hello World!'));
*
* @see https://mithril.js.org/components.html
*/
export default abstract class Component<T extends ComponentAttrs = any> implements Mithril.ClassComponent<T> {
/**
* The root DOM element for the component.
*/
protected element!: Element;
/**
* The attributes passed into the component.
*
* @see https://mithril.js.org/components.html#passing-data-to-components
*/
protected attrs!: T;
/**
* @inheritdoc
*/
abstract view(vnode: Mithril.Vnode<T, this>): Mithril.Children;
/**
* @inheritdoc
*/
oninit(vnode: Mithril.Vnode<T, this>) {
this.setAttrs(vnode.attrs);
}
/**
* @inheritdoc
*/
oncreate(vnode: Mithril.VnodeDOM<T, this>) {
this.element = vnode.dom;
}
/**
* @inheritdoc
*/
onbeforeupdate(vnode: Mithril.VnodeDOM<T, this>) {
this.setAttrs(vnode.attrs);
}
/**
* Returns a jQuery object for this component's element. If you pass in a
* selector string, this method will return a jQuery object, using the current
* element as its buffer.
*
* For example, calling `component.$('li')` will return a jQuery object
* containing all of the `li` elements inside the DOM element of this
* component.
*
* @param {String} [selector] a jQuery-compatible selector string
* @returns {jQuery} the jQuery object for the DOM node
* @final
*/
protected $(selector) {
const $element = $(this.element);
return selector ? $element.find(selector) : $element;
}
/**
* Convenience method to attach a component without JSX.
* Has the same effect as calling `m(THIS_CLASS, attrs, children)`.
*
* @see https://mithril.js.org/hyperscript.html#mselector,-attributes,-children
*/
static component(attrs = {}, children = null): Mithril.Vnode {
const componentAttrs = Object.assign({}, attrs);
return m(this as any, componentAttrs, children);
}
/**
* Saves a reference to the vnode attrs after running them through initAttrs,
* and checking for common issues.
*/
private setAttrs(attrs: T = {} as T): void {
(this.constructor as typeof Component).initAttrs(attrs);
if (attrs) {
if ('children' in attrs) {
throw new Error(
`[${
(this.constructor as any).name
}] The "children" attribute of attrs should never be used. Either pass children in as the vnode children or rename the attribute`
);
}
if ('tag' in attrs) {
throw new Error(`[${(this.constructor as any).name}] You cannot use the "tag" attribute name with Mithril 2.`);
}
}
this.attrs = attrs;
}
/**
* Initialize the component's attrs.
*
* This can be used to assign default values for missing, optional attrs.
*/
protected static initAttrs<T>(attrs: T): void {}
}

74
js/src/common/Fragment.ts Normal file
View File

@ -0,0 +1,74 @@
import * as Mithril from 'mithril';
/**
* The `Fragment` class represents a chunk of DOM that is rendered once with Mithril and then takes
* over control of its own DOM and lifecycle.
*
* This is very similar to the `Component` wrapper class, but is used for more fine-grained control over
* the rendering and display of some significant chunks of the DOM. In contrast to components, fragments
* do not offer Mithril's lifecycle hooks.
*
* Use this when you want to enjoy the benefits of JSX / VDOM for initial rendering, combined with
* small helper methods that then make updates to that DOM directly, instead of fully redrawing
* everything through Mithril.
*
* This should only be used when necessary, and only with `m.render`. If you are unsure whether you need
* this or `Component, you probably need `Component`.
*/
export default abstract class Fragment {
/**
* The root DOM element for the fragment.
*/
protected element!: Element;
/**
* Returns a jQuery object for this fragment's element. If you pass in a
* selector string, this method will return a jQuery object, using the current
* element as its buffer.
*
* For example, calling `fragment.$('li')` will return a jQuery object
* containing all of the `li` elements inside the DOM element of this
* fragment.
*
* @param {String} [selector] a jQuery-compatible selector string
* @returns {jQuery} the jQuery object for the DOM node
* @final
*/
public $(selector) {
const $element = $(this.element);
return selector ? $element.find(selector) : $element;
}
/**
* Get the renderable virtual DOM that represents the fragment's view.
*
* This should NOT be overridden by subclasses. Subclasses wishing to define
* their virtual DOM should override Fragment#view instead.
*
* @example
* const fragment = new MyFragment();
* m.render(document.body, fragment.render());
*
* @final
*/
public render(): Mithril.Vnode<Mithril.Attributes, this> {
const vdom = this.view();
vdom.attrs = vdom.attrs || {};
const originalOnCreate = vdom.attrs.oncreate;
vdom.attrs.oncreate = (vnode) => {
this.element = vnode.dom;
if (originalOnCreate) originalOnCreate.apply(this, [vnode]);
};
return vdom;
}
/**
* Creates a view out of virtual elements.
*/
abstract view(): Mithril.Vnode<Mithril.Attributes, this>;
}

View File

@ -161,7 +161,7 @@ export default class Model {
{ {
method: this.exists ? 'PATCH' : 'POST', method: this.exists ? 'PATCH' : 'POST',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(), url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
data: request, body: request,
}, },
options options
) )
@ -180,7 +180,7 @@ export default class Model {
// old data! We'll revert to that and let others handle the error. // old data! We'll revert to that and let others handle the error.
(response) => { (response) => {
this.pushData(oldData); this.pushData(oldData);
m.lazyRedraw(); m.redraw();
throw response; throw response;
} }
); );
@ -189,13 +189,13 @@ export default class Model {
/** /**
* Send a request to delete the resource. * Send a request to delete the resource.
* *
* @param {Object} data Data to send along with the DELETE request. * @param {Object} body Data to send along with the DELETE request.
* @param {Object} [options] * @param {Object} [options]
* @return {Promise} * @return {Promise}
* @public * @public
*/ */
delete(data, options = {}) { delete(body, options = {}) {
if (!this.exists) return m.deferred().resolve().promise; if (!this.exists) return Promise.resolve();
return app return app
.request( .request(
@ -203,7 +203,7 @@ export default class Model {
{ {
method: 'DELETE', method: 'DELETE',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(), url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
data, body,
}, },
options options
) )

View File

@ -30,13 +30,13 @@ export default class Session {
* @return {Promise} * @return {Promise}
* @public * @public
*/ */
login(data, options = {}) { login(body, options = {}) {
return app.request( return app.request(
Object.assign( Object.assign(
{ {
method: 'POST', method: 'POST',
url: app.forum.attribute('baseUrl') + '/login', url: `${app.forum.attribute('baseUrl')}/login`,
data, body,
}, },
options options
) )
@ -49,6 +49,6 @@ export default class Session {
* @public * @public
*/ */
logout() { logout() {
window.location = app.forum.attribute('baseUrl') + '/logout?token=' + this.csrfToken; window.location = `${app.forum.attribute('baseUrl')}/logout?token=${this.csrfToken}`;
} }
} }

View File

@ -82,13 +82,13 @@ export default class Store {
* @public * @public
*/ */
find(type, id, query = {}, options = {}) { find(type, id, query = {}, options = {}) {
let data = query; let params = query;
let url = app.forum.attribute('apiUrl') + '/' + type; let url = app.forum.attribute('apiUrl') + '/' + type;
if (id instanceof Array) { if (id instanceof Array) {
url += '?filter[id]=' + id.join(','); url += '?filter[id]=' + id.join(',');
} else if (typeof id === 'object') { } else if (typeof id === 'object') {
data = id; params = id;
} else if (id) { } else if (id) {
url += '/' + id; url += '/' + id;
} }
@ -99,7 +99,7 @@ export default class Store {
{ {
method: 'GET', method: 'GET',
url, url,
data, params,
}, },
options options
) )

View File

@ -1,4 +1,3 @@
import User from './models/User';
import username from './helpers/username'; import username from './helpers/username';
import extract from './utils/extract'; import extract from './utils/extract';
@ -71,18 +70,34 @@ export default class Translator {
const match = part.match(new RegExp('{([a-z0-9_]+)}|<(/?)([a-z0-9_]+)>', 'i')); const match = part.match(new RegExp('{([a-z0-9_]+)}|<(/?)([a-z0-9_]+)>', 'i'));
if (match) { if (match) {
// Either an opening or closing tag.
if (match[1]) { if (match[1]) {
open[0].push(input[match[1]]); open[0].push(input[match[1]]);
} else if (match[3]) { } else if (match[3]) {
if (match[2]) { if (match[2]) {
// Closing tag. We start by removing all raw children (generally in the form of strings) from the temporary
// holding array, then run them through m.fragment to convert them to vnodes. Usually this will just give us a
// text vnode, but using m.fragment as opposed to an explicit conversion should be more flexible. This is necessary because
// otherwise, our generated vnode will have raw strings as its children, and mithril expects vnodes.
// Finally, we add the now-processed vnodes back onto the holding array (which is the same object in memory as the
// children array of the vnode we are currently processing), and remove the reference to the holding array so that
// further text will be added to the full set of returned elements.
const rawChildren = open[0].splice(0, open[0].length);
open[0].push(...m.fragment(rawChildren).children);
open.shift(); open.shift();
} else { } else {
// If a vnode with a matching tag was provided in the translator input, we use that. Otherwise, we create a new vnode
// with this tag, and an empty children array (since we're expecting to insert children, as that's the point of having this in translator)
let tag = input[match[3]] || { tag: match[3], children: [] }; let tag = input[match[3]] || { tag: match[3], children: [] };
open[0].push(tag); open[0].push(tag);
// Insert the tag's children array as the first element of open, so that text in between the opening
// and closing tags will be added to the tag's children, not to the full set of returned elements.
open.unshift(tag.children || tag); open.unshift(tag.children || tag);
} }
} }
} else { } else {
// Not an html tag, we add it to open[0], which is either the full set of returned elements (vnodes and text),
// or if an html tag is currently being processed, the children attribute of that html tag's vnode.
open[0].push(part); open[0].push(part);
} }
}); });

View File

@ -13,6 +13,7 @@ import RequestError from './utils/RequestError';
import abbreviateNumber from './utils/abbreviateNumber'; import abbreviateNumber from './utils/abbreviateNumber';
import * as string from './utils/string'; import * as string from './utils/string';
import SubtreeRetainer from './utils/SubtreeRetainer'; import SubtreeRetainer from './utils/SubtreeRetainer';
import setRouteWithForcedRefresh from './utils/setRouteWithForcedRefresh';
import extract from './utils/extract'; import extract from './utils/extract';
import ScrollListener from './utils/ScrollListener'; import ScrollListener from './utils/ScrollListener';
import stringToColor from './utils/stringToColor'; import stringToColor from './utils/stringToColor';
@ -22,6 +23,7 @@ import classList from './utils/classList';
import extractText from './utils/extractText'; import extractText from './utils/extractText';
import formatNumber from './utils/formatNumber'; import formatNumber from './utils/formatNumber';
import mapRoutes from './utils/mapRoutes'; import mapRoutes from './utils/mapRoutes';
import withAttr from './utils/withAttr';
import Notification from './models/Notification'; import Notification from './models/Notification';
import User from './models/User'; import User from './models/User';
import Post from './models/Post'; import Post from './models/Post';
@ -62,6 +64,7 @@ import highlight from './helpers/highlight';
import username from './helpers/username'; import username from './helpers/username';
import userOnline from './helpers/userOnline'; import userOnline from './helpers/userOnline';
import listItems from './helpers/listItems'; import listItems from './helpers/listItems';
import Fragment from './Fragment';
export default { export default {
extend: extend, extend: extend,
@ -83,11 +86,13 @@ export default {
'utils/ScrollListener': ScrollListener, 'utils/ScrollListener': ScrollListener,
'utils/stringToColor': stringToColor, 'utils/stringToColor': stringToColor,
'utils/subclassOf': subclassOf, 'utils/subclassOf': subclassOf,
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
'utils/patchMithril': patchMithril, 'utils/patchMithril': patchMithril,
'utils/classList': classList, 'utils/classList': classList,
'utils/extractText': extractText, 'utils/extractText': extractText,
'utils/formatNumber': formatNumber, 'utils/formatNumber': formatNumber,
'utils/mapRoutes': mapRoutes, 'utils/mapRoutes': mapRoutes,
'utils/withAttr': withAttr,
'models/Notification': Notification, 'models/Notification': Notification,
'models/User': User, 'models/User': User,
'models/Post': Post, 'models/Post': Post,
@ -95,6 +100,7 @@ export default {
'models/Group': Group, 'models/Group': Group,
'models/Forum': Forum, 'models/Forum': Forum,
Component: Component, Component: Component,
Fragment: Fragment,
Translator: Translator, Translator: Translator,
'components/AlertManager': AlertManager, 'components/AlertManager': AlertManager,
'components/Page': Page, 'components/Page': Page,

View File

@ -7,7 +7,7 @@ import extract from '../utils/extract';
* The `Alert` component represents an alert box, which contains a message, * The `Alert` component represents an alert box, which contains a message,
* some controls, and may be dismissible. * some controls, and may be dismissible.
* *
* The alert may have the following special props: * ### Attrs
* *
* - `type` The type of alert this is. Will be used to give the alert a class * - `type` The type of alert this is. Will be used to give the alert a class
* name of `Alert--{type}`. * name of `Alert--{type}`.
@ -15,16 +15,16 @@ import extract from '../utils/extract';
* - `dismissible` Whether or not the alert can be dismissed. * - `dismissible` Whether or not the alert can be dismissed.
* - `ondismiss` A callback to run when the alert is dismissed. * - `ondismiss` A callback to run when the alert is dismissed.
* *
* All other props will be assigned as attributes on the alert element. * All other attrs will be assigned as attributes on the DOM element.
*/ */
export default class Alert extends Component { export default class Alert extends Component {
view() { view(vnode) {
const attrs = Object.assign({}, this.props); const attrs = Object.assign({}, this.attrs);
const type = extract(attrs, 'type'); const type = extract(attrs, 'type');
attrs.className = 'Alert Alert--' + type + ' ' + (attrs.className || ''); attrs.className = 'Alert Alert--' + type + ' ' + (attrs.className || '');
const children = extract(attrs, 'children'); const content = extract(attrs, 'content') || vnode.children;
const controls = extract(attrs, 'controls') || []; const controls = extract(attrs, 'controls') || [];
// If the alert is meant to be dismissible (which is the case by default), // If the alert is meant to be dismissible (which is the case by default),
@ -40,7 +40,7 @@ export default class Alert extends Component {
return ( return (
<div {...attrs}> <div {...attrs}>
<span className="Alert-body">{children}</span> <span className="Alert-body">{content}</span>
<ul className="Alert-controls">{listItems(controls.concat(dismissControl))}</ul> <ul className="Alert-controls">{listItems(controls.concat(dismissControl))}</ul>
</div> </div>
); );

View File

@ -6,8 +6,10 @@ import Alert from './Alert';
* be shown and dismissed. * be shown and dismissed.
*/ */
export default class AlertManager extends Component { export default class AlertManager extends Component {
init() { oninit(vnode) {
this.state = this.props.state; super.oninit(vnode);
this.state = this.attrs.state;
} }
view() { view() {
@ -15,17 +17,12 @@ export default class AlertManager extends Component {
<div className="AlertManager"> <div className="AlertManager">
{Object.entries(this.state.getActiveAlerts()).map(([key, alert]) => ( {Object.entries(this.state.getActiveAlerts()).map(([key, alert]) => (
<div className="AlertManager-alert"> <div className="AlertManager-alert">
{(alert.componentClass || Alert).component({ ...alert.attrs, ondismiss: this.state.dismiss.bind(this.state, key) })} <alert.componentClass {...alert.attrs} ondismiss={this.state.dismiss.bind(this.state, key)}>
{alert.children}
</alert.componentClass>
</div> </div>
))} ))}
</div> </div>
); );
} }
config(isInitialized, context) {
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
}
} }

View File

@ -6,18 +6,18 @@ import extract from '../utils/extract';
* The `Badge` component represents a user/discussion badge, indicating some * The `Badge` component represents a user/discussion badge, indicating some
* status (e.g. a discussion is stickied, a user is an admin). * status (e.g. a discussion is stickied, a user is an admin).
* *
* A badge may have the following special props: * A badge may have the following special attrs:
* *
* - `type` The type of badge this is. This will be used to give the badge a * - `type` The type of badge this is. This will be used to give the badge a
* class name of `Badge--{type}`. * class name of `Badge--{type}`.
* - `icon` The name of an icon to show inside the badge. * - `icon` The name of an icon to show inside the badge.
* - `label` * - `label`
* *
* All other props will be assigned as attributes on the badge element. * All other attrs will be assigned as attributes on the badge element.
*/ */
export default class Badge extends Component { export default class Badge extends Component {
view() { view() {
const attrs = Object.assign({}, this.props); const attrs = Object.assign({}, this.attrs);
const type = extract(attrs, 'type'); const type = extract(attrs, 'type');
const iconName = extract(attrs, 'icon'); const iconName = extract(attrs, 'icon');
@ -27,9 +27,9 @@ export default class Badge extends Component {
return <span {...attrs}>{iconName ? icon(iconName, { className: 'Badge-icon' }) : m.trust('&nbsp;')}</span>; return <span {...attrs}>{iconName ? icon(iconName, { className: 'Badge-icon' }) : m.trust('&nbsp;')}</span>;
} }
config(isInitialized) { oncreate(vnode) {
if (isInitialized) return; super.oncreate(vnode);
if (this.props.label) this.$().tooltip(); if (this.attrs.label) this.$().tooltip();
} }
} }

View File

@ -1,12 +1,15 @@
import Component from '../Component'; import Component from '../Component';
import icon from '../helpers/icon'; import icon from '../helpers/icon';
import classList from '../utils/classList';
import extract from '../utils/extract'; import extract from '../utils/extract';
import extractText from '../utils/extractText'; import extractText from '../utils/extractText';
import LoadingIndicator from './LoadingIndicator'; import LoadingIndicator from './LoadingIndicator';
/** /**
* The `Button` component defines an element which, when clicked, performs an * The `Button` component defines an element which, when clicked, performs an
* action. The button may have the following special props: * action.
*
* ### Attrs
* *
* - `icon` The name of the icon class. If specified, the button will be given a * - `icon` The name of the icon class. If specified, the button will be given a
* 'has-icon' class name. * 'has-icon' class name.
@ -15,41 +18,38 @@ import LoadingIndicator from './LoadingIndicator';
* removed. * removed.
* - `loading` Whether or not the button should be in a disabled loading state. * - `loading` Whether or not the button should be in a disabled loading state.
* *
* All other props will be assigned as attributes on the button element. * All other attrs will be assigned as attributes on the button element.
* *
* Note that a Button has no default class names. This is because a Button can * Note that a Button has no default class names. This is because a Button can
* be used to represent any generic clickable control, like a menu item. * be used to represent any generic clickable control, like a menu item.
*/ */
export default class Button extends Component { export default class Button extends Component {
view() { view(vnode) {
const attrs = Object.assign({}, this.props); const attrs = Object.assign({}, this.attrs);
delete attrs.children;
attrs.className = attrs.className || '';
attrs.type = attrs.type || 'button'; attrs.type = attrs.type || 'button';
// If a tooltip was provided for buttons without additional content, we also // If a tooltip was provided for buttons without additional content, we also
// use this tooltip as text for screen readers // use this tooltip as text for screen readers
if (attrs.title && !this.props.children) { if (attrs.title && !vnode.children) {
attrs['aria-label'] = attrs.title; attrs['aria-label'] = attrs.title;
} }
// If nothing else is provided, we use the textual button content as tooltip // If nothing else is provided, we use the textual button content as tooltip
if (!attrs.title && this.props.children) { if (!attrs.title && vnode.children) {
attrs.title = extractText(this.props.children); attrs.title = extractText(vnode.children);
} }
const iconName = extract(attrs, 'icon'); const iconName = extract(attrs, 'icon');
if (iconName) attrs.className += ' hasIcon';
const loading = extract(attrs, 'loading'); const loading = extract(attrs, 'loading');
if (attrs.disabled || loading) { if (attrs.disabled || loading) {
attrs.className += ' disabled' + (loading ? ' loading' : '');
delete attrs.onclick; delete attrs.onclick;
} }
return <button {...attrs}>{this.getButtonContent()}</button>; attrs.className = classList([attrs.className, iconName && 'hasIcon', (attrs.disabled || loading) && 'disabled', loading && 'loading']);
return <button {...attrs}>{this.getButtonContent(vnode.children)}</button>;
} }
/** /**
@ -58,13 +58,13 @@ export default class Button extends Component {
* @return {*} * @return {*}
* @protected * @protected
*/ */
getButtonContent() { getButtonContent(children) {
const iconName = this.props.icon; const iconName = this.attrs.icon;
return [ return [
iconName && iconName !== true ? icon(iconName, { className: 'Button-icon' }) : '', iconName && iconName !== true ? icon(iconName, { className: 'Button-icon' }) : '',
this.props.children ? <span className="Button-label">{this.props.children}</span> : '', children ? <span className="Button-label">{children}</span> : '',
this.props.loading ? LoadingIndicator.component({ size: 'tiny', className: 'LoadingIndicator--inline' }) : '', this.attrs.loading ? <LoadingIndicator size="tiny" className="LoadingIndicator--inline" /> : '',
]; ];
} }
} }

View File

@ -1,11 +1,13 @@
import Component from '../Component'; import Component from '../Component';
import LoadingIndicator from './LoadingIndicator'; import LoadingIndicator from './LoadingIndicator';
import icon from '../helpers/icon'; import icon from '../helpers/icon';
import classList from '../utils/classList';
import withAttr from '../utils/withAttr';
/** /**
* The `Checkbox` component defines a checkbox input. * The `Checkbox` component defines a checkbox input.
* *
* ### Props * ### Attrs
* *
* - `state` Whether or not the checkbox is checked. * - `state` Whether or not the checkbox is checked.
* - `className` The class name for the root element. * - `className` The class name for the root element.
@ -15,19 +17,24 @@ import icon from '../helpers/icon';
* - `children` A text label to display next to the checkbox. * - `children` A text label to display next to the checkbox.
*/ */
export default class Checkbox extends Component { export default class Checkbox extends Component {
view() { view(vnode) {
// Sometimes, false is stored in the DB as '0'. This is a temporary // Sometimes, false is stored in the DB as '0'. This is a temporary
// conversion layer until a more robust settings encoding is introduced // conversion layer until a more robust settings encoding is introduced
if (this.props.state === '0') this.props.state = false; if (this.attrs.state === '0') this.attrs.state = false;
let className = 'Checkbox ' + (this.props.state ? 'on' : 'off') + ' ' + (this.props.className || '');
if (this.props.loading) className += ' loading'; const className = classList([
if (this.props.disabled) className += ' disabled'; 'Checkbox',
this.attrs.state ? 'on' : 'off',
this.attrs.className,
this.attrs.loading && 'loading',
this.attrs.disabled && 'disabled',
]);
return ( return (
<label className={className}> <label className={className}>
<input type="checkbox" checked={this.props.state} disabled={this.props.disabled} onchange={m.withAttr('checked', this.onchange.bind(this))} /> <input type="checkbox" checked={this.attrs.state} disabled={this.attrs.disabled} onchange={withAttr('checked', this.onchange.bind(this))} />
<div className="Checkbox-display">{this.getDisplay()}</div> <div className="Checkbox-display">{this.getDisplay()}</div>
{this.props.children} {vnode.children}
</label> </label>
); );
} }
@ -39,7 +46,7 @@ export default class Checkbox extends Component {
* @protected * @protected
*/ */
getDisplay() { getDisplay() {
return this.props.loading ? LoadingIndicator.component({ size: 'tiny' }) : icon(this.props.state ? 'fas fa-check' : 'fas fa-times'); return this.attrs.loading ? <LoadingIndicator size="tiny" /> : icon(this.attrs.state ? 'fas fa-check' : 'fas fa-times');
} }
/** /**
@ -49,6 +56,6 @@ export default class Checkbox extends Component {
* @protected * @protected
*/ */
onchange(checked) { onchange(checked) {
if (this.props.onchange) this.props.onchange(checked, this); if (this.attrs.onchange) this.attrs.onchange(checked, this);
} }
} }

View File

@ -5,7 +5,7 @@ import Component from '../Component';
* event handler that prevents closing the browser window/tab based on the * event handler that prevents closing the browser window/tab based on the
* return value of a given callback prop. * return value of a given callback prop.
* *
* ### Props * ### Attrs
* *
* - `when` - a callback returning true when the browser should prompt for * - `when` - a callback returning true when the browser should prompt for
* confirmation before closing the window/tab * confirmation before closing the window/tab
@ -17,21 +17,24 @@ import Component from '../Component';
* *
*/ */
export default class ConfirmDocumentUnload extends Component { export default class ConfirmDocumentUnload extends Component {
config(isInitialized, context) { handler() {
if (isInitialized) return; return this.attrs.when() || undefined;
const handler = () => this.props.when() || undefined;
$(window).on('beforeunload', handler);
context.onunload = () => {
$(window).off('beforeunload', handler);
};
} }
view() { oncreate(vnode) {
super.oncreate(vnode);
this.boundHandler = this.handler.bind(this);
$(window).on('beforeunload', this.boundHandler);
}
onremove() {
$(window).off('beforeunload', this.boundHandler);
}
view(vnode) {
// To avoid having to render another wrapping <div> here, we assume that // To avoid having to render another wrapping <div> here, we assume that
// this component is only wrapped around a single element / component. // this component is only wrapped around a single element / component.
return this.props.children[0]; return vnode.children[0];
} }
} }

View File

@ -6,7 +6,7 @@ import listItems from '../helpers/listItems';
* The `Dropdown` component displays a button which, when clicked, shows a * The `Dropdown` component displays a button which, when clicked, shows a
* dropdown menu beneath it. * dropdown menu beneath it.
* *
* ### Props * ### Attrs
* *
* - `buttonClassName` A class name to apply to the dropdown toggle button. * - `buttonClassName` A class name to apply to the dropdown toggle button.
* - `menuClassName` A class name to apply to the dropdown menu. * - `menuClassName` A class name to apply to the dropdown menu.
@ -19,33 +19,33 @@ import listItems from '../helpers/listItems';
* The children will be displayed as a list inside of the dropdown menu. * The children will be displayed as a list inside of the dropdown menu.
*/ */
export default class Dropdown extends Component { export default class Dropdown extends Component {
static initProps(props) { static initAttrs(attrs) {
super.initProps(props); attrs.className = attrs.className || '';
attrs.buttonClassName = attrs.buttonClassName || '';
props.className = props.className || ''; attrs.menuClassName = attrs.menuClassName || '';
props.buttonClassName = props.buttonClassName || ''; attrs.label = attrs.label || '';
props.menuClassName = props.menuClassName || ''; attrs.caretIcon = typeof attrs.caretIcon !== 'undefined' ? attrs.caretIcon : 'fas fa-caret-down';
props.label = props.label || '';
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-caret-down';
} }
init() { oninit(vnode) {
super.oninit(vnode);
this.showing = false; this.showing = false;
} }
view() { view(vnode) {
const items = this.props.children ? listItems(this.props.children) : []; const items = vnode.children ? listItems(vnode.children) : [];
return ( return (
<div className={'ButtonGroup Dropdown dropdown ' + this.props.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}> <div className={'ButtonGroup Dropdown dropdown ' + this.attrs.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}>
{this.getButton()} {this.getButton(vnode.children)}
{this.getMenu(items)} {this.getMenu(items)}
</div> </div>
); );
} }
config(isInitialized) { oncreate(vnode) {
if (isInitialized) return; super.oncreate(vnode);
// When opening the dropdown menu, work out if the menu goes beyond the // When opening the dropdown menu, work out if the menu goes beyond the
// bottom of the viewport. If it does, we will apply class to make it show // bottom of the viewport. If it does, we will apply class to make it show
@ -53,8 +53,8 @@ export default class Dropdown extends Component {
this.$().on('shown.bs.dropdown', () => { this.$().on('shown.bs.dropdown', () => {
this.showing = true; this.showing = true;
if (this.props.onshow) { if (this.attrs.onshow) {
this.props.onshow(); this.attrs.onshow();
} }
m.redraw(); m.redraw();
@ -76,8 +76,8 @@ export default class Dropdown extends Component {
this.$().on('hidden.bs.dropdown', () => { this.$().on('hidden.bs.dropdown', () => {
this.showing = false; this.showing = false;
if (this.props.onhide) { if (this.attrs.onhide) {
this.props.onhide(); this.attrs.onhide();
} }
m.redraw(); m.redraw();
@ -90,10 +90,10 @@ export default class Dropdown extends Component {
* @return {*} * @return {*}
* @protected * @protected
*/ */
getButton() { getButton(children) {
return ( return (
<button className={'Dropdown-toggle ' + this.props.buttonClassName} data-toggle="dropdown" onclick={this.props.onclick}> <button className={'Dropdown-toggle ' + this.attrs.buttonClassName} data-toggle="dropdown" onclick={this.attrs.onclick}>
{this.getButtonContent()} {this.getButtonContent(children)}
</button> </button>
); );
} }
@ -104,15 +104,15 @@ export default class Dropdown extends Component {
* @return {*} * @return {*}
* @protected * @protected
*/ */
getButtonContent() { getButtonContent(children) {
return [ return [
this.props.icon ? icon(this.props.icon, { className: 'Button-icon' }) : '', this.attrs.icon ? icon(this.attrs.icon, { className: 'Button-icon' }) : '',
<span className="Button-label">{this.props.label}</span>, <span className="Button-label">{this.attrs.label}</span>,
this.props.caretIcon ? icon(this.props.caretIcon, { className: 'Button-caret' }) : '', this.attrs.caretIcon ? icon(this.attrs.caretIcon, { className: 'Button-caret' }) : '',
]; ];
} }
getMenu(items) { getMenu(items) {
return <ul className={'Dropdown-menu dropdown-menu ' + this.props.menuClassName}>{items}</ul>; return <ul className={'Dropdown-menu dropdown-menu ' + this.attrs.menuClassName}>{items}</ul>;
} }
} }

View File

@ -11,11 +11,11 @@ import listItems from '../helpers/listItems';
* The children should be an array of items to show in the fieldset. * The children should be an array of items to show in the fieldset.
*/ */
export default class FieldSet extends Component { export default class FieldSet extends Component {
view() { view(vnode) {
return ( return (
<fieldset className={this.props.className}> <fieldset className={this.attrs.className}>
<legend>{this.props.label}</legend> <legend>{this.attrs.label}</legend>
<ul>{listItems(this.props.children)}</ul> <ul>{listItems(vnode.children)}</ul>
</fieldset> </fieldset>
); );
} }

View File

@ -1,16 +1,16 @@
import Badge from './Badge'; import Badge from './Badge';
export default class GroupBadge extends Badge { export default class GroupBadge extends Badge {
static initProps(props) { static initAttrs(attrs) {
super.initProps(props); super.initAttrs(attrs);
if (props.group) { if (attrs.group) {
props.icon = props.group.icon(); attrs.icon = attrs.group.icon();
props.style = { backgroundColor: props.group.color() }; attrs.style = { backgroundColor: attrs.group.color() };
props.label = typeof props.label === 'undefined' ? props.group.nameSingular() : props.label; attrs.label = typeof attrs.label === 'undefined' ? attrs.group.nameSingular() : attrs.label;
props.type = 'group--' + props.group.id(); attrs.type = 'group--' + attrs.group.id();
delete props.group; delete attrs.group;
} }
} }
} }

View File

@ -3,9 +3,9 @@ import Button from './Button';
/** /**
* The `LinkButton` component defines a `Button` which links to a route. * The `LinkButton` component defines a `Button` which links to a route.
* *
* ### Props * ### Attrs
* *
* All of the props accepted by `Button`, plus: * All of the attrs accepted by `Button`, plus:
* *
* - `active` Whether or not the page that this button links to is currently * - `active` Whether or not the page that this button links to is currently
* active. * active.
@ -13,26 +13,28 @@ import Button from './Button';
* the `active` prop will automatically be set to true. * the `active` prop will automatically be set to true.
*/ */
export default class LinkButton extends Button { export default class LinkButton extends Button {
static initProps(props) { static initAttrs(attrs) {
props.active = this.isActive(props); super.initAttrs(attrs);
props.config = props.config || m.route;
attrs.active = this.isActive(attrs);
} }
view() { view(vnode) {
const vdom = super.view(); const vdom = super.view(vnode);
vdom.tag = 'a'; vdom.tag = m.route.Link;
vdom.attrs.active = String(vdom.attrs.active);
return vdom; return vdom;
} }
/** /**
* Determine whether a component with the given props is 'active'. * Determine whether a component with the given attrs is 'active'.
* *
* @param {Object} props * @param {Object} attrs
* @return {Boolean} * @return {Boolean}
*/ */
static isActive(props) { static isActive(attrs) {
return typeof props.active !== 'undefined' ? props.active : m.route() === props.href; return typeof attrs.active !== 'undefined' ? attrs.active : m.route.get() === attrs.href;
} }
} }

View File

@ -2,16 +2,17 @@ import Component from '../Component';
import { Spinner } from 'spin.js'; import { Spinner } from 'spin.js';
/** /**
* The `LoadingIndicator` component displays a loading spinner with spin.js. It * The `LoadingIndicator` component displays a loading spinner with spin.js.
* may have the following special props: *
* ### Attrs
* *
* - `size` The spin.js size preset to use. Defaults to 'small'. * - `size` The spin.js size preset to use. Defaults to 'small'.
* *
* All other props will be assigned as attributes on the element. * All other attrs will be assigned as attributes on the DOM element.
*/ */
export default class LoadingIndicator extends Component { export default class LoadingIndicator extends Component {
view() { view() {
const attrs = Object.assign({}, this.props); const attrs = Object.assign({}, this.attrs);
attrs.className = 'LoadingIndicator ' + (attrs.className || ''); attrs.className = 'LoadingIndicator ' + (attrs.className || '');
delete attrs.size; delete attrs.size;
@ -19,12 +20,12 @@ export default class LoadingIndicator extends Component {
return <div {...attrs}>{m.trust('&nbsp;')}</div>; return <div {...attrs}>{m.trust('&nbsp;')}</div>;
} }
config(isInitialized) { oncreate(vnode) {
if (isInitialized) return; super.oncreate(vnode);
const options = { zIndex: 'auto', color: this.$().css('color') }; const options = { zIndex: 'auto', color: this.$().css('color') };
switch (this.props.size) { switch (this.attrs.size) {
case 'large': case 'large':
Object.assign(options, { lines: 10, length: 8, width: 4, radius: 8 }); Object.assign(options, { lines: 10, length: 8, width: 4, radius: 8 });
break; break;

View File

@ -14,23 +14,21 @@ export default class Modal extends Component {
*/ */
static isDismissible = true; static isDismissible = true;
init() {
/** /**
* Attributes for an alert component to show below the header. * Attributes for an alert component to show below the header.
* *
* @type {object} * @type {object}
*/ */
this.alertAttrs = null; alertAttrs = null;
oncreate(vnode) {
super.oncreate(vnode);
this.attrs.onshow(() => this.onready());
} }
config(isInitialized, context) { onremove() {
if (isInitialized) return; this.attrs.onhide();
this.props.onshow(() => this.onready());
context.onunload = () => {
this.props.onhide();
};
} }
view() { view() {
@ -109,7 +107,7 @@ export default class Modal extends Component {
* Hide the modal. * Hide the modal.
*/ */
hide() { hide() {
this.props.onhide(); this.attrs.onhide();
} }
/** /**

View File

@ -6,12 +6,8 @@ import Component from '../Component';
* overwrite the previous one. * overwrite the previous one.
*/ */
export default class ModalManager extends Component { export default class ModalManager extends Component {
init() {
this.state = this.props.state;
}
view() { view() {
const modal = this.state.modal; const modal = this.attrs.state.modal;
return ( return (
<div className="ModalManager modal fade"> <div className="ModalManager modal fade">
@ -20,22 +16,17 @@ export default class ModalManager extends Component {
); );
} }
config(isInitialized, context) { oncreate(vnode) {
if (isInitialized) return; super.oncreate(vnode);
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
// Ensure the modal state is notified about a closed modal, even when the // Ensure the modal state is notified about a closed modal, even when the
// DOM-based Bootstrap JavaScript code triggered the closing of the modal, // DOM-based Bootstrap JavaScript code triggered the closing of the modal,
// e.g. via ESC key or a click on the modal backdrop. // e.g. via ESC key or a click on the modal backdrop.
this.$().on('hidden.bs.modal', this.state.close.bind(this.state)); this.$().on('hidden.bs.modal', this.attrs.state.close.bind(this.attrs.state));
} }
animateShow(readyCallback) { animateShow(readyCallback) {
const dismissible = !!this.state.modal.componentClass.isDismissible; const dismissible = !!this.attrs.state.modal.componentClass.isDismissible;
this.$() this.$()
.one('shown.bs.modal', readyCallback) .one('shown.bs.modal', readyCallback)

View File

@ -11,7 +11,7 @@ import LinkButton from './LinkButton';
* If the app has a pane, it will also include a 'pin' button which toggles the * If the app has a pane, it will also include a 'pin' button which toggles the
* pinned state of the pane. * pinned state of the pane.
* *
* Accepts the following props: * Accepts the following attrs:
* *
* - `className` The name of a class to set on the root element. * - `className` The name of a class to set on the root element.
* - `drawer` Whether or not to show a button to toggle the app's drawer if * - `drawer` Whether or not to show a button to toggle the app's drawer if
@ -23,7 +23,7 @@ export default class Navigation extends Component {
return ( return (
<div <div
className={'Navigation ButtonGroup ' + (this.props.className || '')} className={'Navigation ButtonGroup ' + (this.attrs.className || '')}
onmouseenter={pane && pane.show.bind(pane)} onmouseenter={pane && pane.show.bind(pane)}
onmouseleave={pane && pane.onmouseleave.bind(pane)} onmouseleave={pane && pane.onmouseleave.bind(pane)}
> >
@ -32,13 +32,6 @@ export default class Navigation extends Component {
); );
} }
config(isInitialized, context) {
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
}
/** /**
* Get the back button. * Get the back button.
* *
@ -54,7 +47,6 @@ export default class Navigation extends Component {
href: history.backUrl(), href: history.backUrl(),
icon: 'fas fa-chevron-left', icon: 'fas fa-chevron-left',
title: previous.title, title: previous.title,
config: () => {},
onclick: (e) => { onclick: (e) => {
if (e.shiftKey || e.ctrlKey || e.metaKey || e.which === 2) return; if (e.shiftKey || e.ctrlKey || e.metaKey || e.which === 2) return;
e.preventDefault(); e.preventDefault();
@ -88,7 +80,7 @@ export default class Navigation extends Component {
* @protected * @protected
*/ */
getDrawerButton() { getDrawerButton() {
if (!this.props.drawer) return ''; if (!this.attrs.drawer) return '';
const { drawer } = app; const { drawer } = app;
const user = app.session.user; const user = app.session.user;

View File

@ -7,10 +7,14 @@ import PageState from '../states/PageState';
* @abstract * @abstract
*/ */
export default class Page extends Component { export default class Page extends Component {
init() { oninit(vnode) {
super.oninit(vnode);
app.previous = app.current; app.previous = app.current;
app.current = new PageState(this.constructor); app.current = new PageState(this.constructor);
this.onNewRoute();
app.drawer.hide(); app.drawer.hide();
app.modal.close(); app.modal.close();
@ -22,13 +26,27 @@ export default class Page extends Component {
this.bodyClass = ''; this.bodyClass = '';
} }
config(isInitialized, context) { /**
if (isInitialized) return; * A collections of actions to run when the route changes.
* This is extracted here, and not hardcoded in oninit, as oninit is not called
* when a different route is handled by the same component, but we still need to
* adjust the current route name.
*/
onNewRoute() {
app.current.set('routeName', this.attrs.routeName);
}
oncreate(vnode) {
super.oncreate(vnode);
if (this.bodyClass) { if (this.bodyClass) {
$('#app').addClass(this.bodyClass); $('#app').addClass(this.bodyClass);
}
}
context.onunload = () => $('#app').removeClass(this.bodyClass); onremove() {
if (this.bodyClass) {
$('#app').removeClass(this.bodyClass);
} }
} }
} }

View File

@ -4,7 +4,7 @@ import Component from '../Component';
* The `Placeholder` component displays a muted text with some call to action, * The `Placeholder` component displays a muted text with some call to action,
* usually used as an empty state. * usually used as an empty state.
* *
* ### Props * ### Attrs
* *
* - `text` * - `text`
*/ */
@ -12,7 +12,7 @@ export default class Placeholder extends Component {
view() { view() {
return ( return (
<div className="Placeholder"> <div className="Placeholder">
<p>{this.props.text}</p> <p>{this.attrs.text}</p>
</div> </div>
); );
} }

View File

@ -6,11 +6,11 @@ export default class RequestErrorModal extends Modal {
} }
title() { title() {
return this.props.error.xhr ? `${this.props.error.xhr.status} ${this.props.error.xhr.statusText}` : ''; return this.attrs.error.xhr ? `${this.attrs.error.xhr.status} ${this.attrs.error.xhr.statusText}` : '';
} }
content() { content() {
const { error, formattedError } = this.props; const { error, formattedError } = this.attrs;
let responseText; let responseText;
@ -31,7 +31,7 @@ export default class RequestErrorModal extends Modal {
return ( return (
<div className="Modal-body"> <div className="Modal-body">
<pre> <pre>
{this.props.error.options.method} {this.props.error.options.url} {this.attrs.error.options.method} {this.attrs.error.options.url}
<br /> <br />
<br /> <br />
{responseText} {responseText}

View File

@ -1,9 +1,10 @@
import Component from '../Component'; import Component from '../Component';
import icon from '../helpers/icon'; import icon from '../helpers/icon';
import withAttr from '../utils/withAttr';
/** /**
* The `Select` component displays a <select> input, surrounded with some extra * The `Select` component displays a <select> input, surrounded with some extra
* elements for styling. It accepts the following props: * elements for styling. It accepts the following attrs:
* *
* - `options` A map of option values to labels. * - `options` A map of option values to labels.
* - `onchange` A callback to run when the selected value is changed. * - `onchange` A callback to run when the selected value is changed.
@ -12,13 +13,13 @@ import icon from '../helpers/icon';
*/ */
export default class Select extends Component { export default class Select extends Component {
view() { view() {
const { options, onchange, value, disabled } = this.props; const { options, onchange, value, disabled } = this.attrs;
return ( return (
<span className="Select"> <span className="Select">
<select <select
className="Select-input FormControl" className="Select-input FormControl"
onchange={onchange ? m.withAttr('value', onchange.bind(this)) : undefined} onchange={onchange ? withAttr('value', onchange.bind(this)) : undefined}
value={value} value={value}
disabled={disabled} disabled={disabled}
> >

View File

@ -1,31 +1,49 @@
import Dropdown from './Dropdown'; import Dropdown from './Dropdown';
import icon from '../helpers/icon'; import icon from '../helpers/icon';
/**
* Determines via a vnode is currently "active".
* Due to changes in Mithril 2, attrs will not be instantiated until AFTER view()
* is initially called on the parent component, so we can not always depend on the
* active attr to determine which element should be displayed as the "active child".
*
* This is a temporary patch, and as so, is not exported / placed in utils.
*/
function isActive(vnode) {
const tag = vnode.tag;
if ('initAttrs' in tag) {
tag.initAttrs(vnode.attrs);
}
return 'isActive' in tag ? tag.isActive(vnode.attrs) : vnode.attrs.active;
}
/** /**
* The `SelectDropdown` component is the same as a `Dropdown`, except the toggle * The `SelectDropdown` component is the same as a `Dropdown`, except the toggle
* button's label is set as the label of the first child which has a truthy * button's label is set as the label of the first child which has a truthy
* `active` prop. * `active` prop.
* *
* ### Props * ### Attrs
* *
* - `caretIcon` * - `caretIcon`
* - `defaultLabel` * - `defaultLabel`
*/ */
export default class SelectDropdown extends Dropdown { export default class SelectDropdown extends Dropdown {
static initProps(props) { static initAttrs(attrs) {
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-sort'; attrs.caretIcon = typeof attrs.caretIcon !== 'undefined' ? attrs.caretIcon : 'fas fa-sort';
super.initProps(props); super.initAttrs(attrs);
props.className += ' Dropdown--select'; attrs.className += ' Dropdown--select';
} }
getButtonContent() { getButtonContent(children) {
const activeChild = this.props.children.filter((child) => child.props.active)[0]; const activeChild = children.find(isActive);
let label = (activeChild && activeChild.props.children) || this.props.defaultLabel; let label = (activeChild && activeChild.children) || this.attrs.defaultLabel;
if (label instanceof Array) label = label[0]; if (label instanceof Array) label = label[0];
return [<span className="Button-label">{label}</span>, icon(this.props.caretIcon, { className: 'Button-caret' })]; return [<span className="Button-label">{label}</span>, icon(this.attrs.caretIcon, { className: 'Button-caret' })];
} }
} }

View File

@ -7,25 +7,25 @@ import icon from '../helpers/icon';
* is displayed as its own button prior to the toggle button. * is displayed as its own button prior to the toggle button.
*/ */
export default class SplitDropdown extends Dropdown { export default class SplitDropdown extends Dropdown {
static initProps(props) { static initAttrs(attrs) {
super.initProps(props); super.initAttrs(attrs);
props.className += ' Dropdown--split'; attrs.className += ' Dropdown--split';
props.menuClassName += ' Dropdown-menu--right'; attrs.menuClassName += ' Dropdown-menu--right';
} }
getButton() { getButton(children) {
// Make a copy of the props of the first child component. We will assign // Make a copy of the attrs of the first child component. We will assign
// these props to a new button, so that it has exactly the same behaviour as // these attrs to a new button, so that it has exactly the same behaviour as
// the first child. // the first child.
const firstChild = this.getFirstChild(); const firstChild = this.getFirstChild(children);
const buttonProps = Object.assign({}, firstChild.props); const buttonAttrs = Object.assign({}, firstChild.attrs);
buttonProps.className = (buttonProps.className || '') + ' SplitDropdown-button Button ' + this.props.buttonClassName; buttonAttrs.className = (buttonAttrs.className || '') + ' SplitDropdown-button Button ' + this.attrs.buttonClassName;
return [ return [
Button.component(buttonProps), Button.component(buttonAttrs, firstChild.children),
<button className={'Dropdown-toggle Button Button--icon ' + this.props.buttonClassName} data-toggle="dropdown"> <button className={'Dropdown-toggle Button Button--icon ' + this.attrs.buttonClassName} data-toggle="dropdown">
{icon(this.props.icon, { className: 'Button-icon' })} {icon(this.attrs.icon, { className: 'Button-icon' })}
{icon('fas fa-caret-down', { className: 'Button-caret' })} {icon('fas fa-caret-down', { className: 'Button-caret' })}
</button>, </button>,
]; ];
@ -38,8 +38,8 @@ export default class SplitDropdown extends Dropdown {
* @return {*} * @return {*}
* @protected * @protected
*/ */
getFirstChild() { getFirstChild(children) {
let firstChild = this.props.children; let firstChild = children;
while (firstChild instanceof Array) firstChild = firstChild[0]; while (firstChild instanceof Array) firstChild = firstChild[0];

View File

@ -5,13 +5,13 @@ import Checkbox from './Checkbox';
* a tick/cross one. * a tick/cross one.
*/ */
export default class Switch extends Checkbox { export default class Switch extends Checkbox {
static initProps(props) { static initAttrs(attrs) {
super.initProps(props); super.initAttrs(attrs);
props.className = (props.className || '') + ' Checkbox--switch'; attrs.className = (attrs.className || '') + ' Checkbox--switch';
} }
getDisplay() { getDisplay() {
return this.props.loading ? super.getDisplay() : ''; return this.attrs.loading ? super.getDisplay() : '';
} }
} }

View File

@ -2,14 +2,14 @@ import Separator from '../components/Separator';
import classList from '../utils/classList'; import classList from '../utils/classList';
function isSeparator(item) { function isSeparator(item) {
return item && item.component === Separator; return item.tag === Separator;
} }
function withoutUnnecessarySeparators(items) { function withoutUnnecessarySeparators(items) {
const newItems = []; const newItems = [];
let prevItem; let prevItem;
items.forEach((item, i) => { items.filter(Boolean).forEach((item, i) => {
if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) { if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) {
prevItem = item; prevItem = item;
newItems.push(item); newItems.push(item);
@ -30,21 +30,27 @@ export default function listItems(items) {
if (!(items instanceof Array)) items = [items]; if (!(items instanceof Array)) items = [items];
return withoutUnnecessarySeparators(items).map((item) => { return withoutUnnecessarySeparators(items).map((item) => {
const isListItem = item.component && item.component.isListItem; const isListItem = item.tag && item.tag.isListItem;
const active = item.component && item.component.isActive && item.component.isActive(item.props); const active = item.tag && item.tag.isActive && item.tag.isActive(item.attrs);
const className = item.props ? item.props.itemClassName : item.itemClassName; const className = (item.attrs && item.attrs.itemClassName) || item.itemClassName;
if (isListItem) { if (isListItem) {
item.attrs = item.attrs || {}; item.attrs = item.attrs || {};
item.attrs.key = item.attrs.key || item.itemName; item.attrs.key = item.attrs.key || item.itemName;
item.key = item.attrs.key;
} }
return isListItem ? ( const node = isListItem ? (
item item
) : ( ) : (
<li className={classList([item.itemName ? 'item-' + item.itemName : '', className, active ? 'active' : ''])} key={item.itemName}> <li
className={classList([className, item.itemName && `item-${item.itemName}`, active && 'active'])}
key={(item.attrs && item.attrs.key) || item.itemName}
>
{item} {item}
</li> </li>
); );
return node;
}); });
} }

View File

@ -13,7 +13,21 @@ export default class AlertManagerState {
/** /**
* Show an Alert in the alerts area. * Show an Alert in the alerts area.
*/ */
show(attrs, componentClass = Alert) { show(arg1, arg2, arg3) {
let componentClass = Alert;
let attrs = {};
let children;
if (arguments.length == 1) {
children = arg1;
} else if (arguments.length == 2) {
attrs = arg1;
children = arg2;
} else if (arguments.length == 3) {
componentClass = arg1;
attrs = arg2;
children = arg3;
}
// Breaking Change Compliance Warning, Remove in Beta 15. // Breaking Change Compliance Warning, Remove in Beta 15.
// This is applied to the first argument (attrs) because previously, the alert was passed as the first argument. // This is applied to the first argument (attrs) because previously, the alert was passed as the first argument.
if (attrs === Alert || attrs instanceof Alert) { if (attrs === Alert || attrs instanceof Alert) {
@ -22,7 +36,7 @@ export default class AlertManagerState {
throw new Error('The AlertManager can only show Alerts. Whichever extension triggered this alert should be updated to comply with beta 14.'); throw new Error('The AlertManager can only show Alerts. Whichever extension triggered this alert should be updated to comply with beta 14.');
} }
// End Change Compliance Warning, Remove in Beta 15 // End Change Compliance Warning, Remove in Beta 15
this.activeAlerts[++this.alertId] = { attrs, componentClass }; this.activeAlerts[++this.alertId] = { children, attrs, componentClass };
m.redraw(); m.redraw();
return this.alertId; return this.alertId;

View File

@ -32,7 +32,7 @@ export default class ModalManagerState {
this.modal = { componentClass, attrs }; this.modal = { componentClass, attrs };
m.redraw(true); m.redraw.sync();
} }
/** /**
@ -50,7 +50,7 @@ export default class ModalManagerState {
// ahead. // ahead.
this.closeTimeout = setTimeout(() => { this.closeTimeout = setTimeout(() => {
this.modal = null; this.modal = null;
m.lazyRedraw(); m.redraw();
}); });
} }
} }

View File

@ -1,20 +1,25 @@
/** /**
* The `SubtreeRetainer` class represents a Mithril virtual DOM subtree. It * The `SubtreeRetainer` class keeps track of a number of pieces of data,
* keeps track of a number of pieces of data, allowing the subtree to be * comparing the values of these pieces at every iteration.
* retained if none of them have changed. *
* This is useful for preventing redraws to relatively static (or huge)
* components whose VDOM only depends on very few values, when none of them
* have changed.
* *
* @example * @example
* // constructor * // Check two callbacks for changes on each update
* this.subtree = new SubtreeRetainer( * this.subtree = new SubtreeRetainer(
* () => this.props.post.freshness, * () => this.attrs.post.freshness,
* () => this.showing * () => this.showing
* ); * );
* this.subtree.check(() => this.props.user.freshness);
* *
* // view * // Add more callbacks to be checked for updates
* this.subtree.retain() || 'expensive expression' * this.subtree.check(() => this.attrs.user.freshness);
* *
* @see https://lhorie.github.io/mithril/mithril.html#persisting-dom-elements-across-route-changes * // In a component's onbeforeupdate() method:
* return this.subtree.needsRebuild()
*
* @see https://mithril.js.org/lifecycle-methods.html#onbeforeupdate
*/ */
export default class SubtreeRetainer { export default class SubtreeRetainer {
/** /**
@ -26,13 +31,13 @@ export default class SubtreeRetainer {
} }
/** /**
* Return a virtual DOM directive that will retain a subtree if no data has * Return whether any data has changed since the last check.
* changed since the last check. * If so, Mithril needs to re-diff the vnode and its children.
* *
* @return {Object|false} * @return {boolean}
* @public * @public
*/ */
retain() { needsRebuild() {
let needsRebuild = false; let needsRebuild = false;
this.callbacks.forEach((callback, i) => { this.callbacks.forEach((callback, i) => {
@ -44,7 +49,7 @@ export default class SubtreeRetainer {
} }
}); });
return needsRebuild ? false : { subtree: 'retain' }; return needsRebuild;
} }
/** /**

View File

@ -8,7 +8,7 @@ export default function extractText(vdom) {
if (vdom instanceof Array) { if (vdom instanceof Array) {
return vdom.map((element) => extractText(element)).join(''); return vdom.map((element) => extractText(element)).join('');
} else if (typeof vdom === 'object' && vdom !== null) { } else if (typeof vdom === 'object' && vdom !== null) {
return extractText(vdom.children); return vdom.children ? extractText(vdom.children) : vdom.text;
} else { } else {
return vdom; return vdom;
} }

View File

@ -2,7 +2,7 @@
* The `mapRoutes` utility converts a map of named application routes into a * The `mapRoutes` utility converts a map of named application routes into a
* format that can be understood by Mithril. * format that can be understood by Mithril.
* *
* @see https://lhorie.github.io/mithril/mithril.route.html#defining-routes * @see https://mithril.js.org/route.html#signature
* @param {Object} routes * @param {Object} routes
* @param {String} [basePath] * @param {String} [basePath]
* @return {Object} * @return {Object}
@ -13,9 +13,11 @@ export default function mapRoutes(routes, basePath = '') {
for (const key in routes) { for (const key in routes) {
const route = routes[key]; const route = routes[key];
if (route.component) route.component.props.routeName = key; map[basePath + route.path] = {
render() {
map[basePath + route.path] = route.component; return m(route.component, { routeName: key });
},
};
} }
return map; return map;

View File

@ -1,27 +1,60 @@
import Component from '../Component'; import Stream from 'mithril/stream';
import extract from './extract';
export default function patchMithril(global) { export default function patchMithril(global) {
const mo = global.m; const defaultMithril = global.m;
const m = function (comp, ...args) { /**
if (comp.prototype && comp.prototype instanceof Component) { * If the href URL of the link is the same as the current page path
let children = args.slice(1); * we will not add a new entry to the browser history.
if (children.length === 1 && Array.isArray(children[0])) { *
children = children[0]; * This allows us to still refresh the Page component
* without adding endless history entries.
*
* We also add the `force` attribute that adds a custom state key
* for when you want to force a complete refresh of the Page
*/
const defaultLinkView = defaultMithril.route.Link.view;
const modifiedLink = {
view: function (vnode) {
let { href, options = {} } = vnode.attrs;
if (href === m.route.get()) {
if (!('replace' in options)) options.replace = true;
} }
return comp.component(args[0], children); if (extract(vnode.attrs, 'force')) {
if (!('state' in options)) options.state = {};
if (!('key' in options.state)) options.state.key = Date.now();
} }
const node = mo.apply(this, arguments); vnode.attrs.options = options;
return defaultLinkView(vnode);
},
};
const modifiedMithril = function (comp, ...args) {
const node = defaultMithril.apply(this, arguments);
if (!node.attrs) node.attrs = {};
// Allows the use of the bidi attr.
if (node.attrs.bidi) { if (node.attrs.bidi) {
m.bidi(node, node.attrs.bidi); modifiedMithril.bidi(node, node.attrs.bidi);
} }
// Allows us to use a "route" attr on links, which will automatically convert the link to one which
// supports linking to other pages in the SPA without refreshing the document.
if (node.attrs.route) { if (node.attrs.route) {
node.attrs.href = node.attrs.route; node.attrs.href = node.attrs.route;
node.attrs.config = m.route; node.tag = modifiedLink;
// For some reason, m.route.Link does not like vnode.text, so if present, we
// need to convert it to text vnodes and store it in children.
if (node.text) {
node.children = { tag: '#', children: node.text };
}
delete node.attrs.route; delete node.attrs.route;
} }
@ -29,17 +62,11 @@ export default function patchMithril(global) {
return node; return node;
}; };
Object.keys(mo).forEach((key) => (m[key] = mo[key])); Object.keys(defaultMithril).forEach((key) => (modifiedMithril[key] = defaultMithril[key]));
/** modifiedMithril.stream = Stream;
* Redraw only if not in the middle of a computation (e.g. a route change).
*
* @return {void}
*/
m.lazyRedraw = function () {
m.startComputation();
m.endComputation();
};
global.m = m; modifiedMithril.route.Link = modifiedLink;
global.m = modifiedMithril;
} }

View File

@ -0,0 +1,15 @@
import Mithril from 'mithril';
/**
* Mithril 2 does not completely rerender the page if a route change leads to the same route
* (or the same component handling a different route). This util calls m.route.set, forcing a reonit.
*
* @see https://mithril.js.org/route.html#key-parameter
*/
export default function setRouteWithForcedRefresh(route: string, params = null, options: Mithril.RouteOptions = {}) {
const newOptions = { ...options };
newOptions.state = newOptions.state || {};
newOptions.state.key = Date.now();
m.route.set(route, params, newOptions);
}

View File

@ -0,0 +1,15 @@
/**
* An event handler factory that makes it simpler to implement data binding
* for component event listeners.
*
* The handler created by this factory passes the DOM element's attribute
* identified by the first argument to the callback (usually a bidirectional
* Mithril stream: https://mithril.js.org/stream.html#bidirectional-bindings).
*
* Replaces m.withAttr for Mithril 2.0.
* @see https://mithril.js.org/archive/v0.2.5/mithril.withAttr.html
*/
export default (key: string, cb: Function) =>
function (this: Element) {
cb(this.getAttribute(key) || this[key]);
};

View File

@ -115,15 +115,15 @@ export default class ForumApplication extends Application {
this.routes[defaultAction].path = '/'; this.routes[defaultAction].path = '/';
this.history.push(defaultAction, this.translator.trans('core.forum.header.back_to_index_tooltip'), '/'); this.history.push(defaultAction, this.translator.trans('core.forum.header.back_to_index_tooltip'), '/');
m.mount(document.getElementById('app-navigation'), Navigation.component({ className: 'App-backControl', drawer: true })); m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
m.mount(document.getElementById('header-navigation'), Navigation.component()); m.mount(document.getElementById('header-navigation'), Navigation);
m.mount(document.getElementById('header-primary'), HeaderPrimary.component()); m.mount(document.getElementById('header-primary'), HeaderPrimary);
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component()); m.mount(document.getElementById('header-secondary'), HeaderSecondary);
m.mount(document.getElementById('composer'), Composer.component({ state: this.composer })); m.mount(document.getElementById('composer'), { view: () => Composer.component({ state: this.composer }) });
this.pane = new Pane(document.getElementById('app')); this.pane = new Pane(document.getElementById('app'));
m.route.mode = 'pathname'; m.route.prefix = '';
super.mount(this.forum.attribute('basePath')); super.mount(this.forum.attribute('basePath'));
alertEmailConfirmation(this); alertEmailConfirmation(this);
@ -161,8 +161,8 @@ export default class ForumApplication extends Application {
* will be reloaded. Otherwise, a SignUpModal will be opened, prefilled * will be reloaded. Otherwise, a SignUpModal will be opened, prefilled
* with the provided details. * with the provided details.
* *
* @param {Object} payload A dictionary of props to pass into the sign up * @param {Object} payload A dictionary of attrs to pass into the sign up
* modal. A truthy `loggedIn` prop indicates that the user has logged * modal. A truthy `loggedIn` attr indicates that the user has logged
* in, and thus the page is reloaded. * in, and thus the page is reloaded.
* @public * @public
*/ */

View File

@ -3,7 +3,6 @@ import compat from '../common/compat';
import PostControls from './utils/PostControls'; import PostControls from './utils/PostControls';
import KeyboardNavigatable from './utils/KeyboardNavigatable'; import KeyboardNavigatable from './utils/KeyboardNavigatable';
import slidable from './utils/slidable'; import slidable from './utils/slidable';
import affixSidebar from './utils/affixSidebar';
import History from './utils/History'; import History from './utils/History';
import DiscussionControls from './utils/DiscussionControls'; import DiscussionControls from './utils/DiscussionControls';
import alertEmailConfirmation from './utils/alertEmailConfirmation'; import alertEmailConfirmation from './utils/alertEmailConfirmation';
@ -15,6 +14,7 @@ import GlobalSearchState from './states/GlobalSearchState';
import NotificationListState from './states/NotificationListState'; import NotificationListState from './states/NotificationListState';
import PostStreamState from './states/PostStreamState'; import PostStreamState from './states/PostStreamState';
import SearchState from './states/SearchState'; import SearchState from './states/SearchState';
import AffixedSidebar from './components/AffixedSidebar';
import DiscussionPage from './components/DiscussionPage'; import DiscussionPage from './components/DiscussionPage';
import LogInModal from './components/LogInModal'; import LogInModal from './components/LogInModal';
import ComposerBody from './components/ComposerBody'; import ComposerBody from './components/ComposerBody';
@ -61,6 +61,7 @@ import NotificationList from './components/NotificationList';
import WelcomeHero from './components/WelcomeHero'; import WelcomeHero from './components/WelcomeHero';
import SignUpModal from './components/SignUpModal'; import SignUpModal from './components/SignUpModal';
import CommentPost from './components/CommentPost'; import CommentPost from './components/CommentPost';
import ComposerPostPreview from './components/ComposerPostPreview';
import ReplyComposer from './components/ReplyComposer'; import ReplyComposer from './components/ReplyComposer';
import NotificationsPage from './components/NotificationsPage'; import NotificationsPage from './components/NotificationsPage';
import PostStreamScrubber from './components/PostStreamScrubber'; import PostStreamScrubber from './components/PostStreamScrubber';
@ -77,7 +78,6 @@ export default Object.assign(compat, {
'utils/PostControls': PostControls, 'utils/PostControls': PostControls,
'utils/KeyboardNavigatable': KeyboardNavigatable, 'utils/KeyboardNavigatable': KeyboardNavigatable,
'utils/slidable': slidable, 'utils/slidable': slidable,
'utils/affixSidebar': affixSidebar,
'utils/History': History, 'utils/History': History,
'utils/DiscussionControls': DiscussionControls, 'utils/DiscussionControls': DiscussionControls,
'utils/alertEmailConfirmation': alertEmailConfirmation, 'utils/alertEmailConfirmation': alertEmailConfirmation,
@ -89,6 +89,7 @@ export default Object.assign(compat, {
'states/NotificationListState': NotificationListState, 'states/NotificationListState': NotificationListState,
'states/PostStreamState': PostStreamState, 'states/PostStreamState': PostStreamState,
'states/SearchState': SearchState, 'states/SearchState': SearchState,
'components/AffixedSidebar': AffixedSidebar,
'components/DiscussionPage': DiscussionPage, 'components/DiscussionPage': DiscussionPage,
'components/LogInModal': LogInModal, 'components/LogInModal': LogInModal,
'components/ComposerBody': ComposerBody, 'components/ComposerBody': ComposerBody,
@ -135,6 +136,7 @@ export default Object.assign(compat, {
'components/WelcomeHero': WelcomeHero, 'components/WelcomeHero': WelcomeHero,
'components/SignUpModal': SignUpModal, 'components/SignUpModal': SignUpModal,
'components/CommentPost': CommentPost, 'components/CommentPost': CommentPost,
'components/ComposerPostPreview': ComposerPostPreview,
'components/ReplyComposer': ReplyComposer, 'components/ReplyComposer': ReplyComposer,
'components/NotificationsPage': NotificationsPage, 'components/NotificationsPage': NotificationsPage,
'components/PostStreamScrubber': PostStreamScrubber, 'components/PostStreamScrubber': PostStreamScrubber,

View File

@ -0,0 +1,51 @@
import Component from '../../common/Component';
/**
* The `AffixedSidebar` component uses Bootstrap's "affix" plugin to keep a
* sidebar navigation at the top of the viewport when scrolling.
*
* ### Children
*
* The component must wrap an element that itself wraps an <ul> element, which
* will be "affixed".
*
* @see https://getbootstrap.com/docs/3.4/javascript/#affix
*/
export default class AffixedSidebar extends Component {
view(vnode) {
return vnode.children[0];
}
oncreate(vnode) {
super.oncreate(vnode);
// Register the affix plugin to execute on every window resize (and trigger)
this.boundOnresize = this.onresize.bind(this);
$(window).on('resize', this.boundOnresize).resize();
}
onremove() {
$(window).off('resize', this.boundOnresize);
}
onresize() {
const $sidebar = this.$();
const $header = $('#header');
const $footer = $('#footer');
const $affixElement = $sidebar.find('> ul');
$(window).off('.affix');
$affixElement.removeClass('affix affix-top affix-bottom').removeData('bs.affix');
// Don't affix the sidebar if it is taller than the viewport (otherwise
// there would be no way to scroll through its content).
if ($sidebar.outerHeight(true) > $(window).height() - $header.outerHeight(true)) return;
$affixElement.affix({
offset: {
top: () => $sidebar.offset().top - $header.outerHeight(true) - parseInt($sidebar.css('margin-top'), 10),
bottom: () => (this.bottom = $footer.outerHeight(true)),
},
});
}
}

View File

@ -3,6 +3,7 @@ import avatar from '../../common/helpers/avatar';
import icon from '../../common/helpers/icon'; import icon from '../../common/helpers/icon';
import listItems from '../../common/helpers/listItems'; import listItems from '../../common/helpers/listItems';
import ItemList from '../../common/utils/ItemList'; import ItemList from '../../common/utils/ItemList';
import classList from '../../common/utils/classList';
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
import LoadingIndicator from '../../common/components/LoadingIndicator'; import LoadingIndicator from '../../common/components/LoadingIndicator';
@ -10,13 +11,15 @@ import LoadingIndicator from '../../common/components/LoadingIndicator';
* The `AvatarEditor` component displays a user's avatar along with a dropdown * The `AvatarEditor` component displays a user's avatar along with a dropdown
* menu which allows the user to upload/remove the avatar. * menu which allows the user to upload/remove the avatar.
* *
* ### Props * ### Attrs
* *
* - `className` * - `className`
* - `user` * - `user`
*/ */
export default class AvatarEditor extends Component { export default class AvatarEditor extends Component {
init() { oninit(vnode) {
super.oninit(vnode);
/** /**
* Whether or not an avatar upload is in progress. * Whether or not an avatar upload is in progress.
* *
@ -32,17 +35,11 @@ export default class AvatarEditor extends Component {
this.isDraggedOver = false; this.isDraggedOver = false;
} }
static initProps(props) {
super.initProps(props);
props.className = props.className || '';
}
view() { view() {
const user = this.props.user; const user = this.attrs.user;
return ( return (
<div className={'AvatarEditor Dropdown ' + this.props.className + (this.loading ? ' loading' : '') + (this.isDraggedOver ? ' dragover' : '')}> <div className={classList(['AvatarEditor', 'Dropdown', this.attrs.className, this.loading && 'loading', this.isDraggedOver && 'dragover'])}>
{avatar(user)} {avatar(user)}
<a <a
className={user.avatarUrl() ? 'Dropdown-toggle' : 'Dropdown-toggle AvatarEditor--noAvatar'} className={user.avatarUrl() ? 'Dropdown-toggle' : 'Dropdown-toggle AvatarEditor--noAvatar'}
@ -55,7 +52,7 @@ export default class AvatarEditor extends Component {
ondragend={this.disableDragover.bind(this)} ondragend={this.disableDragover.bind(this)}
ondrop={this.dropUpload.bind(this)} ondrop={this.dropUpload.bind(this)}
> >
{this.loading ? LoadingIndicator.component() : user.avatarUrl() ? icon('fas fa-pencil-alt') : icon('fas fa-plus-circle')} {this.loading ? <LoadingIndicator /> : user.avatarUrl() ? icon('fas fa-pencil-alt') : icon('fas fa-plus-circle')}
</a> </a>
<ul className="Dropdown-menu Menu">{listItems(this.controlItems().toArray())}</ul> <ul className="Dropdown-menu Menu">{listItems(this.controlItems().toArray())}</ul>
</div> </div>
@ -72,20 +69,16 @@ export default class AvatarEditor extends Component {
items.add( items.add(
'upload', 'upload',
Button.component({ <Button icon="fas fa-upload" onclick={this.openPicker.bind(this)}>
icon: 'fas fa-upload', {app.translator.trans('core.forum.user.avatar_upload_button')}
children: app.translator.trans('core.forum.user.avatar_upload_button'), </Button>
onclick: this.openPicker.bind(this),
})
); );
items.add( items.add(
'remove', 'remove',
Button.component({ <Button icon="fas fa-times" onclick={this.remove.bind(this)}>
icon: 'fas fa-times', {app.translator.trans('core.forum.user.avatar_remove_button')}
children: app.translator.trans('core.forum.user.avatar_remove_button'), </Button>
onclick: this.remove.bind(this),
})
); );
return items; return items;
@ -134,7 +127,7 @@ export default class AvatarEditor extends Component {
* @param {Event} e * @param {Event} e
*/ */
quickUpload(e) { quickUpload(e) {
if (!this.props.user.avatarUrl()) { if (!this.attrs.user.avatarUrl()) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.openPicker(); this.openPicker();
@ -149,7 +142,6 @@ export default class AvatarEditor extends Component {
// Create a hidden HTML input element and click on it so the user can select // Create a hidden HTML input element and click on it so the user can select
// an avatar file. Once they have, we will upload it via the API. // an avatar file. Once they have, we will upload it via the API.
const user = this.props.user;
const $input = $('<input type="file">'); const $input = $('<input type="file">');
$input $input
@ -169,7 +161,7 @@ export default class AvatarEditor extends Component {
upload(file) { upload(file) {
if (this.loading) return; if (this.loading) return;
const user = this.props.user; const user = this.attrs.user;
const data = new FormData(); const data = new FormData();
data.append('avatar', file); data.append('avatar', file);
@ -179,9 +171,9 @@ export default class AvatarEditor extends Component {
app app
.request({ .request({
method: 'POST', method: 'POST',
url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar', url: `${app.forum.attribute('apiUrl')}/users/${user.id()}/avatar`,
serialize: (raw) => raw, serialize: (raw) => raw,
data, body: data,
}) })
.then(this.success.bind(this), this.failure.bind(this)); .then(this.success.bind(this), this.failure.bind(this));
} }
@ -190,7 +182,7 @@ export default class AvatarEditor extends Component {
* Remove the user's avatar. * Remove the user's avatar.
*/ */
remove() { remove() {
const user = this.props.user; const user = this.attrs.user;
this.loading = true; this.loading = true;
m.redraw(); m.redraw();
@ -198,7 +190,7 @@ export default class AvatarEditor extends Component {
app app
.request({ .request({
method: 'DELETE', method: 'DELETE',
url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar', url: `${app.forum.attribute('apiUrl')}/users/${user.id()}/avatar`,
}) })
.then(this.success.bind(this), this.failure.bind(this)); .then(this.success.bind(this), this.failure.bind(this));
} }
@ -212,7 +204,7 @@ export default class AvatarEditor extends Component {
*/ */
success(response) { success(response) {
app.store.pushPayload(response); app.store.pushPayload(response);
delete this.props.user.avatarColor; delete this.attrs.user.avatarColor;
this.loading = false; this.loading = false;
m.redraw(); m.redraw();

View File

@ -6,8 +6,8 @@ import Button from '../../common/components/Button';
* to change their email address. * to change their email address.
*/ */
export default class ChangeEmailModal extends Modal { export default class ChangeEmailModal extends Modal {
init() { oninit(vnode) {
super.init(); super.oninit(vnode);
/** /**
* Whether or not the email has been changed successfully. * Whether or not the email has been changed successfully.
@ -21,14 +21,14 @@ export default class ChangeEmailModal extends Modal {
* *
* @type {function} * @type {function}
*/ */
this.email = m.prop(app.session.user.email()); this.email = m.stream(app.session.user.email());
/** /**
* The value of the password input. * The value of the password input.
* *
* @type {function} * @type {function}
*/ */
this.password = m.prop(''); this.password = m.stream('');
} }
className() { className() {
@ -81,12 +81,14 @@ export default class ChangeEmailModal extends Modal {
/> />
</div> </div>
<div className="Form-group"> <div className="Form-group">
{Button.component({ {Button.component(
{
className: 'Button Button--primary Button--block', className: 'Button Button--primary Button--block',
type: 'submit', type: 'submit',
loading: this.loading, loading: this.loading,
children: app.translator.trans('core.forum.change_email.submit_button'), },
})} app.translator.trans('core.forum.change_email.submit_button')
)}
</div> </div>
</div> </div>
</div> </div>
@ -122,7 +124,7 @@ export default class ChangeEmailModal extends Modal {
onerror(error) { onerror(error) {
if (error.status === 401) { if (error.status === 401) {
error.alert.children = app.translator.trans('core.forum.change_email.incorrect_password_message'); error.alert.content = app.translator.trans('core.forum.change_email.incorrect_password_message');
} }
super.onerror(error); super.onerror(error);

View File

@ -20,12 +20,14 @@ export default class ChangePasswordModal extends Modal {
<div className="Form Form--centered"> <div className="Form Form--centered">
<p className="helpText">{app.translator.trans('core.forum.change_password.text')}</p> <p className="helpText">{app.translator.trans('core.forum.change_password.text')}</p>
<div className="Form-group"> <div className="Form-group">
{Button.component({ {Button.component(
{
className: 'Button Button--primary Button--block', className: 'Button Button--primary Button--block',
type: 'submit', type: 'submit',
loading: this.loading, loading: this.loading,
children: app.translator.trans('core.forum.change_password.send_button'), },
})} app.translator.trans('core.forum.change_password.send_button')
)}
</div> </div>
</div> </div>
</div> </div>
@ -41,7 +43,7 @@ export default class ChangePasswordModal extends Modal {
.request({ .request({
method: 'POST', method: 'POST',
url: app.forum.attribute('apiUrl') + '/forgot', url: app.forum.attribute('apiUrl') + '/forgot',
data: { email: app.session.user.email() }, body: { email: app.session.user.email() },
}) })
.then(this.hide.bind(this), this.loaded.bind(this)); .then(this.hide.bind(this), this.loaded.bind(this));
} }

View File

@ -1,5 +1,3 @@
/*global s9e, hljs*/
import Post from './Post'; import Post from './Post';
import classList from '../../common/utils/classList'; import classList from '../../common/utils/classList';
import PostUser from './PostUser'; import PostUser from './PostUser';
@ -9,19 +7,20 @@ import EditPostComposer from './EditPostComposer';
import ItemList from '../../common/utils/ItemList'; import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems'; import listItems from '../../common/helpers/listItems';
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
import ComposerPostPreview from './ComposerPostPreview';
/** /**
* The `CommentPost` component displays a standard `comment`-typed post. This * The `CommentPost` component displays a standard `comment`-typed post. This
* includes a number of item lists (controls, header, and footer) surrounding * includes a number of item lists (controls, header, and footer) surrounding
* the post's HTML content. * the post's HTML content.
* *
* ### Props * ### Attrs
* *
* - `post` * - `post`
*/ */
export default class CommentPost extends Post { export default class CommentPost extends Post {
init() { oninit(vnode) {
super.init(); super.oninit(vnode);
/** /**
* If the post has been hidden, then this flag determines whether or not its * If the post has been hidden, then this flag determines whether or not its
@ -41,48 +40,46 @@ export default class CommentPost extends Post {
this.subtree.check( this.subtree.check(
() => this.cardVisible, () => this.cardVisible,
() => this.isEditing() () => this.isEditing(),
() => this.revealContent
); );
} }
content() { content() {
// Note: we avoid using JSX for the <ul> below because it results in some return super.content().concat([
// weirdness in Mithril.js 0.1.x (see flarum/core#975). This workaround can <header className="Post-header">
// be reverted when we upgrade to Mithril 1.0. <ul>{listItems(this.headerItems().toArray())}</ul>
return super </header>,
.content()
.concat([
<header className="Post-header">{m('ul', listItems(this.headerItems().toArray()))}</header>,
<div className="Post-body"> <div className="Post-body">
{this.isEditing() ? <div className="Post-preview" config={this.configPreview.bind(this)} /> : m.trust(this.props.post.contentHtml())} {this.isEditing() ? <ComposerPostPreview className="Post-preview" composer={app.composer} /> : m.trust(this.attrs.post.contentHtml())}
</div>, </div>,
]); ]);
} }
config(isInitialized, context) { onupdate(vnode) {
super.config(...arguments); super.onupdate();
const contentHtml = this.isEditing() ? '' : this.props.post.contentHtml(); const contentHtml = this.isEditing() ? '' : this.attrs.post.contentHtml();
// If the post content has changed since the last render, we'll run through // 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 // all of the <script> tags in the content and evaluate them. This is
// necessary because TextFormatter outputs them for e.g. syntax highlighting. // necessary because TextFormatter outputs them for e.g. syntax highlighting.
if (context.contentHtml !== contentHtml) { if (this.contentHtml !== contentHtml) {
this.$('.Post-body script').each(function () { this.$('.Post-body script').each(function () {
eval.call(window, $(this).text()); eval.call(window, $(this).text());
}); });
} }
context.contentHtml = contentHtml; this.contentHtml = contentHtml;
} }
isEditing() { isEditing() {
return app.composer.bodyMatches(EditPostComposer, { post: this.props.post }); return app.composer.bodyMatches(EditPostComposer, { post: this.attrs.post });
} }
attrs() { elementAttrs() {
const post = this.props.post; const post = this.attrs.post;
const attrs = super.attrs(); const attrs = super.elementAttrs();
attrs.className = attrs.className =
(attrs.className || '') + (attrs.className || '') +
@ -98,27 +95,6 @@ export default class CommentPost extends Post {
return attrs; return attrs;
} }
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.fields.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. * Toggle the visibility of a hidden post's content.
*/ */
@ -133,7 +109,7 @@ export default class CommentPost extends Post {
*/ */
headerItems() { headerItems() {
const items = new ItemList(); const items = new ItemList();
const post = this.props.post; const post = this.attrs.post;
items.add( items.add(
'user', 'user',

View File

@ -11,13 +11,15 @@ import ComposerState from '../states/ComposerState';
* `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`. * `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`.
*/ */
export default class Composer extends Component { export default class Composer extends Component {
init() { oninit(vnode) {
super.oninit(vnode);
/** /**
* The composer's "state". * The composer's "state".
* *
* @type {ComposerState} * @type {ComposerState}
*/ */
this.state = this.props.state; this.state = this.attrs.state;
/** /**
* Whether or not the composer currently has focus. * Whether or not the composer currently has focus.
@ -45,7 +47,7 @@ export default class Composer extends Component {
return ( return (
<div className={'Composer ' + classList(classes)}> <div className={'Composer ' + classList(classes)}>
<div className="Composer-handle" config={this.configHandle.bind(this)} /> <div className="Composer-handle" oncreate={this.configHandle.bind(this)} />
<ul className="Composer-controls">{listItems(this.controlItems().toArray())}</ul> <ul className="Composer-controls">{listItems(this.controlItems().toArray())}</ul>
<div className="Composer-content" onclick={showIfMinimized}> <div className="Composer-content" onclick={showIfMinimized}>
{body.componentClass ? body.componentClass.component({ ...body.attrs, composer: this.state, disabled: classes.minimized }) : ''} {body.componentClass ? body.componentClass.component({ ...body.attrs, composer: this.state, disabled: classes.minimized }) : ''}
@ -54,7 +56,7 @@ export default class Composer extends Component {
); );
} }
config(isInitialized, context) { onupdate() {
if (this.state.position === this.prevPosition) { if (this.state.position === this.prevPosition) {
// Set the height of the Composer element and its contents on each redraw, // Set the height of the Composer element and its contents on each redraw,
// so that they do not lose it if their DOM elements are recreated. // so that they do not lose it if their DOM elements are recreated.
@ -64,12 +66,10 @@ export default class Composer extends Component {
this.prevPosition = this.state.position; this.prevPosition = this.state.position;
} }
}
if (isInitialized) return; oncreate(vnode) {
super.oncreate(vnode);
// Since this component is a part of the global UI that persists between
// routes, we will flag the DOM to be retained across route changes.
context.retain = true;
this.initializeHeight(); this.initializeHeight();
this.$().hide().css('bottom', -this.state.computedHeight()); this.$().hide().css('bottom', -this.state.computedHeight());
@ -84,36 +84,31 @@ export default class Composer extends Component {
// When the escape key is pressed on any inputs, close the composer. // When the escape key is pressed on any inputs, close the composer.
this.$().on('keydown', ':input', 'esc', () => this.state.close()); this.$().on('keydown', ':input', 'esc', () => this.state.close());
const handlers = {}; this.handlers = {};
$(window) $(window)
.on('resize', (handlers.onresize = this.updateHeight.bind(this))) .on('resize', (this.handlers.onresize = this.updateHeight.bind(this)))
.resize(); .resize();
$(document) $(document)
.on('mousemove', (handlers.onmousemove = this.onmousemove.bind(this))) .on('mousemove', (this.handlers.onmousemove = this.onmousemove.bind(this)))
.on('mouseup', (handlers.onmouseup = this.onmouseup.bind(this))); .on('mouseup', (this.handlers.onmouseup = this.onmouseup.bind(this)));
}
context.onunload = () => { onremove() {
$(window).off('resize', handlers.onresize); $(window).off('resize', this.handlers.onresize);
$(document).off('mousemove', handlers.onmousemove).off('mouseup', handlers.onmouseup); $(document).off('mousemove', this.handlers.onmousemove).off('mouseup', this.handlers.onmouseup);
};
} }
/** /**
* Add the necessary event handlers to the composer's handle so that it can * Add the necessary event handlers to the composer's handle so that it can
* be used to resize the composer. * be used to resize the composer.
*
* @param {DOMElement} element
* @param {Boolean} isInitialized
*/ */
configHandle(element, isInitialized) { configHandle(vnode) {
if (isInitialized) return;
const composer = this; const composer = this;
$(element) $(vnode.dom)
.css('cursor', 'row-resize') .css('cursor', 'row-resize')
.bind('dragstart mousedown', (e) => e.preventDefault()) .bind('dragstart mousedown', (e) => e.preventDefault())
.mousedown(function (e) { .mousedown(function (e) {

View File

@ -11,7 +11,7 @@ import ItemList from '../../common/utils/ItemList';
* composer. Subclasses should implement the `onsubmit` method and override * composer. Subclasses should implement the `onsubmit` method and override
* `headerTimes`. * `headerTimes`.
* *
* ### Props * ### Attrs
* *
* - `composer` * - `composer`
* - `originalContent` * - `originalContent`
@ -24,8 +24,10 @@ import ItemList from '../../common/utils/ItemList';
* @abstract * @abstract
*/ */
export default class ComposerBody extends Component { export default class ComposerBody extends Component {
init() { oninit(vnode) {
this.composer = this.props.composer; super.oninit(vnode);
this.composer = this.attrs.composer;
/** /**
* Whether or not the component is loading. * Whether or not the component is loading.
@ -37,11 +39,11 @@ export default class ComposerBody extends Component {
// Let the composer state know to ask for confirmation under certain // Let the composer state know to ask for confirmation under certain
// circumstances, if the body supports / requires it and has a corresponding // circumstances, if the body supports / requires it and has a corresponding
// confirmation question to ask. // confirmation question to ask.
if (this.props.confirmExit) { if (this.attrs.confirmExit) {
this.composer.preventClosingWhen(() => this.hasChanges(), this.props.confirmExit); this.composer.preventClosingWhen(() => this.hasChanges(), this.attrs.confirmExit);
} }
this.composer.fields.content(this.props.originalContent || ''); this.composer.fields.content(this.attrs.originalContent || '');
/** /**
* @deprecated BC layer, remove in Beta 15. * @deprecated BC layer, remove in Beta 15.
@ -53,15 +55,15 @@ export default class ComposerBody extends Component {
view() { view() {
return ( return (
<ConfirmDocumentUnload when={this.hasChanges.bind(this)}> <ConfirmDocumentUnload when={this.hasChanges.bind(this)}>
<div className={'ComposerBody ' + (this.props.className || '')}> <div className={'ComposerBody ' + (this.attrs.className || '')}>
{avatar(this.props.user, { className: 'ComposerBody-avatar' })} {avatar(this.attrs.user, { className: 'ComposerBody-avatar' })}
<div className="ComposerBody-content"> <div className="ComposerBody-content">
<ul className="ComposerBody-header">{listItems(this.headerItems().toArray())}</ul> <ul className="ComposerBody-header">{listItems(this.headerItems().toArray())}</ul>
<div className="ComposerBody-editor"> <div className="ComposerBody-editor">
{TextEditor.component({ {TextEditor.component({
submitLabel: this.props.submitLabel, submitLabel: this.attrs.submitLabel,
placeholder: this.props.placeholder, placeholder: this.attrs.placeholder,
disabled: this.loading || this.props.disabled, disabled: this.loading || this.attrs.disabled,
composer: this.composer, composer: this.composer,
preview: this.jumpToPreview && this.jumpToPreview.bind(this), preview: this.jumpToPreview && this.jumpToPreview.bind(this),
onchange: this.composer.fields.content, onchange: this.composer.fields.content,
@ -84,7 +86,7 @@ export default class ComposerBody extends Component {
hasChanges() { hasChanges() {
const content = this.composer.fields.content(); const content = this.composer.fields.content();
return content && content !== this.props.originalContent; return content && content !== this.attrs.originalContent;
} }
/** /**

View File

@ -5,9 +5,9 @@ import Button from '../../common/components/Button';
* controls. * controls.
*/ */
export default class ComposerButton extends Button { export default class ComposerButton extends Button {
static initProps(props) { static initAttrs(attrs) {
super.initProps(props); super.initAttrs(attrs);
props.className = props.className || 'Button Button--icon Button--link'; attrs.className = attrs.className || 'Button Button--icon Button--link';
} }
} }

View File

@ -0,0 +1,54 @@
/*global s9e*/
import Component from '../../common/Component';
/**
* The `ComposerPostPreview` component renders Markdown as HTML using the
* TextFormatter library, polling a data source for changes every 50ms. This is
* done to prevent expensive redraws on e.g. every single keystroke, while
* still retaining the perception of live updates for the user.
*
* ### Attrs
*
* - `composer` The state of the composer controlling this preview.
* - `className` A CSS class for the element surrounding the preview.
* - `surround` A callback that can execute code before and after re-render, e.g. for scroll anchoring.
*/
export default class ComposerPostPreview extends Component {
static initAttrs(attrs) {
attrs.className = attrs.className || '';
attrs.surround = attrs.surround || ((preview) => preview());
}
view() {
return <div className={this.attrs.className} />;
}
oncreate(vnode) {
super.oncreate(vnode);
// Every 50ms, if the composer content has changed, then update the post's
// body with a preview.
let preview;
const updatePreview = () => {
// Since we're polling, the composer may have been closed in the meantime,
// so we bail in that case.
if (!this.attrs.composer.isVisible()) return;
const content = this.attrs.composer.fields.content();
if (preview === content) return;
preview = content;
this.attrs.surround(() => s9e.TextFormatter.preview(preview || '', vnode.dom));
};
updatePreview();
this.updateInterval = setInterval(updatePreview, 50);
}
onremove() {
clearInterval(this.updateInterval);
}
}

View File

@ -7,16 +7,26 @@ import extractText from '../../common/utils/extractText';
* enter the title of their discussion. It also overrides the `submit` and * enter the title of their discussion. It also overrides the `submit` and
* `willExit` actions to account for the title. * `willExit` actions to account for the title.
* *
* ### Props * ### Attrs
* *
* - All of the props for ComposerBody * - All of the attrs for ComposerBody
* - `titlePlaceholder` * - `titlePlaceholder`
*/ */
export default class DiscussionComposer extends ComposerBody { export default class DiscussionComposer extends ComposerBody {
init() { static initAttrs(attrs) {
super.init(); super.initAttrs(attrs);
this.composer.fields.title = this.composer.fields.title || m.prop(''); attrs.placeholder = attrs.placeholder || extractText(app.translator.trans('core.forum.composer_discussion.body_placeholder'));
attrs.submitLabel = attrs.submitLabel || app.translator.trans('core.forum.composer_discussion.submit_button');
attrs.confirmExit = attrs.confirmExit || extractText(app.translator.trans('core.forum.composer_discussion.discard_confirmation'));
attrs.titlePlaceholder = attrs.titlePlaceholder || extractText(app.translator.trans('core.forum.composer_discussion.title_placeholder'));
attrs.className = 'ComposerBody--discussion';
}
oninit(vnode) {
super.oninit(vnode);
this.composer.fields.title = this.composer.fields.title || m.stream('');
/** /**
* The value of the title input. * The value of the title input.
@ -26,16 +36,6 @@ export default class DiscussionComposer extends ComposerBody {
this.title = this.composer.fields.title; this.title = this.composer.fields.title;
} }
static initProps(props) {
super.initProps(props);
props.placeholder = props.placeholder || extractText(app.translator.trans('core.forum.composer_discussion.body_placeholder'));
props.submitLabel = props.submitLabel || app.translator.trans('core.forum.composer_discussion.submit_button');
props.confirmExit = props.confirmExit || extractText(app.translator.trans('core.forum.composer_discussion.discard_confirmation'));
props.titlePlaceholder = props.titlePlaceholder || extractText(app.translator.trans('core.forum.composer_discussion.title_placeholder'));
props.className = 'ComposerBody--discussion';
}
headerItems() { headerItems() {
const items = super.headerItems(); const items = super.headerItems();
@ -46,10 +46,9 @@ export default class DiscussionComposer extends ComposerBody {
<h3> <h3>
<input <input
className="FormControl" className="FormControl"
value={this.title()} bidi={this.title}
oninput={m.withAttr('value', this.title)} placeholder={this.attrs.titlePlaceholder}
placeholder={this.props.titlePlaceholder} disabled={!!this.attrs.disabled}
disabled={!!this.props.disabled}
onkeydown={this.onkeydown.bind(this)} onkeydown={this.onkeydown.bind(this)}
/> />
</h3> </h3>
@ -71,7 +70,7 @@ export default class DiscussionComposer extends ComposerBody {
this.composer.editor.moveCursorTo(0); this.composer.editor.moveCursorTo(0);
} }
m.redraw.strategy('none'); e.redraw = false;
} }
hasChanges() { hasChanges() {
@ -101,7 +100,7 @@ export default class DiscussionComposer extends ComposerBody {
.then((discussion) => { .then((discussion) => {
this.composer.hide(); this.composer.hide();
app.discussions.refresh(); app.discussions.refresh();
m.route(app.route.discussion(discussion)); m.route.set(app.route.discussion(discussion));
}, this.loaded.bind(this)); }, this.loaded.bind(this));
} }
} }

View File

@ -5,7 +5,7 @@ import listItems from '../../common/helpers/listItems';
/** /**
* The `DiscussionHero` component displays the hero on a discussion page. * The `DiscussionHero` component displays the hero on a discussion page.
* *
* ### Props * ### attrs
* *
* - `discussion` * - `discussion`
*/ */
@ -27,7 +27,7 @@ export default class DiscussionHero extends Component {
*/ */
items() { items() {
const items = new ItemList(); const items = new ItemList();
const discussion = this.props.discussion; const discussion = this.attrs.discussion;
const badges = discussion.badges().toArray(); const badges = discussion.badges().toArray();
if (badges.length) { if (badges.length) {

View File

@ -7,17 +7,13 @@ import Placeholder from '../../common/components/Placeholder';
/** /**
* The `DiscussionList` component displays a list of discussions. * The `DiscussionList` component displays a list of discussions.
* *
* ### Props * ### Attrs
* *
* - `state` A DiscussionListState object that represents the discussion lists's state. * - `state` A DiscussionListState object that represents the discussion lists's state.
*/ */
export default class DiscussionList extends Component { export default class DiscussionList extends Component {
init() {
this.state = this.props.state;
}
view() { view() {
const state = this.state; const state = this.attrs.state;
const params = state.getParams(); const params = state.getParams();
let loading; let loading;
@ -25,11 +21,13 @@ export default class DiscussionList extends Component {
if (state.isLoading()) { if (state.isLoading()) {
loading = LoadingIndicator.component(); loading = LoadingIndicator.component();
} else if (state.moreResults) { } else if (state.moreResults) {
loading = Button.component({ loading = Button.component(
children: app.translator.trans('core.forum.discussion_list.load_more_button'), {
className: 'Button', className: 'Button',
onclick: state.loadMore.bind(state), onclick: state.loadMore.bind(state),
}); },
app.translator.trans('core.forum.discussion_list.load_more_button')
);
} }
if (state.empty()) { if (state.empty()) {

View File

@ -8,7 +8,6 @@ import ItemList from '../../common/utils/ItemList';
import abbreviateNumber from '../../common/utils/abbreviateNumber'; import abbreviateNumber from '../../common/utils/abbreviateNumber';
import Dropdown from '../../common/components/Dropdown'; import Dropdown from '../../common/components/Dropdown';
import TerminalPost from './TerminalPost'; import TerminalPost from './TerminalPost';
import PostPreview from './PostPreview';
import SubtreeRetainer from '../../common/utils/SubtreeRetainer'; import SubtreeRetainer from '../../common/utils/SubtreeRetainer';
import DiscussionControls from '../utils/DiscussionControls'; import DiscussionControls from '../utils/DiscussionControls';
import slidable from '../utils/slidable'; import slidable from '../utils/slidable';
@ -20,13 +19,15 @@ import { escapeRegExp } from 'lodash-es';
* The `DiscussionListItem` component shows a single discussion in the * The `DiscussionListItem` component shows a single discussion in the
* discussion list. * discussion list.
* *
* ### Props * ### Attrs
* *
* - `discussion` * - `discussion`
* - `params` * - `params`
*/ */
export default class DiscussionListItem extends Component { export default class DiscussionListItem extends Component {
init() { oninit(vnode) {
super.oninit(vnode);
/** /**
* Set up a subtree retainer so that the discussion will not be redrawn * Set up a subtree retainer so that the discussion will not be redrawn
* unless new data comes in. * unless new data comes in.
@ -34,7 +35,7 @@ export default class DiscussionListItem extends Component {
* @type {SubtreeRetainer} * @type {SubtreeRetainer}
*/ */
this.subtree = new SubtreeRetainer( this.subtree = new SubtreeRetainer(
() => this.props.discussion.freshness, () => this.attrs.discussion.freshness,
() => { () => {
const time = app.session.user && app.session.user.markedAllAsReadAt(); const time = app.session.user && app.session.user.markedAllAsReadAt();
return time && time.getTime(); return time && time.getTime();
@ -43,37 +44,33 @@ export default class DiscussionListItem extends Component {
); );
} }
attrs() { elementAttrs() {
return { return {
className: classList([ className: classList([
'DiscussionListItem', 'DiscussionListItem',
this.active() ? 'active' : '', this.active() ? 'active' : '',
this.props.discussion.isHidden() ? 'DiscussionListItem--hidden' : '', this.attrs.discussion.isHidden() ? 'DiscussionListItem--hidden' : '',
]), ]),
}; };
} }
view() { view() {
const retain = this.subtree.retain(); const discussion = this.attrs.discussion;
if (retain) return retain;
const discussion = this.props.discussion;
const user = discussion.user(); const user = discussion.user();
const isUnread = discussion.isUnread(); const isUnread = discussion.isUnread();
const isRead = discussion.isRead(); const isRead = discussion.isRead();
const showUnread = !this.showRepliesCount() && isUnread; const showUnread = !this.showRepliesCount() && isUnread;
let jumpTo = 0; let jumpTo = 0;
const controls = DiscussionControls.controls(discussion, this).toArray(); const controls = DiscussionControls.controls(discussion, this).toArray();
const attrs = this.attrs(); const attrs = this.elementAttrs();
if (this.props.params.q) { if (this.attrs.params.q) {
const post = discussion.mostRelevantPost(); const post = discussion.mostRelevantPost();
if (post) { if (post) {
jumpTo = post.number(); jumpTo = post.number();
} }
const phrase = escapeRegExp(this.props.params.q); const phrase = escapeRegExp(this.attrs.params.q);
this.highlightRegExp = new RegExp(phrase + '|' + phrase.trim().replace(/\s+/g, '|'), 'gi'); this.highlightRegExp = new RegExp(phrase + '|' + phrase.trim().replace(/\s+/g, '|'), 'gi');
} else { } else {
jumpTo = Math.min(discussion.lastPostNumber(), (discussion.lastReadPostNumber() || 0) + 1); jumpTo = Math.min(discussion.lastPostNumber(), (discussion.lastReadPostNumber() || 0) + 1);
@ -82,12 +79,14 @@ export default class DiscussionListItem extends Component {
return ( return (
<div {...attrs}> <div {...attrs}>
{controls.length {controls.length
? Dropdown.component({ ? Dropdown.component(
{
icon: 'fas fa-ellipsis-v', icon: 'fas fa-ellipsis-v',
children: controls,
className: 'DiscussionListItem-controls', className: 'DiscussionListItem-controls',
buttonClassName: 'Button Button--icon Button--flat Slidable-underneath Slidable-underneath--right', buttonClassName: 'Button Button--icon Button--flat Slidable-underneath Slidable-underneath--right',
}) },
controls
)
: ''} : ''}
<a <a
@ -99,14 +98,13 @@ export default class DiscussionListItem extends Component {
<div className={'DiscussionListItem-content Slidable-content' + (isUnread ? ' unread' : '') + (isRead ? ' read' : '')}> <div className={'DiscussionListItem-content Slidable-content' + (isUnread ? ' unread' : '') + (isRead ? ' read' : '')}>
<a <a
href={user ? app.route.user(user) : '#'} route={user ? app.route.user(user) : '#'}
className="DiscussionListItem-author" className="DiscussionListItem-author"
title={extractText( title={extractText(
app.translator.trans('core.forum.discussion_list.started_text', { user: user, ago: humanTime(discussion.createdAt()) }) app.translator.trans('core.forum.discussion_list.started_text', { user: user, ago: humanTime(discussion.createdAt()) })
)} )}
config={function (element) { oncreate={function (vnode) {
$(element).tooltip({ placement: 'right' }); $(vnode.dom).tooltip({ placement: 'right' });
m.route.apply(this, arguments);
}} }}
> >
{avatar(user, { title: '' })} {avatar(user, { title: '' })}
@ -114,7 +112,7 @@ export default class DiscussionListItem extends Component {
<ul className="DiscussionListItem-badges badges">{listItems(discussion.badges().toArray())}</ul> <ul className="DiscussionListItem-badges badges">{listItems(discussion.badges().toArray())}</ul>
<a href={app.route.discussion(discussion, jumpTo)} config={m.route} className="DiscussionListItem-main"> <a route={app.route.discussion(discussion, jumpTo)} className="DiscussionListItem-main">
<h3 className="DiscussionListItem-title">{highlight(discussion.title(), this.highlightRegExp)}</h3> <h3 className="DiscussionListItem-title">{highlight(discussion.title(), this.highlightRegExp)}</h3>
<ul className="DiscussionListItem-info">{listItems(this.infoItems().toArray())}</ul> <ul className="DiscussionListItem-info">{listItems(this.infoItems().toArray())}</ul>
</a> </a>
@ -131,8 +129,8 @@ export default class DiscussionListItem extends Component {
); );
} }
config(isInitialized) { oncreate(vnode) {
if (isInitialized) return; super.oncreate(vnode);
// If we're on a touch device, set up the discussion row to be slidable. // If we're on a touch device, set up the discussion row to be slidable.
// This allows the user to drag the row to either side of the screen to // This allows the user to drag the row to either side of the screen to
@ -144,6 +142,12 @@ export default class DiscussionListItem extends Component {
} }
} }
onbeforeupdate(vnode, old) {
super.onbeforeupdate(vnode, old);
return this.subtree.needsRebuild();
}
/** /**
* Determine whether or not the discussion is currently being viewed. * Determine whether or not the discussion is currently being viewed.
* *
@ -152,7 +156,7 @@ export default class DiscussionListItem extends Component {
active() { active() {
const idParam = m.route.param('id'); const idParam = m.route.param('id');
return idParam && idParam.split('-')[0] === this.props.discussion.id(); return idParam && idParam.split('-')[0] === this.attrs.discussion.id();
} }
/** /**
@ -163,7 +167,7 @@ export default class DiscussionListItem extends Component {
* @return {Boolean} * @return {Boolean}
*/ */
showFirstPost() { showFirstPost() {
return ['newest', 'oldest'].indexOf(this.props.params.sort) !== -1; return ['newest', 'oldest'].indexOf(this.attrs.params.sort) !== -1;
} }
/** /**
@ -173,14 +177,14 @@ export default class DiscussionListItem extends Component {
* @return {Boolean} * @return {Boolean}
*/ */
showRepliesCount() { showRepliesCount() {
return this.props.params.sort === 'replies'; return this.attrs.params.sort === 'replies';
} }
/** /**
* Mark the discussion as read. * Mark the discussion as read.
*/ */
markAsRead() { markAsRead() {
const discussion = this.props.discussion; const discussion = this.attrs.discussion;
if (discussion.isUnread()) { if (discussion.isUnread()) {
discussion.save({ lastReadPostNumber: discussion.lastPostNumber() }); discussion.save({ lastReadPostNumber: discussion.lastPostNumber() });
@ -197,8 +201,8 @@ export default class DiscussionListItem extends Component {
infoItems() { infoItems() {
const items = new ItemList(); const items = new ItemList();
if (this.props.params.q) { if (this.attrs.params.q) {
const post = this.props.discussion.mostRelevantPost() || this.props.discussion.firstPost(); const post = this.attrs.discussion.mostRelevantPost() || this.attrs.discussion.firstPost();
if (post && post.contentType() === 'comment') { if (post && post.contentType() === 'comment') {
const excerpt = highlight(post.contentPlain(), this.highlightRegExp, 175); const excerpt = highlight(post.contentPlain(), this.highlightRegExp, 175);
@ -208,7 +212,7 @@ export default class DiscussionListItem extends Component {
items.add( items.add(
'terminalPost', 'terminalPost',
TerminalPost.component({ TerminalPost.component({
discussion: this.props.discussion, discussion: this.attrs.discussion,
lastPost: !this.showFirstPost(), lastPost: !this.showFirstPost(),
}) })
); );

View File

@ -0,0 +1,67 @@
import DiscussionList from './DiscussionList';
import Component from '../../common/Component';
const hotEdge = (e) => {
if (e.pageX < 10) app.pane.show();
};
/**
* The `DiscussionListPane` component displays the list of previously viewed
* discussions in a panel that can be displayed by moving the mouse to the left
* edge of the screen, where it can also be pinned in place.
*
* ### Attrs
*
* - `state` A DiscussionListState object that represents the discussion lists's state.
*/
export default class DiscussionListPane extends Component {
view() {
if (!this.attrs.state.hasDiscussions()) {
return;
}
return <div className="DiscussionPage-list">{this.enoughSpace() && <DiscussionList state={this.attrs.state} />}</div>;
}
oncreate(vnode) {
super.oncreate(vnode);
const $list = $(vnode.dom);
// When the mouse enters and leaves the discussions pane, we want to show
// and hide the pane respectively. We also create a 10px 'hot edge' on the
// left of the screen to activate the pane.
const pane = app.pane;
$list.hover(pane.show.bind(pane), pane.onmouseleave.bind(pane));
$(document).on('mousemove', hotEdge);
// If the discussion we are viewing is listed in the discussion list, then
// we will make sure it is visible in the viewport if it is not we will
// scroll the list down to it.
const $discussion = $list.find('.DiscussionListItem.active');
if ($discussion.length) {
const listTop = $list.offset().top;
const listBottom = listTop + $list.outerHeight();
const discussionTop = $discussion.offset().top;
const discussionBottom = discussionTop + $discussion.outerHeight();
if (discussionTop < listTop || discussionBottom > listBottom) {
$list.scrollTop($list.scrollTop() - listTop + discussionTop);
}
}
}
onremove() {
$(document).off('mousemove', hotEdge);
}
/**
* Are we on a device that's larger than we consider "mobile"?
*
* @returns {boolean}
*/
enoughSpace() {
return !$('.App-navigation').is(':visible');
}
}

View File

@ -1,13 +1,13 @@
import Page from '../../common/components/Page'; import Page from '../../common/components/Page';
import ItemList from '../../common/utils/ItemList'; import ItemList from '../../common/utils/ItemList';
import DiscussionHero from './DiscussionHero'; import DiscussionHero from './DiscussionHero';
import DiscussionListPane from './DiscussionListPane';
import PostStream from './PostStream'; import PostStream from './PostStream';
import PostStreamScrubber from './PostStreamScrubber'; import PostStreamScrubber from './PostStreamScrubber';
import LoadingIndicator from '../../common/components/LoadingIndicator'; import LoadingIndicator from '../../common/components/LoadingIndicator';
import SplitDropdown from '../../common/components/SplitDropdown'; import SplitDropdown from '../../common/components/SplitDropdown';
import listItems from '../../common/helpers/listItems'; import listItems from '../../common/helpers/listItems';
import DiscussionControls from '../utils/DiscussionControls'; import DiscussionControls from '../utils/DiscussionControls';
import DiscussionList from './DiscussionList';
import PostStreamState from '../states/PostStreamState'; import PostStreamState from '../states/PostStreamState';
/** /**
@ -15,8 +15,8 @@ import PostStreamState from '../states/PostStreamState';
* the discussion list pane, the hero, the posts, and the sidebar. * the discussion list pane, the hero, the posts, and the sidebar.
*/ */
export default class DiscussionPage extends Page { export default class DiscussionPage extends Page {
init() { oninit(vnode) {
super.init(); super.oninit(vnode);
/** /**
* The discussion that is being viewed. * The discussion that is being viewed.
@ -42,38 +42,16 @@ export default class DiscussionPage extends Page {
if (app.discussions.hasDiscussions()) { if (app.discussions.hasDiscussions()) {
app.pane.enable(); app.pane.enable();
app.pane.hide(); app.pane.hide();
if (app.previous.matches(DiscussionPage)) {
m.redraw.strategy('diff');
}
} }
app.history.push('discussion'); app.history.push('discussion');
this.bodyClass = 'App--discussion'; this.bodyClass = 'App--discussion';
this.prevRoute = m.route.get();
} }
onunload(e) { onremove() {
// If we have routed to the same discussion as we were viewing previously,
// cancel the unloading of this controller and instead prompt the post
// stream to jump to the new 'near' param.
if (this.discussion) {
const idParam = m.route.param('id');
if (idParam && idParam.split('-')[0] === this.discussion.id()) {
e.preventDefault();
const near = m.route.param('near') || '1';
if (near !== String(this.near)) {
this.stream.goToNumber(near);
}
this.near = null;
return;
}
}
// If we are indeed navigating away from this discussion, then disable the // If we are indeed navigating away from this discussion, then disable the
// discussion list pane. Also, if we're composing a reply to this // discussion list pane. Also, if we're composing a reply to this
// discussion, minimize the composer unless it's empty, in which case // discussion, minimize the composer unless it's empty, in which case
@ -92,14 +70,7 @@ export default class DiscussionPage extends Page {
return ( return (
<div className="DiscussionPage"> <div className="DiscussionPage">
{app.discussions.hasDiscussions() ? ( <DiscussionListPane state={app.discussions} />
<div className="DiscussionPage-list" config={this.configPane.bind(this)}>
{!$('.App-navigation').is(':visible') && <DiscussionList state={app.discussions} />}
</div>
) : (
''
)}
<div className="DiscussionPage-discussion"> <div className="DiscussionPage-discussion">
{discussion {discussion
? [ ? [
@ -124,11 +95,30 @@ export default class DiscussionPage extends Page {
); );
} }
config(...args) { onbeforeupdate(vnode) {
super.config(...args); super.onbeforeupdate(vnode);
if (m.route.get() !== this.prevRoute) {
this.prevRoute = m.route.get();
// If we have routed to the same discussion as we were viewing previously,
// cancel the unloading of this controller and instead prompt the post
// stream to jump to the new 'near' param.
if (this.discussion) { if (this.discussion) {
app.setTitle(this.discussion.title()); const idParam = m.route.param('id');
if (idParam && idParam.split('-')[0] === this.discussion.id()) {
const near = m.route.param('near') || '1';
if (near !== String(this.near)) {
this.stream.goToNumber(near);
}
this.near = near;
} else {
this.oninit(vnode);
}
}
} }
} }
@ -149,7 +139,7 @@ export default class DiscussionPage extends Page {
app.store.find('discussions', m.route.param('id').split('-')[0], params).then(this.show.bind(this)); app.store.find('discussions', m.route.param('id').split('-')[0], params).then(this.show.bind(this));
} }
m.lazyRedraw(); m.redraw();
} }
/** /**
@ -173,6 +163,7 @@ export default class DiscussionPage extends Page {
this.discussion = discussion; this.discussion = discussion;
app.history.push('discussion', discussion.title()); app.history.push('discussion', discussion.title());
app.setTitle(this.discussion.title());
app.setTitleCount(0); app.setTitleCount(0);
// When the API responds with a discussion, it will also include a number of // When the API responds with a discussion, it will also include a number of
@ -209,48 +200,6 @@ export default class DiscussionPage extends Page {
app.current.set('stream', this.stream); app.current.set('stream', this.stream);
} }
/**
* Configure the discussion list pane.
*
* @param {DOMElement} element
* @param {Boolean} isInitialized
* @param {Object} context
*/
configPane(element, isInitialized, context) {
if (isInitialized) return;
context.retain = true;
const $list = $(element);
// When the mouse enters and leaves the discussions pane, we want to show
// and hide the pane respectively. We also create a 10px 'hot edge' on the
// left of the screen to activate the pane.
const pane = app.pane;
$list.hover(pane.show.bind(pane), pane.onmouseleave.bind(pane));
const hotEdge = (e) => {
if (e.pageX < 10) pane.show();
};
$(document).on('mousemove', hotEdge);
context.onunload = () => $(document).off('mousemove', hotEdge);
// If the discussion we are viewing is listed in the discussion list, then
// we will make sure it is visible in the viewport if it is not we will
// scroll the list down to it.
const $discussion = $list.find('.DiscussionListItem.active');
if ($discussion.length) {
const listTop = $list.offset().top;
const listBottom = listTop + $list.outerHeight();
const discussionTop = $discussion.offset().top;
const discussionBottom = discussionTop + $discussion.outerHeight();
if (discussionTop < listTop || discussionBottom > listBottom) {
$list.scrollTop($list.scrollTop() - listTop + discussionTop);
}
}
}
/** /**
* Build an item list for the contents of the sidebar. * Build an item list for the contents of the sidebar.
* *
@ -261,12 +210,14 @@ export default class DiscussionPage extends Page {
items.add( items.add(
'controls', 'controls',
SplitDropdown.component({ SplitDropdown.component(
children: DiscussionControls.controls(this.discussion, this).toArray(), {
icon: 'fas fa-ellipsis-v', icon: 'fas fa-ellipsis-v',
className: 'App-primaryControl', className: 'App-primaryControl',
buttonClassName: 'Button--primary', buttonClassName: 'Button--primary',
}) },
DiscussionControls.controls(this.discussion, this).toArray()
)
); );
items.add( items.add(
@ -295,7 +246,8 @@ export default class DiscussionPage extends Page {
// replace it into the window's history and our own history stack. // replace it into the window's history and our own history stack.
const url = app.route.discussion(discussion, (this.near = startNumber)); const url = app.route.discussion(discussion, (this.near = startNumber));
m.route(url, true); this.prevRoute = url;
m.route.set(url, null, { replace: true });
window.history.replaceState(null, document.title, url); window.history.replaceState(null, document.title, url);
app.history.push('discussion', discussion.title()); app.history.push('discussion', discussion.title());

View File

@ -4,9 +4,9 @@ import Notification from './Notification';
* The `DiscussionRenamedNotification` component displays a notification which * The `DiscussionRenamedNotification` component displays a notification which
* indicates that a discussion has had its title changed. * indicates that a discussion has had its title changed.
* *
* ### Props * ### Attrs
* *
* - All of the props for Notification * - All of the attrs for Notification
*/ */
export default class DiscussionRenamedNotification extends Notification { export default class DiscussionRenamedNotification extends Notification {
icon() { icon() {
@ -14,12 +14,12 @@ export default class DiscussionRenamedNotification extends Notification {
} }
href() { href() {
const notification = this.props.notification; const notification = this.attrs.notification;
return app.route.discussion(notification.subject(), notification.content().postNumber); return app.route.discussion(notification.subject(), notification.content().postNumber);
} }
content() { content() {
return app.translator.trans('core.forum.notifications.discussion_renamed_text', { user: this.props.notification.fromUser() }); return app.translator.trans('core.forum.notifications.discussion_renamed_text', { user: this.attrs.notification.fromUser() });
} }
} }

View File

@ -5,9 +5,9 @@ import extractText from '../../common/utils/extractText';
* The `DiscussionRenamedPost` component displays a discussion event post * The `DiscussionRenamedPost` component displays a discussion event post
* indicating that the discussion has been renamed. * indicating that the discussion has been renamed.
* *
* ### Props * ### Attrs
* *
* - All of the props for EventPost * - All of the attrs for EventPost
*/ */
export default class DiscussionRenamedPost extends EventPost { export default class DiscussionRenamedPost extends EventPost {
icon() { icon() {
@ -22,7 +22,7 @@ export default class DiscussionRenamedPost extends EventPost {
} }
descriptionData() { descriptionData() {
const post = this.props.post; const post = this.attrs.post;
const oldTitle = post.content()[0]; const oldTitle = post.content()[0];
const newTitle = post.content()[1]; const newTitle = post.content()[1];

View File

@ -34,18 +34,20 @@ export default class DiscussionsSearchSource {
return [ return [
<li className="Dropdown-header">{app.translator.trans('core.forum.search.discussions_heading')}</li>, <li className="Dropdown-header">{app.translator.trans('core.forum.search.discussions_heading')}</li>,
<li> <li>
{LinkButton.component({ {LinkButton.component(
{
icon: 'fas fa-search', icon: 'fas fa-search',
children: app.translator.trans('core.forum.search.all_discussions_button', { query }),
href: app.route('index', { q: query }), href: app.route('index', { q: query }),
})} },
app.translator.trans('core.forum.search.all_discussions_button', { query })
)}
</li>, </li>,
results.map((discussion) => { results.map((discussion) => {
const mostRelevantPost = discussion.mostRelevantPost(); const mostRelevantPost = discussion.mostRelevantPost();
return ( return (
<li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()}> <li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()}>
<a href={app.route.discussion(discussion, mostRelevantPost && mostRelevantPost.number())} config={m.route}> <a route={app.route.discussion(discussion, mostRelevantPost && mostRelevantPost.number())}>
<div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div> <div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div>
{mostRelevantPost ? <div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain(), query, 100)}</div> : ''} {mostRelevantPost ? <div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain(), query, 100)}</div> : ''}
</a> </a>

View File

@ -7,8 +7,8 @@ import DiscussionListState from '../states/DiscussionListState';
* page. * page.
*/ */
export default class DiscussionsUserPage extends UserPage { export default class DiscussionsUserPage extends UserPage {
init() { oninit(vnode) {
super.init(); super.oninit(vnode);
this.loadUser(m.route.param('username')); this.loadUser(m.route.param('username'));
} }

View File

@ -14,38 +14,32 @@ function minimizeComposerIfFullScreen(e) {
* post. It sets the initial content to the content of the post that is being * post. It sets the initial content to the content of the post that is being
* edited, and adds a header control to indicate which post is being edited. * edited, and adds a header control to indicate which post is being edited.
* *
* ### Props * ### Attrs
* *
* - All of the props for ComposerBody * - All of the attrs for ComposerBody
* - `post` * - `post`
*/ */
export default class EditPostComposer extends ComposerBody { export default class EditPostComposer extends ComposerBody {
static initProps(props) { static initAttrs(attrs) {
super.initProps(props); super.initAttrs(attrs);
props.submitLabel = props.submitLabel || app.translator.trans('core.forum.composer_edit.submit_button'); attrs.submitLabel = attrs.submitLabel || app.translator.trans('core.forum.composer_edit.submit_button');
props.confirmExit = props.confirmExit || app.translator.trans('core.forum.composer_edit.discard_confirmation'); attrs.confirmExit = attrs.confirmExit || app.translator.trans('core.forum.composer_edit.discard_confirmation');
props.originalContent = props.originalContent || props.post.content(); attrs.originalContent = attrs.originalContent || attrs.post.content();
props.user = props.user || props.post.user(); attrs.user = attrs.user || attrs.post.user();
props.post.editedContent = props.originalContent; attrs.post.editedContent = attrs.originalContent;
} }
headerItems() { headerItems() {
const items = super.headerItems(); const items = super.headerItems();
const post = this.props.post; const post = this.attrs.post;
const routeAndMinimize = function (element, isInitialized) {
if (isInitialized) return;
$(element).on('click', minimizeComposerIfFullScreen);
m.route.apply(this, arguments);
};
items.add( items.add(
'title', 'title',
<h3> <h3>
{icon('fas fa-pencil-alt')}{' '} {icon('fas fa-pencil-alt')}{' '}
<a href={app.route.discussion(post.discussion(), post.number())} config={routeAndMinimize}> <a route={app.route.discussion(post.discussion(), post.number())} onclick={minimizeComposerIfFullScreen}>
{app.translator.trans('core.forum.composer_edit.post_link', { number: post.number(), discussion: post.discussion().title() })} {app.translator.trans('core.forum.composer_edit.post_link', { number: post.number(), discussion: post.discussion().title() })}
</a> </a>
</h3> </h3>
@ -60,7 +54,7 @@ export default class EditPostComposer extends ComposerBody {
jumpToPreview(e) { jumpToPreview(e) {
minimizeComposerIfFullScreen(e); minimizeComposerIfFullScreen(e);
m.route(app.route.post(this.props.post)); m.route.set(app.route.post(this.attrs.post));
} }
/** /**
@ -75,13 +69,13 @@ export default class EditPostComposer extends ComposerBody {
} }
onsubmit() { onsubmit() {
const discussion = this.props.post.discussion(); const discussion = this.attrs.post.discussion();
this.loading = true; this.loading = true;
const data = this.data(); const data = this.data();
this.props.post.save(data).then((post) => { this.attrs.post.save(data).then((post) => {
// If we're currently viewing the discussion which this edit was made // If we're currently viewing the discussion which this edit was made
// in, then we can scroll to the post. // in, then we can scroll to the post.
if (app.viewingDiscussion(discussion)) { if (app.viewingDiscussion(discussion)) {
@ -91,19 +85,23 @@ export default class EditPostComposer extends ComposerBody {
// their edit has been made, containing a button which will // their edit has been made, containing a button which will
// transition to their edited post when clicked. // transition to their edited post when clicked.
let alert; let alert;
const viewButton = Button.component({ const viewButton = Button.component(
{
className: 'Button Button--link', className: 'Button Button--link',
children: app.translator.trans('core.forum.composer_edit.view_button'),
onclick: () => { onclick: () => {
m.route(app.route.post(post)); m.route.set(app.route.post(post));
app.alerts.dismiss(alert); app.alerts.dismiss(alert);
}, },
}); },
alert = app.alerts.show({ app.translator.trans('core.forum.composer_edit.view_button')
);
alert = app.alerts.show(
{
type: 'success', type: 'success',
children: app.translator.trans('core.forum.composer_edit.edited_message'),
controls: [viewButton], controls: [viewButton],
}); },
app.translator.trans('core.forum.composer_edit.edited_message')
);
} }
this.composer.hide(); this.composer.hide();

View File

@ -9,22 +9,22 @@ import ItemList from '../../common/utils/ItemList';
* The `EditUserModal` component displays a modal dialog with a login form. * The `EditUserModal` component displays a modal dialog with a login form.
*/ */
export default class EditUserModal extends Modal { export default class EditUserModal extends Modal {
init() { oninit(vnode) {
super.init(); super.oninit(vnode);
const user = this.props.user; const user = this.attrs.user;
this.username = m.prop(user.username() || ''); this.username = m.stream(user.username() || '');
this.email = m.prop(user.email() || ''); this.email = m.stream(user.email() || '');
this.isEmailConfirmed = m.prop(user.isEmailConfirmed() || false); this.isEmailConfirmed = m.stream(user.isEmailConfirmed() || false);
this.setPassword = m.prop(false); this.setPassword = m.stream(false);
this.password = m.prop(user.password() || ''); this.password = m.stream(user.password() || '');
this.groups = {}; this.groups = {};
app.store app.store
.all('groups') .all('groups')
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1) .filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
.forEach((group) => (this.groups[group.id()] = m.prop(user.groups().indexOf(group) !== -1))); .forEach((group) => (this.groups[group.id()] = m.stream(user.groups().indexOf(group) !== -1)));
} }
className() { className() {
@ -55,7 +55,7 @@ export default class EditUserModal extends Modal {
40 40
); );
if (app.session.user !== this.props.user) { if (app.session.user !== this.attrs.user) {
items.add( items.add(
'email', 'email',
<div className="Form-group"> <div className="Form-group">
@ -65,12 +65,14 @@ export default class EditUserModal extends Modal {
</div> </div>
{!this.isEmailConfirmed() ? ( {!this.isEmailConfirmed() ? (
<div> <div>
{Button.component({ {Button.component(
{
className: 'Button Button--block', className: 'Button Button--block',
children: app.translator.trans('core.forum.edit_user.activate_button'),
loading: this.loading, loading: this.loading,
onclick: this.activate.bind(this), onclick: this.activate.bind(this),
})} },
app.translator.trans('core.forum.edit_user.activate_button')
)}
</div> </div>
) : ( ) : (
'' ''
@ -89,9 +91,9 @@ export default class EditUserModal extends Modal {
type="checkbox" type="checkbox"
onchange={(e) => { onchange={(e) => {
this.setPassword(e.target.checked); this.setPassword(e.target.checked);
m.redraw(true); m.redraw.sync();
if (e.target.checked) this.$('[name=password]').select(); if (e.target.checked) this.$('[name=password]').select();
m.redraw.strategy('none'); e.redraw = false;
}} }}
/> />
{app.translator.trans('core.forum.edit_user.set_password_label')} {app.translator.trans('core.forum.edit_user.set_password_label')}
@ -125,7 +127,7 @@ export default class EditUserModal extends Modal {
<input <input
type="checkbox" type="checkbox"
bidi={this.groups[group.id()]} bidi={this.groups[group.id()]}
disabled={this.props.user.id() === '1' && group.id() === Group.ADMINISTRATOR_ID} disabled={this.attrs.user.id() === '1' && group.id() === Group.ADMINISTRATOR_ID}
/> />
{GroupBadge.component({ group, label: '' })} {group.nameSingular()} {GroupBadge.component({ group, label: '' })} {group.nameSingular()}
</label> </label>
@ -138,12 +140,14 @@ export default class EditUserModal extends Modal {
items.add( items.add(
'submit', 'submit',
<div className="Form-group"> <div className="Form-group">
{Button.component({ {Button.component(
{
className: 'Button Button--primary', className: 'Button Button--primary',
type: 'submit', type: 'submit',
loading: this.loading, loading: this.loading,
children: app.translator.trans('core.forum.edit_user.submit_button'), },
})} app.translator.trans('core.forum.edit_user.submit_button')
)}
</div>, </div>,
-10 -10
); );
@ -157,7 +161,7 @@ export default class EditUserModal extends Modal {
username: this.username(), username: this.username(),
isEmailConfirmed: true, isEmailConfirmed: true,
}; };
this.props.user this.attrs.user
.save(data, { errorHandler: this.onerror.bind(this) }) .save(data, { errorHandler: this.onerror.bind(this) })
.then(() => { .then(() => {
this.isEmailConfirmed(true); this.isEmailConfirmed(true);
@ -180,7 +184,7 @@ export default class EditUserModal extends Modal {
relationships: { groups }, relationships: { groups },
}; };
if (app.session.user !== this.props.user) { if (app.session.user !== this.attrs.user) {
data.email = this.email(); data.email = this.email();
} }
@ -196,7 +200,7 @@ export default class EditUserModal extends Modal {
this.loading = true; this.loading = true;
this.props.user this.attrs.user
.save(this.data(), { errorHandler: this.onerror.bind(this) }) .save(this.data(), { errorHandler: this.onerror.bind(this) })
.then(this.hide.bind(this)) .then(this.hide.bind(this))
.catch(() => { .catch(() => {

View File

@ -8,28 +8,28 @@ import icon from '../../common/helpers/icon';
* event, like a discussion being renamed or stickied. Subclasses must implement * event, like a discussion being renamed or stickied. Subclasses must implement
* the `icon` and `description` methods. * the `icon` and `description` methods.
* *
* ### Props * ### Attrs
* *
* - All of the props for `Post` * - All of the attrs for `Post`
* *
* @abstract * @abstract
*/ */
export default class EventPost extends Post { export default class EventPost extends Post {
attrs() { elementAttrs() {
const attrs = super.attrs(); const attrs = super.elementAttrs();
attrs.className = (attrs.className || '') + ' EventPost ' + ucfirst(this.props.post.contentType()) + 'Post'; attrs.className = (attrs.className || '') + ' EventPost ' + ucfirst(this.attrs.post.contentType()) + 'Post';
return attrs; return attrs;
} }
content() { content() {
const user = this.props.post.user(); const user = this.attrs.post.user();
const username = usernameHelper(user); const username = usernameHelper(user);
const data = Object.assign(this.descriptionData(), { const data = Object.assign(this.descriptionData(), {
user, user,
username: user ? ( username: user ? (
<a className="EventPost-user" href={app.route.user(user)} config={m.route}> <a className="EventPost-user" route={app.route.user(user)}>
{username} {username}
</a> </a>
) : ( ) : (

View File

@ -1,5 +1,4 @@
import Modal from '../../common/components/Modal'; import Modal from '../../common/components/Modal';
import Alert from '../../common/components/Alert';
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
import extractText from '../../common/utils/extractText'; import extractText from '../../common/utils/extractText';
@ -7,20 +6,20 @@ import extractText from '../../common/utils/extractText';
* The `ForgotPasswordModal` component displays a modal which allows the user to * The `ForgotPasswordModal` component displays a modal which allows the user to
* enter their email address and request a link to reset their password. * enter their email address and request a link to reset their password.
* *
* ### Props * ### Attrs
* *
* - `email` * - `email`
*/ */
export default class ForgotPasswordModal extends Modal { export default class ForgotPasswordModal extends Modal {
init() { oninit(vnode) {
super.init(); super.oninit(vnode);
/** /**
* The value of the email input. * The value of the email input.
* *
* @type {Function} * @type {Function}
*/ */
this.email = m.prop(this.props.email || ''); this.email = m.stream(this.attrs.email || '');
/** /**
* Whether or not the password reset email was sent successfully. * Whether or not the password reset email was sent successfully.
@ -64,18 +63,19 @@ export default class ForgotPasswordModal extends Modal {
name="email" name="email"
type="email" type="email"
placeholder={extractText(app.translator.trans('core.forum.forgot_password.email_placeholder'))} placeholder={extractText(app.translator.trans('core.forum.forgot_password.email_placeholder'))}
value={this.email()} bidi={this.email}
onchange={m.withAttr('value', this.email)}
disabled={this.loading} disabled={this.loading}
/> />
</div> </div>
<div className="Form-group"> <div className="Form-group">
{Button.component({ {Button.component(
{
className: 'Button Button--primary Button--block', className: 'Button Button--primary Button--block',
type: 'submit', type: 'submit',
loading: this.loading, loading: this.loading,
children: app.translator.trans('core.forum.forgot_password.submit_button'), },
})} app.translator.trans('core.forum.forgot_password.submit_button')
)}
</div> </div>
</div> </div>
</div> </div>
@ -91,7 +91,7 @@ export default class ForgotPasswordModal extends Modal {
.request({ .request({
method: 'POST', method: 'POST',
url: app.forum.attribute('apiUrl') + '/forgot', url: app.forum.attribute('apiUrl') + '/forgot',
data: { email: this.email() }, body: { email: this.email() },
errorHandler: this.onerror.bind(this), errorHandler: this.onerror.bind(this),
}) })
.then(() => { .then(() => {
@ -104,7 +104,7 @@ export default class ForgotPasswordModal extends Modal {
onerror(error) { onerror(error) {
if (error.status === 404) { if (error.status === 404) {
error.alert.children = app.translator.trans('core.forum.forgot_password.not_found_message'); error.alert.content = app.translator.trans('core.forum.forgot_password.not_found_message');
} }
super.onerror(error); super.onerror(error);

View File

@ -11,13 +11,6 @@ export default class HeaderPrimary extends Component {
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>; return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
} }
config(isInitialized, context) {
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
}
/** /**
* Build an item list for the controls. * Build an item list for the controls.
* *

View File

@ -19,13 +19,6 @@ export default class HeaderSecondary extends Component {
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>; return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
} }
config(isInitialized, context) {
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
}
/** /**
* Build an item list for the controls. * Build an item list for the controls.
* *
@ -41,9 +34,9 @@ export default class HeaderSecondary extends Component {
for (const locale in app.data.locales) { for (const locale in app.data.locales) {
locales.push( locales.push(
Button.component({ Button.component(
{
active: app.data.locale === locale, active: app.data.locale === locale,
children: app.data.locales[locale],
icon: app.data.locale === locale ? 'fas fa-check' : true, icon: app.data.locale === locale ? 'fas fa-check' : true,
onclick: () => { onclick: () => {
if (app.session.user) { if (app.session.user) {
@ -53,16 +46,20 @@ export default class HeaderSecondary extends Component {
window.location.reload(); window.location.reload();
} }
}, },
}) },
app.data.locales[locale]
)
); );
} }
items.add( items.add(
'locale', 'locale',
SelectDropdown.component({ SelectDropdown.component(
children: locales, {
buttonClassName: 'Button Button--link', buttonClassName: 'Button Button--link',
}), },
locales
),
20 20
); );
} }
@ -74,22 +71,26 @@ export default class HeaderSecondary extends Component {
if (app.forum.attribute('allowSignUp')) { if (app.forum.attribute('allowSignUp')) {
items.add( items.add(
'signUp', 'signUp',
Button.component({ Button.component(
children: app.translator.trans('core.forum.header.sign_up_link'), {
className: 'Button Button--link', className: 'Button Button--link',
onclick: () => app.modal.show(SignUpModal), onclick: () => app.modal.show(SignUpModal),
}), },
app.translator.trans('core.forum.header.sign_up_link')
),
10 10
); );
} }
items.add( items.add(
'logIn', 'logIn',
Button.component({ Button.component(
children: app.translator.trans('core.forum.header.log_in_link'), {
className: 'Button Button--link', className: 'Button Button--link',
onclick: () => app.modal.show(LogInModal), onclick: () => app.modal.show(LogInModal),
}), },
app.translator.trans('core.forum.header.log_in_link')
),
0 0
); );
} }

View File

@ -19,8 +19,8 @@ import SelectDropdown from '../../common/components/SelectDropdown';
export default class IndexPage extends Page { export default class IndexPage extends Page {
static providesInitialSearch = true; static providesInitialSearch = true;
init() { oninit(vnode) {
super.init(); super.oninit(vnode);
// If the user is returning from a discussion page, then take note of which // If the user is returning from a discussion page, then take note of which
// discussion they have just visited. After the view is rendered, we will // discussion they have just visited. After the view is rendered, we will
@ -42,12 +42,26 @@ export default class IndexPage extends Page {
app.history.push('index', app.translator.trans('core.forum.header.back_to_index_tooltip')); app.history.push('index', app.translator.trans('core.forum.header.back_to_index_tooltip'));
this.bodyClass = 'App--index'; this.bodyClass = 'App--index';
this.currentPath = m.route.get();
} }
onunload() { onbeforeupdate(vnode) {
// Save the scroll position so we can restore it when we return to the super.onbeforeupdate(vnode);
// discussion list.
app.cache.scrollTop = $(window).scrollTop(); const curPath = m.route.get();
if (this.currentPath !== curPath) {
this.onNewRoute();
app.discussions.clear();
app.discussions.refreshParams(app.search.params());
this.currentPath = curPath;
this.setTitle();
}
} }
view() { view() {
@ -72,15 +86,15 @@ export default class IndexPage extends Page {
); );
} }
config(isInitialized, context) { setTitle() {
super.config(...arguments);
if (isInitialized) return;
extend(context, 'onunload', () => $('#app').css('min-height', ''));
app.setTitle(app.translator.trans('core.forum.index.meta_title_text')); app.setTitle(app.translator.trans('core.forum.index.meta_title_text'));
app.setTitleCount(0); app.setTitleCount(0);
}
oncreate(vnode) {
super.oncreate(vnode);
this.setTitle();
// Work out the difference between the height of this hero and that of the // Work out the difference between the height of this hero and that of the
// previous hero. Maintain the same scroll position relative to the bottom // previous hero. Maintain the same scroll position relative to the bottom
@ -117,6 +131,16 @@ export default class IndexPage extends Page {
} }
} }
onremove() {
super.onremove();
$('#app').css('min-height', '');
// Save the scroll position so we can restore it when we return to the
// discussion list.
app.cache.scrollTop = $(window).scrollTop();
}
/** /**
* Get the component to display as the hero. * Get the component to display as the hero.
* *
@ -139,25 +163,31 @@ export default class IndexPage extends Page {
items.add( items.add(
'newDiscussion', 'newDiscussion',
Button.component({ Button.component(
children: app.translator.trans( {
canStartDiscussion ? 'core.forum.index.start_discussion_button' : 'core.forum.index.cannot_start_discussion_button'
),
icon: 'fas fa-edit', icon: 'fas fa-edit',
className: 'Button Button--primary IndexPage-newDiscussion', className: 'Button Button--primary IndexPage-newDiscussion',
itemClassName: 'App-primaryControl', itemClassName: 'App-primaryControl',
onclick: this.newDiscussionAction.bind(this), onclick: () => {
// If the user is not logged in, the promise rejects, and a login modal shows up.
// Since that's already handled, we dont need to show an error message in the console.
return this.newDiscussionAction().catch(() => {});
},
disabled: !canStartDiscussion, disabled: !canStartDiscussion,
}) },
app.translator.trans(canStartDiscussion ? 'core.forum.index.start_discussion_button' : 'core.forum.index.cannot_start_discussion_button')
)
); );
items.add( items.add(
'nav', 'nav',
SelectDropdown.component({ SelectDropdown.component(
children: this.navItems(this).toArray(), {
buttonClassName: 'Button', buttonClassName: 'Button',
className: 'App-titleControl', className: 'App-titleControl',
}) },
this.navItems(this).toArray()
)
); );
return items; return items;
@ -175,11 +205,13 @@ export default class IndexPage extends Page {
items.add( items.add(
'allDiscussions', 'allDiscussions',
LinkButton.component({ LinkButton.component(
{
href: app.route('index', params), href: app.route('index', params),
children: app.translator.trans('core.forum.index.all_discussions_link'),
icon: 'far fa-comments', icon: 'far fa-comments',
}), },
app.translator.trans('core.forum.index.all_discussions_link')
),
100 100
); );
@ -204,21 +236,25 @@ export default class IndexPage extends Page {
items.add( items.add(
'sort', 'sort',
Dropdown.component({ Dropdown.component(
{
buttonClassName: 'Button', buttonClassName: 'Button',
label: sortOptions[app.search.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0], label: sortOptions[app.search.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0],
children: Object.keys(sortOptions).map((value) => { },
Object.keys(sortOptions).map((value) => {
const label = sortOptions[value]; const label = sortOptions[value];
const active = (app.search.params().sort || Object.keys(sortMap)[0]) === value; const active = (app.search.params().sort || Object.keys(sortMap)[0]) === value;
return Button.component({ return Button.component(
children: label, {
icon: active ? 'fas fa-check' : true, icon: active ? 'fas fa-check' : true,
onclick: app.search.changeSort.bind(app.search, value), onclick: app.search.changeSort.bind(app.search, value),
active: active, active: active,
}); },
}), label
);
}) })
)
); );
return items; return items;
@ -270,20 +306,18 @@ export default class IndexPage extends Page {
* @return {Promise} * @return {Promise}
*/ */
newDiscussionAction() { newDiscussionAction() {
const deferred = m.deferred(); return new Promise((resolve, reject) => {
if (app.session.user) { if (app.session.user) {
app.composer.load(DiscussionComposer, { user: app.session.user }); app.composer.load(DiscussionComposer, { user: app.session.user });
app.composer.show(); app.composer.show();
deferred.resolve(app.composer); return resolve(app.composer);
} else { } else {
deferred.reject();
app.modal.show(LogInModal); app.modal.show(LogInModal);
}
return deferred.promise; return reject();
}
});
} }
/** /**

View File

@ -4,21 +4,21 @@ import Button from '../../common/components/Button';
* The `LogInButton` component displays a social login button which will open * The `LogInButton` component displays a social login button which will open
* a popup window containing the specified path. * a popup window containing the specified path.
* *
* ### Props * ### Attrs
* *
* - `path` * - `path`
*/ */
export default class LogInButton extends Button { export default class LogInButton extends Button {
static initProps(props) { static initAttrs(attrs) {
props.className = (props.className || '') + ' LogInButton'; attrs.className = (attrs.className || '') + ' LogInButton';
props.onclick = function () { attrs.onclick = function () {
const width = 580; const width = 580;
const height = 400; const height = 400;
const $window = $(window); const $window = $(window);
window.open( window.open(
app.forum.attribute('baseUrl') + props.path, app.forum.attribute('baseUrl') + attrs.path,
'logInPopup', 'logInPopup',
`width=${width},` + `width=${width},` +
`height=${height},` + `height=${height},` +
@ -28,6 +28,6 @@ export default class LogInButton extends Button {
); );
}; };
super.initProps(props); super.initAttrs(attrs);
} }
} }

View File

@ -9,35 +9,35 @@ import ItemList from '../../common/utils/ItemList';
/** /**
* The `LogInModal` component displays a modal dialog with a login form. * The `LogInModal` component displays a modal dialog with a login form.
* *
* ### Props * ### Attrs
* *
* - `identification` * - `identification`
* - `password` * - `password`
*/ */
export default class LogInModal extends Modal { export default class LogInModal extends Modal {
init() { oninit(vnode) {
super.init(); super.oninit(vnode);
/** /**
* The value of the identification input. * The value of the identification input.
* *
* @type {Function} * @type {Function}
*/ */
this.identification = m.prop(this.props.identification || ''); this.identification = m.stream(this.attrs.identification || '');
/** /**
* The value of the password input. * The value of the password input.
* *
* @type {Function} * @type {Function}
*/ */
this.password = m.prop(this.props.password || ''); this.password = m.stream(this.attrs.password || '');
/** /**
* The value of the remember me input. * The value of the remember me input.
* *
* @type {Function} * @type {Function}
*/ */
this.remember = m.prop(!!this.props.remember); this.remember = m.stream(!!this.attrs.remember);
} }
className() { className() {
@ -105,12 +105,14 @@ export default class LogInModal extends Modal {
items.add( items.add(
'submit', 'submit',
<div className="Form-group"> <div className="Form-group">
{Button.component({ {Button.component(
{
className: 'Button Button--primary Button--block', className: 'Button Button--primary Button--block',
type: 'submit', type: 'submit',
loading: this.loading, loading: this.loading,
children: app.translator.trans('core.forum.log_in.submit_button'), },
})} app.translator.trans('core.forum.log_in.submit_button')
)}
</div>, </div>,
-10 -10
); );
@ -140,9 +142,9 @@ export default class LogInModal extends Modal {
*/ */
forgotPassword() { forgotPassword() {
const email = this.identification(); const email = this.identification();
const props = email.indexOf('@') !== -1 ? { email } : undefined; const attrs = email.indexOf('@') !== -1 ? { email } : undefined;
app.modal.show(ForgotPasswordModal, props); app.modal.show(ForgotPasswordModal, attrs);
} }
/** /**
@ -152,11 +154,11 @@ export default class LogInModal extends Modal {
* @public * @public
*/ */
signUp() { signUp() {
const props = { password: this.password() }; const attrs = { password: this.password() };
const identification = this.identification(); const identification = this.identification();
props[identification.indexOf('@') !== -1 ? 'email' : 'username'] = identification; attrs[identification.indexOf('@') !== -1 ? 'email' : 'username'] = identification;
app.modal.show(SignUpModal, props); app.modal.show(SignUpModal, attrs);
} }
onready() { onready() {
@ -179,7 +181,7 @@ export default class LogInModal extends Modal {
onerror(error) { onerror(error) {
if (error.status === 401) { if (error.status === 401) {
error.alert.children = app.translator.trans('core.forum.log_in.invalid_login_message'); error.alert.content = app.translator.trans('core.forum.log_in.invalid_login_message');
} }
super.onerror(error); super.onerror(error);

View File

@ -8,7 +8,7 @@ import Button from '../../common/components/Button';
* The `Notification` component abstract displays a single notification. * The `Notification` component abstract displays a single notification.
* Subclasses should implement the `icon`, `href`, and `content` methods. * Subclasses should implement the `icon`, `href`, and `content` methods.
* *
* ### Props * ### Attrs
* *
* - `notification` * - `notification`
* *
@ -16,18 +16,17 @@ import Button from '../../common/components/Button';
*/ */
export default class Notification extends Component { export default class Notification extends Component {
view() { view() {
const notification = this.props.notification; const notification = this.attrs.notification;
const href = this.href(); const href = this.href();
const linkAttrs = {};
linkAttrs[href.indexOf('://') === -1 ? 'route' : 'href'] = href;
return ( return (
<a <a
className={'Notification Notification--' + notification.contentType() + ' ' + (!notification.isRead() ? 'unread' : '')} className={'Notification Notification--' + notification.contentType() + ' ' + (!notification.isRead() ? 'unread' : '')}
href={href} {...linkAttrs}
config={function (element, isInitialized) { onclick={this.markAsRead.bind(this)}
if (href.indexOf('://') === -1) m.route.apply(this, arguments);
if (!isInitialized) $(element).click(this.markAsRead.bind(this));
}}
> >
{!notification.isRead() && {!notification.isRead() &&
Button.component({ Button.component({
@ -86,10 +85,10 @@ export default class Notification extends Component {
* Mark the notification as read. * Mark the notification as read.
*/ */
markAsRead() { markAsRead() {
if (this.props.notification.isRead()) return; if (this.attrs.notification.isRead()) return;
app.session.user.pushAttributes({ unreadNotificationCount: app.session.user.unreadNotificationCount() - 1 }); app.session.user.pushAttributes({ unreadNotificationCount: app.session.user.unreadNotificationCount() - 1 });
this.props.notification.save({ isRead: true }); this.attrs.notification.save({ isRead: true });
} }
} }

View File

@ -7,12 +7,14 @@ import ItemList from '../../common/utils/ItemList';
* The `NotificationGrid` component displays a table of notification types and * The `NotificationGrid` component displays a table of notification types and
* methods, allowing the user to toggle each combination. * methods, allowing the user to toggle each combination.
* *
* ### Props * ### Attrs
* *
* - `user` * - `user`
*/ */
export default class NotificationGrid extends Component { export default class NotificationGrid extends Component {
init() { oninit(vnode) {
super.oninit(vnode);
/** /**
* Information about the available notification methods. * Information about the available notification methods.
* *
@ -36,7 +38,7 @@ export default class NotificationGrid extends Component {
} }
view() { view() {
const preferences = this.props.user.preferences(); const preferences = this.attrs.user.preferences();
return ( return (
<table className="NotificationGrid"> <table className="NotificationGrid">
@ -62,12 +64,12 @@ export default class NotificationGrid extends Component {
return ( return (
<td className="NotificationGrid-checkbox"> <td className="NotificationGrid-checkbox">
{Checkbox.component({ <Checkbox
state: !!preferences[key], state={!!preferences[key]}
loading: this.loading[key], loading={this.loading[key]}
disabled: !(key in preferences), disabled={!(key in preferences)}
onchange: () => this.toggle([key]), onchange={this.toggle.bind(this, [key])}
})} />
</td> </td>
); );
})} })}
@ -78,8 +80,8 @@ export default class NotificationGrid extends Component {
); );
} }
config(isInitialized) { oncreate(vnode) {
if (isInitialized) return; super.oncreate(vnode);
this.$('thead .NotificationGrid-groupToggle').bind('mouseenter mouseleave', function (e) { this.$('thead .NotificationGrid-groupToggle').bind('mouseenter mouseleave', function (e) {
const i = parseInt($(this).index(), 10) + 1; const i = parseInt($(this).index(), 10) + 1;
@ -104,7 +106,7 @@ export default class NotificationGrid extends Component {
* @param {Array} keys * @param {Array} keys
*/ */
toggle(keys) { toggle(keys) {
const user = this.props.user; const user = this.attrs.user;
const preferences = user.preferences(); const preferences = user.preferences();
const enabled = !preferences[keys[0]]; const enabled = !preferences[keys[0]];
@ -128,7 +130,7 @@ export default class NotificationGrid extends Component {
* @param {String} method * @param {String} method
*/ */
toggleMethod(method) { toggleMethod(method) {
const keys = this.types.map((type) => this.preferenceKey(type.name, method)).filter((key) => key in this.props.user.preferences()); const keys = this.types.map((type) => this.preferenceKey(type.name, method)).filter((key) => key in this.attrs.user.preferences());
this.toggle(keys); this.toggle(keys);
} }
@ -139,7 +141,7 @@ export default class NotificationGrid extends Component {
* @param {String} type * @param {String} type
*/ */
toggleType(type) { toggleType(type) {
const keys = this.methods.map((method) => this.preferenceKey(type, method.name)).filter((key) => key in this.props.user.preferences()); const keys = this.methods.map((method) => this.preferenceKey(type, method.name)).filter((key) => key in this.attrs.user.preferences());
this.toggle(keys); this.toggle(keys);
} }

View File

@ -9,12 +9,9 @@ import Discussion from '../../common/models/Discussion';
* notifications, grouped by discussion. * notifications, grouped by discussion.
*/ */
export default class NotificationList extends Component { export default class NotificationList extends Component {
init() {
this.state = this.props.state;
}
view() { view() {
const pages = this.state.getNotificationPages(); const state = this.attrs.state;
const pages = state.getNotificationPages();
return ( return (
<div className="NotificationList"> <div className="NotificationList">
@ -24,7 +21,7 @@ export default class NotificationList extends Component {
className: 'Button Button--icon Button--link', className: 'Button Button--icon Button--link',
icon: 'fas fa-check', icon: 'fas fa-check',
title: app.translator.trans('core.forum.notifications.mark_all_as_read_tooltip'), title: app.translator.trans('core.forum.notifications.mark_all_as_read_tooltip'),
onclick: this.state.markAllAsRead.bind(this.state), onclick: state.markAllAsRead.bind(state),
})} })}
</div> </div>
@ -66,7 +63,7 @@ export default class NotificationList extends Component {
return ( return (
<div className="NotificationGroup"> <div className="NotificationGroup">
{group.discussion ? ( {group.discussion ? (
<a className="NotificationGroup-header" href={app.route.discussion(group.discussion)} config={m.route}> <a className="NotificationGroup-header" route={app.route.discussion(group.discussion)}>
{badges && badges.length ? <ul className="NotificationGroup-badges badges">{listItems(badges)}</ul> : ''} {badges && badges.length ? <ul className="NotificationGroup-badges badges">{listItems(badges)}</ul> : ''}
{group.discussion.title()} {group.discussion.title()}
</a> </a>
@ -85,7 +82,7 @@ export default class NotificationList extends Component {
}); });
}) })
: ''} : ''}
{this.state.isLoading() ? ( {state.isLoading() ? (
<LoadingIndicator className="LoadingIndicator--block" /> <LoadingIndicator className="LoadingIndicator--block" />
) : pages.length ? ( ) : pages.length ? (
'' ''
@ -97,27 +94,31 @@ export default class NotificationList extends Component {
); );
} }
config(isInitialized, context) { oncreate(vnode) {
if (isInitialized) return; super.oncreate(vnode);
const $notifications = this.$('.NotificationList-content'); this.$notifications = this.$('.NotificationList-content');
const $scrollParent = $notifications.css('overflow') === 'auto' ? $notifications : $(window); this.$scrollParent = this.$notifications.css('overflow') === 'auto' ? this.$notifications : $(window);
const scrollHandler = () => { this.boundScrollHandler = this.scrollHandler.bind(this);
const scrollTop = $scrollParent.scrollTop(); this.$scrollParent.on('scroll', this.boundScrollHandler);
const viewportHeight = $scrollParent.height();
const contentTop = $scrollParent === $notifications ? 0 : $notifications.offset().top;
const contentHeight = $notifications[0].scrollHeight;
if (this.state.hasMoreResults() && !this.state.isLoading() && scrollTop + viewportHeight >= contentTop + contentHeight) {
this.state.loadMore();
} }
};
$scrollParent.on('scroll', scrollHandler); onremove() {
this.$scrollParent.off('scroll', this.boundScrollHandler);
}
context.onunload = () => { scrollHandler() {
$scrollParent.off('scroll', scrollHandler); const state = this.attrs.state;
};
const scrollTop = this.$scrollParent.scrollTop();
const viewportHeight = this.$scrollParent.height();
const contentTop = this.$scrollParent === this.$notifications ? 0 : this.$notifications.offset().top;
const contentHeight = this.$notifications[0].scrollHeight;
if (state.hasMoreResults() && !state.isLoading() && scrollTop + viewportHeight >= contentTop + contentHeight) {
state.loadMore();
}
} }
} }

View File

@ -3,21 +3,21 @@ import icon from '../../common/helpers/icon';
import NotificationList from './NotificationList'; import NotificationList from './NotificationList';
export default class NotificationsDropdown extends Dropdown { export default class NotificationsDropdown extends Dropdown {
static initProps(props) { static initAttrs(attrs) {
props.className = props.className || 'NotificationsDropdown'; attrs.className = attrs.className || 'NotificationsDropdown';
props.buttonClassName = props.buttonClassName || 'Button Button--flat'; attrs.buttonClassName = attrs.buttonClassName || 'Button Button--flat';
props.menuClassName = props.menuClassName || 'Dropdown-menu--right'; attrs.menuClassName = attrs.menuClassName || 'Dropdown-menu--right';
props.label = props.label || app.translator.trans('core.forum.notifications.tooltip'); attrs.label = attrs.label || app.translator.trans('core.forum.notifications.tooltip');
props.icon = props.icon || 'fas fa-bell'; attrs.icon = attrs.icon || 'fas fa-bell';
super.initProps(props); super.initAttrs(attrs);
} }
getButton() { getButton() {
const newNotifications = this.getNewCount(); const newNotifications = this.getNewCount();
const vdom = super.getButton(); const vdom = super.getButton();
vdom.attrs.title = this.props.label; vdom.attrs.title = this.attrs.label;
vdom.attrs.className += newNotifications ? ' new' : ''; vdom.attrs.className += newNotifications ? ' new' : '';
vdom.attrs.onclick = this.onclick.bind(this); vdom.attrs.onclick = this.onclick.bind(this);
@ -29,16 +29,16 @@ export default class NotificationsDropdown extends Dropdown {
const unread = this.getUnreadCount(); const unread = this.getUnreadCount();
return [ return [
icon(this.props.icon, { className: 'Button-icon' }), icon(this.attrs.icon, { className: 'Button-icon' }),
unread ? <span className="NotificationsDropdown-unread">{unread}</span> : '', unread ? <span className="NotificationsDropdown-unread">{unread}</span> : '',
<span className="Button-label">{this.props.label}</span>, <span className="Button-label">{this.attrs.label}</span>,
]; ];
} }
getMenu() { getMenu() {
return ( return (
<div className={'Dropdown-menu ' + this.props.menuClassName} onclick={this.menuClick.bind(this)}> <div className={'Dropdown-menu ' + this.attrs.menuClassName} onclick={this.menuClick.bind(this)}>
{this.showing ? NotificationList.component({ state: this.props.state }) : ''} {this.showing ? NotificationList.component({ state: this.attrs.state }) : ''}
</div> </div>
); );
} }
@ -47,12 +47,12 @@ export default class NotificationsDropdown extends Dropdown {
if (app.drawer.isOpen()) { if (app.drawer.isOpen()) {
this.goToRoute(); this.goToRoute();
} else { } else {
this.props.state.load(); this.attrs.state.load();
} }
} }
goToRoute() { goToRoute() {
m.route(app.route('notifications')); m.route.set(app.route('notifications'));
} }
getUnreadCount() { getUnreadCount() {

View File

@ -6,8 +6,8 @@ import NotificationList from './NotificationList';
* used on mobile devices where the notifications dropdown is within the drawer. * used on mobile devices where the notifications dropdown is within the drawer.
*/ */
export default class NotificationsPage extends Page { export default class NotificationsPage extends Page {
init() { oninit(vnode) {
super.init(); super.oninit(vnode);
app.history.push('notifications'); app.history.push('notifications');

View File

@ -10,14 +10,16 @@ import ItemList from '../../common/utils/ItemList';
* includes a controls dropdown; subclasses must implement `content` and `attrs` * includes a controls dropdown; subclasses must implement `content` and `attrs`
* methods. * methods.
* *
* ### Props * ### Attrs
* *
* - `post` * - `post`
* *
* @abstract * @abstract
*/ */
export default class Post extends Component { export default class Post extends Component {
init() { oninit(vnode) {
super.oninit(vnode);
this.loading = false; this.loading = false;
/** /**
@ -27,9 +29,9 @@ export default class Post extends Component {
* @type {SubtreeRetainer} * @type {SubtreeRetainer}
*/ */
this.subtree = new SubtreeRetainer( this.subtree = new SubtreeRetainer(
() => this.props.post.freshness, () => this.attrs.post.freshness,
() => { () => {
const user = this.props.post.user(); const user = this.attrs.post.user();
return user && user.freshness; return user && user.freshness;
}, },
() => this.controlsOpen () => this.controlsOpen
@ -37,17 +39,14 @@ export default class Post extends Component {
} }
view() { view() {
const attrs = this.attrs(); const attrs = this.elementAttrs();
attrs.className = this.classes(attrs.className).join(' '); attrs.className = this.classes(attrs.className).join(' ');
return ( const controls = PostControls.controls(this.attrs.post, this).toArray();
<article {...attrs}>
{this.subtree.retain() ||
(() => {
const controls = PostControls.controls(this.props.post, this).toArray();
return ( return (
<article {...attrs}>
<div> <div>
{this.content()} {this.content()}
<aside className="Post-actions"> <aside className="Post-actions">
@ -75,13 +74,17 @@ export default class Post extends Component {
<ul>{listItems(this.footerItems().toArray())}</ul> <ul>{listItems(this.footerItems().toArray())}</ul>
</footer> </footer>
</div> </div>
);
})()}
</article> </article>
); );
} }
config(isInitialized) { onbeforeupdate(vnode) {
super.onbeforeupdate(vnode);
return this.subtree.needsRebuild();
}
onupdate() {
const $actions = this.$('.Post-actions'); const $actions = this.$('.Post-actions');
const $controls = this.$('.Post-controls'); const $controls = this.$('.Post-controls');
@ -93,7 +96,7 @@ export default class Post extends Component {
* *
* @return {Object} * @return {Object}
*/ */
attrs() { elementAttrs() {
return {}; return {};
} }
@ -115,8 +118,8 @@ export default class Post extends Component {
classes(existing) { classes(existing) {
let classes = (existing || '').split(' ').concat(['Post']); let classes = (existing || '').split(' ').concat(['Post']);
const user = this.props.post.user(); const user = this.attrs.post.user();
const discussion = this.props.post.discussion(); const discussion = this.attrs.post.discussion();
if (this.loading) { if (this.loading) {
classes.push('Post--loading'); classes.push('Post--loading');

View File

@ -6,18 +6,20 @@ import extractText from '../../common/utils/extractText';
* The `PostEdited` component displays information about when and by whom a post * The `PostEdited` component displays information about when and by whom a post
* was edited. * was edited.
* *
* ### Props * ### Attrs
* *
* - `post` * - `post`
*/ */
export default class PostEdited extends Component { export default class PostEdited extends Component {
init() { oninit(vnode) {
super.oninit(vnode);
this.shouldUpdateTooltip = false; this.shouldUpdateTooltip = false;
this.oldEditedInfo = null; this.oldEditedInfo = null;
} }
view() { view() {
const post = this.props.post; const post = this.attrs.post;
const editedUser = post.editedUser(); const editedUser = post.editedUser();
const editedInfo = extractText(app.translator.trans('core.forum.post.edited_tooltip', { user: editedUser, ago: humanTime(post.editedAt()) })); const editedInfo = extractText(app.translator.trans('core.forum.post.edited_tooltip', { user: editedUser, ago: humanTime(post.editedAt()) }));
if (editedInfo !== this.oldEditedInfo) { if (editedInfo !== this.oldEditedInfo) {
@ -32,7 +34,17 @@ export default class PostEdited extends Component {
); );
} }
config(isInitialized) { oncreate(vnode) {
super.oncreate(vnode);
this.rebuildTooltip();
}
onupdate() {
this.rebuildTooltip();
}
rebuildTooltip() {
if (this.shouldUpdateTooltip) { if (this.shouldUpdateTooltip) {
this.$().tooltip('destroy').tooltip(); this.$().tooltip('destroy').tooltip();
this.shouldUpdateTooltip = false; this.shouldUpdateTooltip = false;

View File

@ -7,23 +7,23 @@ import fullTime from '../../common/helpers/fullTime';
* a dropdown containing more information about the post (number, full time, * a dropdown containing more information about the post (number, full time,
* permalink). * permalink).
* *
* ### Props * ### Attrs
* *
* - `post` * - `post`
*/ */
export default class PostMeta extends Component { export default class PostMeta extends Component {
view() { view() {
const post = this.props.post; const post = this.attrs.post;
const time = post.createdAt(); const time = post.createdAt();
const permalink = this.getPermalink(post); const permalink = this.getPermalink(post);
const touch = 'ontouchstart' in document.documentElement; const touch = 'ontouchstart' in document.documentElement;
// When the dropdown menu is shown, select the contents of the permalink // When the dropdown menu is shown, select the contents of the permalink
// input so that the user can quickly copy the URL. // input so that the user can quickly copy the URL.
const selectPermalink = function () { const selectPermalink = function (e) {
setTimeout(() => $(this).parent().find('.PostMeta-permalink').select()); setTimeout(() => $(this).parent().find('.PostMeta-permalink').select());
m.redraw.strategy('none'); e.redraw = false;
}; };
return ( return (

View File

@ -7,18 +7,18 @@ import highlight from '../../common/helpers/highlight';
* The `PostPreview` component shows a link to a post containing the avatar and * The `PostPreview` component shows a link to a post containing the avatar and
* username of the author, and a short excerpt of the post's content. * username of the author, and a short excerpt of the post's content.
* *
* ### Props * ### Attrs
* *
* - `post` * - `post`
*/ */
export default class PostPreview extends Component { export default class PostPreview extends Component {
view() { view() {
const post = this.props.post; const post = this.attrs.post;
const user = post.user(); const user = post.user();
const excerpt = highlight(post.contentPlain(), this.props.highlight, 300); const excerpt = highlight(post.contentPlain(), this.attrs.highlight, 300);
return ( return (
<a className="PostPreview" href={app.route.post(post)} config={m.route} onclick={this.props.onclick}> <a className="PostPreview" route={app.route.post(post)} onclick={this.attrs.onclick}>
<span className="PostPreview-content"> <span className="PostPreview-content">
{avatar(user)} {avatar(user)}
{username(user)} <span className="PostPreview-excerpt">{excerpt}</span> {username(user)} <span className="PostPreview-excerpt">{excerpt}</span>

View File

@ -8,7 +8,7 @@ import Button from '../../common/components/Button';
* The `PostStream` component displays an infinitely-scrollable wall of posts in * The `PostStream` component displays an infinitely-scrollable wall of posts in
* a discussion. Posts that have not loaded will be displayed as placeholders. * a discussion. Posts that have not loaded will be displayed as placeholders.
* *
* ### Props * ### Attrs
* *
* - `discussion` * - `discussion`
* - `stream` * - `stream`
@ -16,9 +16,11 @@ import Button from '../../common/components/Button';
* - `onPositionChange` * - `onPositionChange`
*/ */
export default class PostStream extends Component { export default class PostStream extends Component {
init() { oninit(vnode) {
this.discussion = this.props.discussion; super.oninit(vnode);
this.stream = this.props.stream;
this.discussion = this.attrs.discussion;
this.stream = this.attrs.stream;
this.scrollListener = new ScrollListener(this.onscroll.bind(this)); this.scrollListener = new ScrollListener(this.onscroll.bind(this));
} }
@ -96,29 +98,33 @@ export default class PostStream extends Component {
return <div className="PostStream">{items}</div>; return <div className="PostStream">{items}</div>;
} }
config(isInitialized, context) { onupdate() {
this.triggerScroll(); this.triggerScroll();
}
if (isInitialized) return; oncreate(vnode) {
super.oncreate(vnode);
this.triggerScroll();
// This is wrapped in setTimeout due to the following Mithril issue: // This is wrapped in setTimeout due to the following Mithril issue:
// https://github.com/lhorie/mithril.js/issues/637 // https://github.com/lhorie/mithril.js/issues/637
setTimeout(() => this.scrollListener.start()); setTimeout(() => this.scrollListener.start());
}
context.onunload = () => { onremove() {
this.scrollListener.stop(); this.scrollListener.stop();
clearTimeout(this.calculatePositionTimeout); clearTimeout(this.calculatePositionTimeout);
};
} }
/** /**
* Start scrolling, if appropriate, to a newly-targeted post. * Start scrolling, if appropriate, to a newly-targeted post.
*/ */
triggerScroll() { triggerScroll() {
if (!this.props.targetPost) return; if (!this.attrs.targetPost) return;
const oldTarget = this.prevTarget; const oldTarget = this.prevTarget;
const newTarget = this.props.targetPost; const newTarget = this.attrs.targetPost;
if (oldTarget) { if (oldTarget) {
if ('number' in oldTarget && oldTarget.number === newTarget.number) return; if ('number' in oldTarget && oldTarget.number === newTarget.number) return;
@ -265,7 +271,7 @@ export default class PostStream extends Component {
}); });
if (startNumber) { if (startNumber) {
this.props.onPositionChange(startNumber || 1, endNumber, startNumber); this.attrs.onPositionChange(startNumber || 1, endNumber, startNumber);
} }
} }
@ -348,7 +354,7 @@ export default class PostStream extends Component {
return Promise.all([$container.promise(), this.stream.loadPromise]).then(() => { return Promise.all([$container.promise(), this.stream.loadPromise]).then(() => {
this.updateScrubber(); this.updateScrubber();
const index = $item.data('index'); const index = $item.data('index');
m.redraw(true); m.redraw.sync();
const scroll = index == 0 ? 0 : $(`.PostStream-item[data-index=${$item.data('index')}]`).offset().top - this.getMarginTop(); const scroll = index == 0 ? 0 : $(`.PostStream-item[data-index=${$item.data('index')}]`).offset().top - this.getMarginTop();
$(window).scrollTop(scroll); $(window).scrollTop(scroll);
this.calculatePosition(); this.calculatePosition();

View File

@ -7,14 +7,16 @@ import ScrollListener from '../../common/utils/ScrollListener';
* The `PostStreamScrubber` component displays a scrubber which can be used to * The `PostStreamScrubber` component displays a scrubber which can be used to
* navigate/scrub through a post stream. * navigate/scrub through a post stream.
* *
* ### Props * ### Attrs
* *
* - `stream` * - `stream`
* - `className` * - `className`
*/ */
export default class PostStreamScrubber extends Component { export default class PostStreamScrubber extends Component {
init() { oninit(vnode) {
this.stream = this.props.stream; super.oninit(vnode);
this.stream = this.attrs.stream;
this.handlers = {}; this.handlers = {};
this.scrollListener = new ScrollListener(this.updateScrubberValues.bind(this, { fromScroll: true, forceHeightChange: true })); this.scrollListener = new ScrollListener(this.updateScrubberValues.bind(this, { fromScroll: true, forceHeightChange: true }));
@ -32,23 +34,23 @@ export default class PostStreamScrubber extends Component {
const unreadCount = this.stream.discussion.unreadCount(); const unreadCount = this.stream.discussion.unreadCount();
const unreadPercent = count ? Math.min(count - this.stream.index, unreadCount) / count : 0; const unreadPercent = count ? Math.min(count - this.stream.index, unreadCount) / count : 0;
function styleUnread(element, isInitialized, context) { function styleUnread(vnode) {
const $element = $(element); const $element = $(vnode.dom);
const newStyle = { const newStyle = {
top: 100 - unreadPercent * 100 + '%', top: 100 - unreadPercent * 100 + '%',
height: unreadPercent * 100 + '%', height: unreadPercent * 100 + '%',
}; };
if (context.oldStyle) { if (vnode.state.oldStyle) {
$element.stop(true).css(context.oldStyle).animate(newStyle); $element.stop(true).css(vnode.state.oldStyle).animate(newStyle);
} else { } else {
$element.css(newStyle); $element.css(newStyle);
} }
context.oldStyle = newStyle; vnode.state.oldStyle = newStyle;
} }
const classNames = ['PostStreamScrubber', 'Dropdown']; const classNames = ['PostStreamScrubber', 'Dropdown'];
if (this.props.className) classNames.push(this.props.className); if (this.attrs.className) classNames.push(this.attrs.className);
return ( return (
<div className={classNames.join(' ')}> <div className={classNames.join(' ')}>
@ -68,12 +70,12 @@ export default class PostStreamScrubber extends Component {
<div className="Scrubber-bar" /> <div className="Scrubber-bar" />
<div className="Scrubber-info"> <div className="Scrubber-info">
<strong>{viewing}</strong> <strong>{viewing}</strong>
<span className="Scrubber-description">{this.stream.description}</span> <span className="Scrubber-description"></span>
</div> </div>
</div> </div>
<div className="Scrubber-after" /> <div className="Scrubber-after" />
<div className="Scrubber-unread" config={styleUnread}> <div className="Scrubber-unread" oncreate={styleUnread} onupdate={styleUnread}>
{app.translator.trans('core.forum.post_scrubber.unread_text', { count: unreadCount })} {app.translator.trans('core.forum.post_scrubber.unread_text', { count: unreadCount })}
</div> </div>
</div> </div>
@ -87,11 +89,12 @@ export default class PostStreamScrubber extends Component {
); );
} }
config(isInitialized, context) { onupdate() {
this.stream.loadPromise.then(() => this.updateScrubberValues({ animate: true, forceHeightChange: true })); this.stream.loadPromise.then(() => this.updateScrubberValues({ animate: true, forceHeightChange: true }));
if (isInitialized) return; }
context.onunload = this.ondestroy.bind(this); oncreate(vnode) {
super.oncreate(vnode);
// Whenever the window is resized, adjust the height of the scrollbar // Whenever the window is resized, adjust the height of the scrollbar
// so that it fills the height of the sidebar. // so that it fills the height of the sidebar.
@ -133,6 +136,15 @@ export default class PostStreamScrubber extends Component {
.on('mouseup touchend', (this.handlers.onmouseup = this.onmouseup.bind(this))); .on('mouseup touchend', (this.handlers.onmouseup = this.onmouseup.bind(this)));
setTimeout(() => this.scrollListener.start()); setTimeout(() => this.scrollListener.start());
this.updateScrubberValues({ animate: true, forceHeightChange: true });
}
onremove() {
this.scrollListener.stop();
$(window).off('resize', this.handlers.onresize);
$(document).off('mousemove touchmove', this.handlers.onmousemove).off('mouseup touchend', this.handlers.onmouseup);
} }
/** /**
@ -196,13 +208,6 @@ export default class PostStreamScrubber extends Component {
this.updateScrubberValues({ animate: true, forceHeightChange: true }); this.updateScrubberValues({ animate: true, forceHeightChange: true });
} }
ondestroy() {
this.scrollListener.stop();
$(window).off('resize', this.handlers.onresize);
$(document).off('mousemove touchmove', this.handlers.onmousemove).off('mouseup touchend', this.handlers.onmouseup);
}
onresize() { onresize() {
// Adjust the height of the scrollbar so that it fills the height of // Adjust the height of the scrollbar so that it fills the height of
// the sidebar and doesn't overlap the footer. // the sidebar and doesn't overlap the footer.

Some files were not shown because too many files have changed in this diff Show More