1
0
mirror of https://github.com/flarum/core.git synced 2025-08-14 20:34:10 +02:00

Compare commits

..

4 Commits

Author SHA1 Message Date
Daniël Klabbers
fd371c1203 Release v0.1.0-beta.13 2020-05-04 12:52:03 +02:00
Franz Liedke
d72416cd8a Make two more tests compatible with PHPUnit 8 2020-05-02 15:35:18 +02:00
Franz Liedke
937ff1a0d5 Remove obsolete method 2020-05-02 15:34:03 +02:00
Alexander Skvortsov
097a87dbb6 Added simply confirmation popup for hiding / deleting posts (#2135) 2020-04-24 23:26:48 +02:00
227 changed files with 4401 additions and 4469 deletions

View File

@@ -3,55 +3,47 @@
## [0.1.0-beta.13](https://github.com/flarum/core/compare/v0.1.0-beta.12...v0.1.0-beta.13)
### Added
- Middleware extender (#2017, #2063, #2084)
- Console extender (#2057)
- CSRF extender (#2095)
- Event extender (#2097)
- Mail extender (#2012)
- Model extender (#2100)
- Posts by users that started a discussion now have the CSS class `.Post--by-start-user`
- PHPUnit 8 compatibility
- Show discussion start user as html class on post
- PHPUnit 8 compatibility.
- Composer 2 compatibility
- Permission groups can now be hidden (#2129)
- Confirmation popup when hiding or deleting posts (#2135)
### Changed
- Updated less.php dependency version to 3.0
- Updated less.php dependency version to 3.0.
- All notifications now processed through the queue (#1931)
- Updated JS dependencies
- All notifications and other emails now processed through the queue, if enabled (#978, #1928, #1931, #2096)
- Simplified uploads, removing need to store intermediate files (#2117)
- Improved date handling for dates older than 1 year (#2034)
- Linting and automatic formatting for JS (#2099)
- Translation files from Language Packs are only loaded for extensions that are enabled (#2020)
- PHP extenders' properties are now `private` instead of `protected`, intentionally making it harder to extend these classes (#1958)
- Preparation for upgrading Laravel components to 5.8 and then 6.0 (#2055, #2117)
- Allowed permission checks based on model classes in addition to instances (#1977)
### Fixed
- Users can no longer restore discussions hidden by admins (#2037)
- Issues of the Modal not showing or auto hiding (#1504, #1813, #2080)
- Columnar layout on admin extensions page was broken in Firefox (#2029, #2111)
- Non-dismissible modals could still be dismissed using the ESC key (#1917)
- New discussions were added to the discussion list above unread sticky posts (#1751, #1868)
- New discussions not visible to users when using Pusher (#2076, #2077)
- Permission icons were aligned unevenly in admin permissions list (#2016, #2018)
- Notification bubble not inversed on mobile with colored header (#1983, #2109)
- Post stream scrubber clicks jumped back to first post (#1945)
- Loading state of Switch toggle component was hard to see (#2039, #1491)
- `Flarum\Extend\Middleware`: The methods `insertBefore()` and `insertAfter()` did not work as described (#2063, #2084)
- Users can no longer restore discussions hidden by others (#2037)
- Issues of the Modal not showing or auto hiding (#2080)
- Extensions page in admin showning columns incorrectly (#2111)
- Non dismissable modals can be dismissed using the ESC key (#1917)
- New post injected above unread sticky (#1868)
- New discussions not visible to users when using Pusher (#2077)
- Icons on admin permissions page (#2016, #2018)
- Notification bubble contrast on mobile with colored header (#2109)
- PostStreamScrubber click jumps back to first position (#1945)
- Loading state of Switch toggle component is hard to see (#2039, #1491)
- Allowing permission check to use class name based gate checks (#1977)
### Removed
- Support for PHP 7.1 (#2014)
- Zend compatibility bridge (#2010)
- SES mail support (#2011)
- Backward compatibility layer for `Flarum\Mail\DriverInterface`, new methods from beta.12 are now required
- `Flarum\Util\Str` helper class
- `Flarum\Event\ConfigureMiddleware` event
### Deprecated
- `Flarum\Event\AbstractConfigureRoutes` event class
- `Flarum\Event\ConfigureApiRoutes` event class
- `Flarum\Event\ConfigureForumRoutes` event class
- `Flarum\Event\ConfigureLocales` event class
- Backward compatibility dropped for mail drivers
- Support for PHP 7.1
- Deprecated Flarum\Util\Str helper class
- Deprecated ConfigureMiddleware event
## [0.1.0-beta.12](https://github.com/flarum/core/compare/v0.1.0-beta.11.1...v0.1.0-beta.12)

View File

@@ -38,24 +38,24 @@
"php": ">=7.2",
"axy/sourcemap": "^0.1.4",
"components/font-awesome": "5.9.*",
"dflydev/fig-cookies": "^2.0.1",
"dflydev/fig-cookies": "^1.0.2",
"doctrine/dbal": "^2.7",
"franzl/whoops-middleware": "^0.4.0",
"illuminate/bus": "^6.0",
"illuminate/cache": "^6.0",
"illuminate/config": "^6.0",
"illuminate/container": "^6.0",
"illuminate/contracts": "^6.0",
"illuminate/database": "^6.0",
"illuminate/events": "^6.0",
"illuminate/filesystem": "^6.0",
"illuminate/hashing": "^6.0",
"illuminate/mail": "^6.0",
"illuminate/queue": "^6.0",
"illuminate/session": "^6.0",
"illuminate/support": "^6.0",
"illuminate/validation": "^6.0",
"illuminate/view": "^6.0",
"illuminate/bus": "5.7.*",
"illuminate/cache": "5.7.*",
"illuminate/config": "5.7.*",
"illuminate/container": "5.7.*",
"illuminate/contracts": "5.7.*",
"illuminate/database": "5.7.*",
"illuminate/events": "5.7.*",
"illuminate/filesystem": "5.7.*",
"illuminate/hashing": "5.7.*",
"illuminate/mail": "5.7.*",
"illuminate/queue": "5.7.*",
"illuminate/session": "5.7.*",
"illuminate/support": "5.7.*",
"illuminate/validation": "5.7.*",
"illuminate/view": "5.7.*",
"intervention/image": "^2.5.0",
"laminas/laminas-diactoros": "^1.8.4",
"laminas/laminas-httphandlerrunner": "^1.0",
@@ -66,7 +66,6 @@
"middlewares/base-path-router": "^0.2.1",
"middlewares/request-handler": "^1.2",
"monolog/monolog": "^1.16.0",
"nesbot/carbon": "^2.0",
"nikic/fast-route": "^0.6",
"psr/http-message": "^1.0",
"psr/http-server-handler": "^1.0",

6
js/dist/admin.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

8
js/dist/forum.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1038
js/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,11 +2,9 @@
"private": true,
"name": "@flarum/core",
"dependencies": {
"@babel/preset-typescript": "^7.10.1",
"bootstrap": "^3.4.1",
"classnames": "^2.2.5",
"color-thief-browser": "^2.0.2",
"dayjs": "^1.8.28",
"expose-loader": "^0.7.5",
"flarum-webpack-config": "0.1.0-beta.10",
"jquery": "^3.4.1",
@@ -14,10 +12,11 @@
"lodash-es": "^4.17.14",
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",
"mithril": "^0.2.8",
"moment": "^2.22.2",
"punycode": "^2.1.1",
"spin.js": "^3.1.0",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11",
"webpack": "^4.41.2",
"webpack-cli": "^3.1.2",
"webpack-merge": "^4.1.4"
},
"devDependencies": {

View File

@@ -6,6 +6,7 @@ import EditCustomFooterModal from './components/EditCustomFooterModal';
import SessionDropdown from './components/SessionDropdown';
import HeaderPrimary from './components/HeaderPrimary';
import AppearancePage from './components/AppearancePage';
import Page from './components/Page';
import StatusWidget from './components/StatusWidget';
import HeaderSecondary from './components/HeaderSecondary';
import SettingsModal from './components/SettingsModal';
@@ -14,6 +15,7 @@ import AddExtensionModal from './components/AddExtensionModal';
import ExtensionsPage from './components/ExtensionsPage';
import AdminLinkButton from './components/AdminLinkButton';
import PermissionGrid from './components/PermissionGrid';
import Widget from './components/Widget';
import MailPage from './components/MailPage';
import UploadImageButton from './components/UploadImageButton';
import LoadingModal from './components/LoadingModal';
@@ -35,6 +37,7 @@ export default Object.assign(compat, {
'components/SessionDropdown': SessionDropdown,
'components/HeaderPrimary': HeaderPrimary,
'components/AppearancePage': AppearancePage,
'components/Page': Page,
'components/StatusWidget': StatusWidget,
'components/HeaderSecondary': HeaderSecondary,
'components/SettingsModal': SettingsModal,
@@ -43,6 +46,7 @@ export default Object.assign(compat, {
'components/ExtensionsPage': ExtensionsPage,
'components/AdminLinkButton': AdminLinkButton,
'components/PermissionGrid': PermissionGrid,
'components/Widget': Widget,
'components/MailPage': MailPage,
'components/UploadImageButton': UploadImageButton,
'components/LoadingModal': LoadingModal,

View File

@@ -1,4 +1,4 @@
import Page from '../../common/components/Page';
import Page from './Page';
import Button from '../../common/components/Button';
import Switch from '../../common/components/Switch';
import EditCustomCssModal from './EditCustomCssModal';
@@ -13,8 +13,8 @@ export default class AppearancePage extends Page {
this.primaryColor = m.prop(app.data.settings.theme_primary_color);
this.secondaryColor = m.prop(app.data.settings.theme_secondary_color);
this.darkMode = m.prop(app.data.settings.theme_dark_mode);
this.coloredHeader = m.prop(app.data.settings.theme_colored_header);
this.darkMode = m.prop(app.data.settings.theme_dark_mode === '1');
this.coloredHeader = m.prop(app.data.settings.theme_colored_header === '1');
}
view() {
@@ -82,7 +82,7 @@ export default class AppearancePage extends Page {
{Button.component({
className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_header_button'),
onclick: () => app.modal.show(EditCustomHeaderModal),
onclick: () => app.modal.show(new EditCustomHeaderModal()),
})}
</fieldset>
@@ -92,7 +92,7 @@ export default class AppearancePage extends Page {
{Button.component({
className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_footer_button'),
onclick: () => app.modal.show(EditCustomFooterModal),
onclick: () => app.modal.show(new EditCustomFooterModal()),
})}
</fieldset>
@@ -102,7 +102,7 @@ export default class AppearancePage extends Page {
{Button.component({
className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_css_button'),
onclick: () => app.modal.show(EditCustomCssModal),
onclick: () => app.modal.show(new EditCustomCssModal()),
})}
</fieldset>
</div>

View File

@@ -1,7 +1,8 @@
import Page from '../../common/components/Page';
import Page from './Page';
import FieldSet from '../../common/components/FieldSet';
import Select from '../../common/components/Select';
import Button from '../../common/components/Button';
import Alert from '../../common/components/Alert';
import saveSettings from '../utils/saveSettings';
import ItemList from '../../common/utils/ItemList';
import Switch from '../../common/components/Switch';
@@ -20,7 +21,6 @@ export default class BasicsPage extends Page {
'default_route',
'welcome_title',
'welcome_message',
'display_name_driver',
];
this.values = {};
@@ -33,14 +33,6 @@ export default class BasicsPage extends Page {
this.localeOptions[i] = `${locales[i]} (${i})`;
}
this.displayNameOptions = {};
const displayNameDrivers = app.data.displayNameDrivers;
displayNameDrivers.forEach(function (identifier) {
this.displayNameOptions[identifier] = identifier;
}, this);
if (!this.values.display_name_driver() && displayNameDrivers.includes('username')) this.values.display_name_driver('username');
if (typeof this.values.show_language_selector() !== 'number') this.values.show_language_selector(1);
}
@@ -122,20 +114,6 @@ export default class BasicsPage extends Page {
],
})}
{Object.keys(this.displayNameOptions).length > 1
? FieldSet.component({
label: app.translator.trans('core.admin.basics.display_name_heading'),
children: [
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>,
Select.component({
options: this.displayNameOptions,
value: this.values.display_name_driver(),
onchange: this.values.display_name_driver,
}),
],
})
: ''}
{Button.component({
type: 'submit',
className: 'Button Button--primary',
@@ -185,10 +163,7 @@ export default class BasicsPage extends Page {
saveSettings(settings)
.then(() => {
this.successAlert = app.alerts.show({
type: 'success',
children: app.translator.trans('core.admin.basics.saved_message'),
});
app.alerts.show((this.successAlert = new Alert({ type: 'success', children: app.translator.trans('core.admin.basics.saved_message') })));
})
.catch(() => {})
.then(() => {

View File

@@ -1,4 +1,4 @@
import Page from '../../common/components/Page';
import Page from './Page';
import StatusWidget from './StatusWidget';
export default class DashboardPage extends Page {

View File

@@ -1,8 +1,17 @@
/*
* 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.
*/
import Component from '../../common/Component';
export default class DashboardWidget extends Component {
export default class Widget extends Component {
view() {
return <div className={'DashboardWidget Widget ' + this.className()}>{this.content()}</div>;
return <div className={'Widget ' + this.className()}>{this.content()}</div>;
}
/**

View File

@@ -1,10 +1,13 @@
import Page from '../../common/components/Page';
import Page from './Page';
import LinkButton from '../../common/components/LinkButton';
import Button from '../../common/components/Button';
import Dropdown from '../../common/components/Dropdown';
import Separator from '../../common/components/Separator';
import AddExtensionModal from './AddExtensionModal';
import LoadingModal from './LoadingModal';
import ItemList from '../../common/utils/ItemList';
import icon from '../../common/helpers/icon';
import listItems from '../../common/helpers/listItems';
export default class ExtensionsPage extends Page {
view() {
@@ -16,7 +19,7 @@ export default class ExtensionsPage extends Page {
children: app.translator.trans('core.admin.extensions.add_button'),
icon: 'fas fa-plus',
className: 'Button Button--primary',
onclick: () => app.modal.show(AddExtensionModal),
onclick: () => app.modal.show(new AddExtensionModal()),
})}
</div>
</div>
@@ -94,7 +97,7 @@ export default class ExtensionsPage extends Page {
})
.then(() => window.location.reload());
app.modal.show(LoadingModal);
app.modal.show(new LoadingModal());
},
})
);
@@ -123,6 +126,6 @@ export default class ExtensionsPage extends Page {
window.location.reload();
});
app.modal.show(LoadingModal);
app.modal.show(new LoadingModal());
}
}

View File

@@ -1,10 +1,9 @@
import Modal from '../../common/components/Modal';
export default class LoadingModal extends Modal {
/**
* @inheritdoc
*/
static isDismissible = false;
isDismissible() {
return false;
}
className() {
return 'LoadingModal Modal--small';

View File

@@ -1,4 +1,4 @@
import Page from '../../common/components/Page';
import Page from './Page';
import FieldSet from '../../common/components/FieldSet';
import Button from '../../common/components/Button';
import Alert from '../../common/components/Alert';
@@ -11,7 +11,6 @@ export default class MailPage extends Page {
super.init();
this.saving = false;
this.sendingTest = false;
this.refresh();
}
@@ -29,7 +28,7 @@ export default class MailPage extends Page {
app
.request({
method: 'GET',
url: app.forum.attribute('apiUrl') + '/mail/settings',
url: app.forum.attribute('apiUrl') + '/mail-settings',
})
.then((response) => {
this.driverFields = response['data']['attributes']['fields'];
@@ -122,27 +121,11 @@ export default class MailPage extends Page {
],
})}
<FieldSet>
{Button.component({
type: 'submit',
className: 'Button Button--primary',
children: app.translator.trans('core.admin.email.submit_button'),
disabled: !this.changed(),
})}
</FieldSet>
{FieldSet.component({
label: app.translator.trans('core.admin.email.send_test_mail_heading'),
className: 'MailPage-MailSettings',
children: [
<div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user.email() })}</div>,
Button.component({
className: 'Button Button--primary',
children: app.translator.trans('core.admin.email.send_test_mail_button'),
disabled: this.sendingTest || this.changed(),
onclick: () => this.sendTestEmail(),
}),
],
{Button.component({
type: 'submit',
className: 'Button Button--primary',
children: app.translator.trans('core.admin.email.submit_button'),
disabled: !this.changed(),
})}
</form>
</div>
@@ -166,35 +149,10 @@ export default class MailPage extends Page {
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
}
sendTestEmail() {
if (this.saving || this.sendingTest) return;
this.sendingTest = true;
app.alerts.dismiss(this.testEmailSuccessAlert);
app
.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/mail/test',
})
.then((response) => {
this.sendingTest = false;
this.testEmailSuccessAlert = app.alerts.show({
type: 'success',
children: app.translator.trans('core.admin.email.send_test_mail_success'),
});
})
.catch((error) => {
this.sendingTest = false;
m.redraw();
throw error;
});
}
onsubmit(e) {
e.preventDefault();
if (this.saving || this.sendingTest) return;
if (this.saving) return;
this.saving = true;
app.alerts.dismiss(this.successAlert);
@@ -205,10 +163,7 @@ export default class MailPage extends Page {
saveSettings(settings)
.then(() => {
this.successAlert = app.alerts.show({
type: 'success',
children: app.translator.trans('core.admin.basics.saved_message'),
});
app.alerts.show((this.successAlert = new Alert({ type: 'success', children: app.translator.trans('core.admin.basics.saved_message') })));
})
.catch(() => {})
.then(() => {

View File

@@ -0,0 +1,32 @@
import Component from '../../common/Component';
/**
* The `Page` component
*
* @abstract
*/
export default class Page extends Component {
init() {
app.previous = app.current;
app.current = this;
app.modal.close();
/**
* A class name to apply to the body while the route is active.
*
* @type {String}
*/
this.bodyClass = '';
}
config(isInitialized, context) {
if (isInitialized) return;
if (this.bodyClass) {
$('#app').addClass(this.bodyClass);
context.onunload = () => $('#app').removeClass(this.bodyClass);
}
}
}

View File

@@ -1,4 +1,4 @@
import Page from '../../common/components/Page';
import Page from './Page';
import GroupBadge from '../../common/components/GroupBadge';
import EditGroupModal from './EditGroupModal';
import Group from '../../common/models/Group';
@@ -15,7 +15,7 @@ export default class PermissionsPage extends Page {
.all('groups')
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
.map((group) => (
<button className="Button Group" onclick={() => app.modal.show(EditGroupModal, { group })}>
<button className="Button Group" onclick={() => app.modal.show(new EditGroupModal({ group }))}>
{GroupBadge.component({
group,
className: 'Group-icon',
@@ -24,7 +24,7 @@ export default class PermissionsPage extends Page {
<span className="Group-name">{group.namePlural()}</span>
</button>
))}
<button className="Button Group Group--add" onclick={() => app.modal.show(EditGroupModal)}>
<button className="Button Group Group--add" onclick={() => app.modal.show(new EditGroupModal())}>
{icon('fas fa-plus', { className: 'Group-icon' })}
<span className="Group-name">{app.translator.trans('core.admin.permissions.new_group_button')}</span>
</button>

View File

@@ -46,7 +46,7 @@ export default class StatusWidget extends DashboardWidget {
}
handleClearCache(e) {
app.modal.show(LoadingModal);
app.modal.show(new LoadingModal());
app
.request({

View File

@@ -0,0 +1,34 @@
/*
* 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.
*/
import Component from '../../common/Component';
export default class DashboardWidget extends Component {
view() {
return <div className={'DashboardWidget ' + this.className()}>{this.content()}</div>;
}
/**
* Get the class name to apply to the widget.
*
* @return {String}
*/
className() {
return '';
}
/**
* Get the content of the widget.
*
* @return {VirtualElement}
*/
content() {
return [];
}
}

View File

@@ -1,4 +1,5 @@
import ItemList from './utils/ItemList';
import Alert from './components/Alert';
import Button from './components/Button';
import ModalManager from './components/ModalManager';
import AlertManager from './components/AlertManager';
@@ -11,7 +12,6 @@ import Drawer from './utils/Drawer';
import mapRoutes from './utils/mapRoutes';
import RequestError from './utils/RequestError';
import ScrollListener from './utils/ScrollListener';
import liveHumanTimes from './utils/liveHumanTimes';
import { extend } from './extend';
import Forum from './models/Forum';
@@ -21,9 +21,6 @@ import Post from './models/Post';
import Group from './models/Group';
import Notification from './models/Notification';
import { flattenDeep } from 'lodash-es';
import PageState from './states/PageState';
import ModalManagerState from './states/ModalManagerState';
import AlertManagerState from './states/AlertManagerState';
/**
* The `App` class provides a container for an application, as well as various
@@ -110,49 +107,13 @@ export default class Application {
booted = false;
/**
* The key for an Alert that was shown as a result of an AJAX request error.
* If present, it will be dismissed on the next successful request.
* An Alert that was shown as a result of an AJAX request error. If present,
* it will be dismissed on the next successful request.
*
* @type {int}
* @type {null|Alert}
* @private
*/
requestErrorAlert = null;
/**
* The page the app is currently on.
*
* This object holds information about the type of page we are currently
* visiting, and sometimes additional arbitrary page state that may be
* relevant to lower-level components.
*
* @type {PageState}
*/
current = new PageState(null);
/**
* The page the app was on before the current page.
*
* Once the application navigates to another page, the object previously
* assigned to this.current will be moved to this.previous, while this.current
* is re-initialized.
*
* @type {PageState}
*/
previous = new PageState(null);
/*
* An object that manages modal state.
*
* @type {ModalManagerState}
*/
modal = new ModalManagerState();
/**
* An object that manages the state of active alerts.
*
* @type {AlertManagerState}
*/
alerts = new AlertManagerState();
requestError = null;
data;
@@ -189,8 +150,8 @@ export default class Application {
}
mount(basePath = '') {
m.mount(document.getElementById('modal'), <ModalManager state={this.modal} />);
m.mount(document.getElementById('alerts'), <AlertManager state={this.alerts} />);
this.modal = m.mount(document.getElementById('modal'), <ModalManager />);
this.alerts = m.mount(document.getElementById('alerts'), <AlertManager />);
this.drawer = new Drawer();
@@ -208,8 +169,6 @@ export default class Application {
$(() => {
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
});
liveHumanTimes();
}
/**
@@ -230,16 +189,6 @@ export default class Application {
return null;
}
/**
* Determine the current screen mode, based on our media queries.
*
* @returns {String} - one of "phone", "tablet", "desktop" or "desktop-hd"
*/
screen() {
const styles = getComputedStyle(document.documentElement);
return styles.getPropertyValue('--flarum-screen');
}
/**
* Set the <title> of the page.
*
@@ -262,10 +211,7 @@ export default class Application {
}
updateTitle() {
const count = this.titleCount ? `(${this.titleCount}) ` : '';
const pageTitleWithSeparator = this.title && m.route() !== '/' ? this.title + ' - ' : '';
const title = this.forum.attribute('title');
document.title = count + pageTitleWithSeparator + title;
document.title = (this.titleCount ? `(${this.titleCount}) ` : '') + (this.title ? this.title + ' - ' : '') + this.forum.attribute('title');
}
/**
@@ -338,7 +284,7 @@ export default class Application {
}
};
if (this.requestErrorAlert) this.alerts.dismiss(this.requestErrorAlert);
if (this.requestError) this.alerts.dismiss(this.requestError.alert);
// Now make the request. If it's a failure, inspect the error that was
// returned and show an alert containing its contents.
@@ -347,6 +293,8 @@ export default class Application {
m.request(options).then(
(response) => deferred.resolve(response),
(error) => {
this.requestError = error;
let children;
switch (error.status) {
@@ -376,35 +324,21 @@ export default class Application {
}
const isDebug = app.forum.attribute('debug');
// 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'
const formattedError = error.response && Array.isArray(error.response.errors) && error.response.errors.map((e) => decodeURI(e.detail));
error.alert = {
error.alert = new Alert({
type: 'error',
children,
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)}>
Debug
</Button>,
],
};
});
try {
options.errorHandler(error);
} catch (error) {
if (isDebug && error.xhr) {
const { method, url } = error.options;
const { status = '' } = error.xhr;
console.group(`${method} ${url} ${status}`);
console.error(...(formattedError || [error]));
console.groupEnd();
}
this.requestErrorAlert = this.alerts.show(error.alert);
this.alerts.show(error.alert);
}
deferred.reject(error);
@@ -416,13 +350,12 @@ export default class Application {
/**
* @param {RequestError} error
* @param {string[]} [formattedError]
* @private
*/
showDebug(error, formattedError) {
this.alerts.dismiss(this.requestErrorAlert);
showDebug(error) {
this.alerts.dismiss(this.requestError.alert);
this.modal.show(RequestErrorModal, { error, formattedError });
this.modal.show(new RequestErrorModal({ error }));
}
/**

View File

@@ -30,7 +30,6 @@ import Forum from './models/Forum';
import Component from './Component';
import Translator from './Translator';
import AlertManager from './components/AlertManager';
import Page from './components/Page';
import Switch from './components/Switch';
import Badge from './components/Badge';
import LoadingIndicator from './components/LoadingIndicator';
@@ -95,7 +94,6 @@ export default {
Component: Component,
Translator: Translator,
'components/AlertManager': AlertManager,
'components/Page': Page,
'components/Switch': Switch,
'components/Badge': Badge,
'components/LoadingIndicator': LoadingIndicator,

View File

@@ -7,16 +7,20 @@ import Alert from './Alert';
*/
export default class AlertManager extends Component {
init() {
this.state = this.props.state;
/**
* An array of Alert components which are currently showing.
*
* @type {Alert[]}
* @protected
*/
this.components = [];
}
view() {
return (
<div className="AlertManager">
{Object.entries(this.state.getActiveAlerts()).map(([key, alert]) => (
<div className="AlertManager-alert">
{(alert.componentClass || Alert).component({ ...alert.attrs, ondismiss: this.state.dismiss.bind(this.state, key) })}
</div>
{this.components.map((component) => (
<div className="AlertManager-alert">{component}</div>
))}
</div>
);
@@ -28,4 +32,46 @@ export default class AlertManager extends Component {
// to be retained across route changes.
context.retain = true;
}
/**
* Show an Alert in the alerts area.
*
* @param {Alert} component
* @public
*/
show(component) {
if (!(component instanceof Alert)) {
throw new Error('The AlertManager component can only show Alert components');
}
component.props.ondismiss = this.dismiss.bind(this, component);
this.components.push(component);
m.redraw();
}
/**
* Dismiss an alert.
*
* @param {Alert} component
* @public
*/
dismiss(component) {
const index = this.components.indexOf(component);
if (index !== -1) {
this.components.splice(index, 1);
m.redraw();
}
}
/**
* Clear all alerts.
*
* @public
*/
clear() {
this.components = [];
m.redraw();
}
}

View File

@@ -30,6 +30,6 @@ export default class Badge extends Component {
config(isInitialized) {
if (isInitialized) return;
if (this.props.label) this.$().tooltip();
if (this.props.label) this.$().tooltip({ container: 'body' });
}
}

View File

@@ -10,17 +10,23 @@ import icon from '../helpers/icon';
* - `state` Whether or not the checkbox is checked.
* - `className` The class name for the root element.
* - `disabled` Whether or not the checkbox is disabled.
* - `loading` Whether or not the checkbox is loading.
* - `onchange` A callback to run when the checkbox is checked/unchecked.
* - `children` A text label to display next to the checkbox.
*/
export default class Checkbox extends Component {
init() {
/**
* Whether or not the checkbox's value is in the process of being saved.
*
* @type {Boolean}
* @public
*/
this.loading = false;
}
view() {
// Sometimes, false is stored in the DB as '0'. This is a temporary
// conversion layer until a more robust settings encoding is introduced
if (this.props.state === '0') this.props.state = false;
let className = 'Checkbox ' + (this.props.state ? 'on' : 'off') + ' ' + (this.props.className || '');
if (this.props.loading) className += ' loading';
if (this.loading) className += ' loading';
if (this.props.disabled) className += ' disabled';
return (
@@ -39,7 +45,7 @@ export default class Checkbox extends Component {
* @protected
*/
getDisplay() {
return this.props.loading ? LoadingIndicator.component({ size: 'tiny' }) : icon(this.props.state ? 'fas fa-check' : 'fas fa-times');
return this.loading ? LoadingIndicator.component({ size: 'tiny' }) : icon(this.props.state ? 'fas fa-check' : 'fas fa-times');
}
/**

View File

@@ -1,37 +0,0 @@
import Component from '../Component';
/**
* The `ConfirmDocumentUnload` component can be used to register a global
* event handler that prevents closing the browser window/tab based on the
* return value of a given callback prop.
*
* ### Props
*
* - `when` - a callback returning true when the browser should prompt for
* confirmation before closing the window/tab
*
* ### Children
*
* NOTE: Only the first child will be rendered. (Use this component to wrap
* another component / DOM element.)
*
*/
export default class ConfirmDocumentUnload extends Component {
config(isInitialized, context) {
if (isInitialized) return;
const handler = () => this.props.when() || undefined;
$(window).on('beforeunload', handler);
context.onunload = () => {
$(window).off('beforeunload', handler);
};
}
view() {
// To avoid having to render another wrapping <div> here, we assume that
// this component is only wrapped around a single element / component.
return this.props.children[0];
}
}

View File

@@ -9,39 +9,24 @@ import Button from './Button';
* @abstract
*/
export default class Modal extends Component {
/**
* Determine whether or not the modal should be dismissible via an 'x' button.
*/
static isDismissible = true;
init() {
/**
* Attributes for an alert component to show below the header.
* An alert component to show below the header.
*
* @type {object}
* @type {Alert}
*/
this.alertAttrs = null;
}
config(isInitialized, context) {
if (isInitialized) return;
this.props.onshow(() => this.onready());
context.onunload = () => {
this.props.onhide();
};
this.alert = null;
}
view() {
if (this.alertAttrs) {
this.alertAttrs.dismissible = false;
if (this.alert) {
this.alert.props.dismissible = false;
}
return (
<div className={'Modal modal-dialog ' + this.className()}>
<div className="Modal-content">
{this.constructor.isDismissible ? (
{this.isDismissible() ? (
<div className="Modal-close App-backControl">
{Button.component({
icon: 'fas fa-times',
@@ -58,7 +43,7 @@ export default class Modal extends Component {
<h3 className="App-titleControl App-titleControl--text">{this.title()}</h3>
</div>
{this.alertAttrs ? <div className="Modal-alert">{Alert.component(this.alertAttrs)}</div> : ''}
{alert ? <div className="Modal-alert">{this.alert}</div> : ''}
{this.content()}
</form>
@@ -67,6 +52,15 @@ export default class Modal extends Component {
);
}
/**
* Determine whether or not the modal should be dismissible via an 'x' button.
*
* @return {Boolean}
*/
isDismissible() {
return true;
}
/**
* Get the class name to apply to the modal.
*
@@ -111,7 +105,7 @@ export default class Modal extends Component {
* Hide the modal.
*/
hide() {
this.props.onhide();
app.modal.close();
}
/**
@@ -129,7 +123,7 @@ export default class Modal extends Component {
* @param {RequestError} error
*/
onerror(error) {
this.alertAttrs = error.alert;
this.alert = error.alert;
m.redraw();

View File

@@ -1,4 +1,5 @@
import Component from '../Component';
import Modal from './Modal';
/**
* The `ModalManager` component manages a modal dialog. Only one modal dialog
@@ -7,17 +8,12 @@ import Component from '../Component';
*/
export default class ModalManager extends Component {
init() {
this.state = this.props.state;
this.showing = false;
this.component = null;
}
view() {
const modal = this.state.modal;
return (
<div className="ModalManager modal fade">
{modal ? modal.componentClass.component({ ...modal.attrs, onshow: this.animateShow.bind(this), onhide: this.animateHide.bind(this) }) : ''}
</div>
);
return <div className="ModalManager modal fade">{this.component && this.component.render()}</div>;
}
config(isInitialized, context) {
@@ -28,17 +24,31 @@ export default class ModalManager extends Component {
// to be retained across route changes.
context.retain = true;
// Ensure the modal state is notified about a closed modal, even when the
// DOM-based Bootstrap JavaScript code triggered the closing of the modal,
// 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.clear.bind(this)).on('shown.bs.modal', this.onready.bind(this));
}
animateShow(readyCallback) {
const dismissible = !!this.state.modal.componentClass.isDismissible;
/**
* Show a modal dialog.
*
* @param {Modal} component
* @public
*/
show(component) {
if (!(component instanceof Modal)) {
throw new Error('The ModalManager component can only show Modal components');
}
clearTimeout(this.hideTimeout);
this.showing = true;
this.component = component;
if (app.current) app.current.retain = true;
m.redraw(true);
const dismissible = !!this.component.isDismissible();
this.$()
.one('shown.bs.modal', readyCallback)
.modal({
backdrop: dismissible || 'static',
keyboard: dismissible,
@@ -46,7 +56,50 @@ export default class ModalManager extends Component {
.modal('show');
}
animateHide() {
this.$().modal('hide');
/**
* Close the modal dialog.
*
* @public
*/
close() {
if (!this.showing) return;
// Don't hide the modal immediately, because if the consumer happens to call
// the `show` method straight after to show another modal dialog, it will
// cause Bootstrap's modal JS to misbehave. Instead we will wait for a tiny
// bit to give the `show` method the opportunity to prevent this from going
// ahead.
this.hideTimeout = setTimeout(() => {
this.$().modal('hide');
this.showing = false;
});
}
/**
* Clear content from the modal area.
*
* @protected
*/
clear() {
if (this.component) {
this.component.onhide();
}
this.component = null;
app.current.retain = false;
m.lazyRedraw();
}
/**
* When the modal dialog is ready to be used, tell it!
*
* @protected
*/
onready() {
if (this.component && this.component.onready) {
this.component.onready(this.$());
}
}
}

View File

@@ -6,26 +6,16 @@ export default class RequestErrorModal extends Modal {
}
title() {
return this.props.error.xhr ? `${this.props.error.xhr.status} ${this.props.error.xhr.statusText}` : '';
return this.props.error.xhr ? this.props.error.xhr.status + ' ' + this.props.error.xhr.statusText : '';
}
content() {
const { error, formattedError } = this.props;
let responseText;
// If the error is already formatted, just add line endings;
// else try to parse it as JSON and stringify it with indentation
if (formattedError) {
responseText = formattedError.join('\n\n');
} else {
try {
const json = error.response || JSON.parse(error.responseText);
responseText = JSON.stringify(json, null, 2);
} catch (e) {
responseText = error.responseText;
}
try {
responseText = JSON.stringify(JSON.parse(this.props.error.responseText), null, 2);
} catch (e) {
responseText = this.props.error.responseText;
}
return (

View File

@@ -12,6 +12,6 @@ export default class Switch extends Checkbox {
}
getDisplay() {
return this.props.loading ? super.getDisplay() : '';
return this.loading ? super.getDisplay() : '';
}
}

View File

@@ -1,4 +1,4 @@
export default class Model {
export default class Routes {
type;
attributes = [];
hasOnes = [];

View File

@@ -6,10 +6,10 @@
* @return {Object}
*/
export default function fullTime(time) {
const d = dayjs(time);
const mo = moment(time);
const datetime = d.format();
const full = d.format('LLLL');
const datetime = mo.format();
const full = mo.format('LLLL');
return (
<time pubdate datetime={datetime}>

View File

@@ -9,10 +9,10 @@ import humanTimeUtil from '../utils/humanTime';
* @return {Object}
*/
export default function humanTime(time) {
const d = dayjs(time);
const mo = moment(time);
const datetime = d.format();
const full = d.format('LLLL');
const datetime = mo.format();
const full = mo.format('LLLL');
const ago = humanTimeUtil(time);
return (

View File

@@ -1,6 +1,6 @@
import 'expose-loader?$!expose-loader?jQuery!jquery';
import 'expose-loader?m!mithril';
import 'expose-loader?moment!expose-loader?dayjs!dayjs';
import 'expose-loader?moment!moment';
import 'expose-loader?m.bidi!m.attrs.bidi';
import 'bootstrap/js/affix';
import 'bootstrap/js/dropdown';
@@ -9,12 +9,6 @@ import 'bootstrap/js/tooltip';
import 'bootstrap/js/transition';
import 'jquery.hotkeys/jquery.hotkeys';
import relativeTime from 'dayjs/plugin/relativeTime';
import localizedFormat from 'dayjs/plugin/localizedFormat';
dayjs.extend(relativeTime);
dayjs.extend(localizedFormat);
import patchMithril from './utils/patchMithril';
patchMithril(window);

View File

@@ -68,10 +68,7 @@ Object.assign(Discussion.prototype, {
const user = app.session.user;
if (user && user.markedAllAsReadAt() < this.lastPostedAt()) {
const unreadCount = Math.max(0, this.lastPostNumber() - (this.lastReadPostNumber() || 0));
// If posts have been deleted, it's possible that the unread count could exceed the
// actual post count. As such, we take the min of the two to ensure this isn't an issue.
return Math.min(unreadCount, this.commentCount());
return Math.max(0, this.lastPostNumber() - (this.lastReadPostNumber() || 0));
}
return 0;

View File

@@ -54,7 +54,7 @@ Object.assign(User.prototype, {
* @public
*/
isOnline() {
return dayjs().subtract(5, 'minutes').isBefore(this.lastSeenAt());
return this.lastSeenAt() > moment().subtract(5, 'minutes').toDate();
},
/**

View File

@@ -1,50 +0,0 @@
import Alert from '../components/Alert';
export default class AlertManagerState {
constructor() {
this.activeAlerts = {};
this.alertId = 0;
}
getActiveAlerts() {
return this.activeAlerts;
}
/**
* Show an Alert in the alerts area.
*/
show(attrs, componentClass = Alert) {
// 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.
if (attrs === Alert || attrs instanceof Alert) {
// This is duplicated so that if the error is caught, an error message still shows up in the debug console.
console.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
this.activeAlerts[++this.alertId] = { attrs, componentClass };
m.redraw();
return this.alertId;
}
/**
* Dismiss an alert.
*/
dismiss(key) {
if (!key || !(key in this.activeAlerts)) return;
delete this.activeAlerts[key];
m.redraw();
}
/**
* Clear all alerts.
*
* @public
*/
clear() {
this.activeAlerts = {};
m.redraw();
}
}

View File

@@ -1,56 +0,0 @@
import Modal from '../components/Modal';
export default class ModalManagerState {
constructor() {
this.modal = null;
}
/**
* Show a modal dialog.
*
* @public
*/
show(componentClass, attrs) {
// Breaking Change Compliance Warning, Remove in Beta 15.
if (!(componentClass.prototype instanceof Modal)) {
// This is duplicated so that if the error is caught, an error message still shows up in the debug console.
console.error('The ModalManager can only show Modals');
throw new Error('The ModalManager can only show Modals');
}
if (componentClass.init) {
// This is duplicated so that if the error is caught, an error message still shows up in the debug console.
console.error(
'The componentClass parameter must be a modal class, not a modal instance. Whichever extension triggered this modal should be updated to comply with beta 14.'
);
throw new Error(
'The componentClass parameter must be a modal class, not a modal instance. Whichever extension triggered this modal should be updated to comply with beta 14.'
);
}
// End Change Compliance Warning, Remove in Beta 15
clearTimeout(this.closeTimeout);
this.modal = { componentClass, attrs };
m.redraw(true);
}
/**
* Close the modal dialog.
*
* @public
*/
close() {
if (!this.modal) return;
// Don't hide the modal immediately, because if the consumer happens to call
// the `show` method straight after to show another modal dialog, it will
// cause Bootstrap's modal JS to misbehave. Instead we will wait for a tiny
// bit to give the `show` method the opportunity to prevent this from going
// ahead.
this.closeTimeout = setTimeout(() => {
this.modal = null;
m.lazyRedraw();
});
}
}

View File

@@ -1,33 +0,0 @@
import subclassOf from '../../common/utils/subclassOf';
export default class PageState {
constructor(type, data = {}) {
this.type = type;
this.data = data;
}
/**
* Determine whether the page matches the given class and data.
*
* @param {object} type The page class to check against. Subclasses are
* accepted as well.
* @param {object} data
* @return {boolean}
*/
matches(type, data = {}) {
// Fail early when the page is of a different type
if (!subclassOf(this.type, type)) return false;
// Now that the type is known to be correct, we loop through the provided
// data to see whether it matches the data in our state.
return Object.keys(data).every((key) => this.data[key] === data[key]);
}
get(key) {
return this.data[key];
}
set(key, value) {
this.data[key] = value;
}
}

View File

@@ -1,9 +1,5 @@
class Item {
content: any;
priority: number;
key?: number;
constructor(content: any, priority?: number) {
constructor(content, priority) {
this.content = content;
this.priority = priority;
}
@@ -14,15 +10,23 @@ class Item {
* by priority.
*/
export default class ItemList {
/**
* The items in the list
*/
items: { [key: string]: Item } = {};
constructor() {
/**
* The items in the list.
*
* @type {Object}
* @public
*/
this.items = {};
}
/**
* Check whether the list is empty.
*
* @returns {boolean}
* @public
*/
isEmpty(): boolean {
isEmpty() {
for (const i in this.items) {
if (this.items.hasOwnProperty(i)) {
return false;
@@ -34,27 +38,36 @@ export default class ItemList {
/**
* Check whether an item is present in the list.
*
* @param key
* @returns {boolean}
*/
has(key: string): boolean {
has(key) {
return !!this.items[key];
}
/**
* Get the content of an item.
*
* @param {String} key
* @return {*}
* @public
*/
get(key: string): any {
get(key) {
return this.items[key].content;
}
/**
* Add an item to the list.
*
* @param key A unique key for the item.
* @param content The item's content.
* @param [priority] The priority of the item. Items with a higher
* @param {String} key A unique key for the item.
* @param {*} content The item's content.
* @param {Integer} [priority] The priority of the item. Items with a higher
* priority will be positioned before items with a lower priority.
* @return {ItemList}
* @public
*/
add(key: string, content: any, priority: number = 0): this {
add(key, content, priority = 0) {
this.items[key] = new Item(content, priority);
return this;
@@ -62,8 +75,14 @@ export default class ItemList {
/**
* Replace an item in the list, only if it is already present.
*
* @param {String} key
* @param {*} [content]
* @param {Integer} [priority]
* @return {ItemList}
* @public
*/
replace(key: string, content: any = null, priority: number = null): this {
replace(key, content = null, priority = null) {
if (this.items[key]) {
if (content !== null) {
this.items[key].content = content;
@@ -79,8 +98,12 @@ export default class ItemList {
/**
* Remove an item from the list.
*
* @param {String} key
* @return {ItemList}
* @public
*/
remove(key: string): this {
remove(key) {
delete this.items[key];
return this;
@@ -88,8 +111,12 @@ export default class ItemList {
/**
* Merge another list's items into this one.
*
* @param {ItemList} items
* @return {ItemList}
* @public
*/
merge(items: this): this {
merge(items) {
for (const i in items.items) {
if (items.items.hasOwnProperty(i) && items.items[i] instanceof Item) {
this.items[i] = items.items[i];
@@ -103,9 +130,12 @@ export default class ItemList {
* Convert the list into an array of item content arranged by priority. Each
* item's content will be assigned an `itemName` property equal to the item's
* unique key.
*
* @return {Array}
* @public
*/
toArray(): any[] {
const items: Item[] = [];
toArray() {
const items = [];
for (const i in this.items) {
if (this.items.hasOwnProperty(i) && this.items[i] instanceof Item) {

View File

@@ -1,14 +1,5 @@
export default class RequestError {
status: string;
options: object;
xhr: XMLHttpRequest;
responseText: string | null;
response: object | null;
alert: any;
constructor(status: string, responseText: string | null, options: object, xhr: XMLHttpRequest) {
constructor(status, responseText, options, xhr) {
this.status = status;
this.responseText = responseText;
this.options = options;

View File

@@ -1,109 +0,0 @@
/**
* A textarea wrapper with powerful helpers for text manipulation.
*
* This wraps a <textarea> DOM element and allows directly manipulating its text
* contents and cursor positions.
*
* I apologize for the pretentious name. :)
*/
export default class SuperTextarea {
/**
* @param {HTMLTextAreaElement} textarea
*/
constructor(textarea) {
this.el = textarea;
this.$ = $(textarea);
}
/**
* Set the value of the text editor.
*
* @param {String} value
*/
setValue(value) {
this.$.val(value).trigger('input');
}
/**
* Focus the textarea and place the cursor at the given index.
*
* @param {number} position
*/
moveCursorTo(position) {
this.setSelectionRange(position, position);
}
/**
* Get the selected range of the textarea.
*
* @return {Array}
*/
getSelectionRange() {
return [this.el.selectionStart, this.el.selectionEnd];
}
/**
* Insert content into the textarea at the position of the cursor.
*
* @param {String} text
*/
insertAtCursor(text) {
this.insertAt(this.el.selectionStart, text);
this.el.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
}
/**
* Insert content into the textarea at the given position.
*
* @param {number} pos
* @param {String} text
*/
insertAt(pos, text) {
this.insertBetween(pos, pos, text);
}
/**
* Insert content into the textarea between the given positions.
*
* If the start and end positions are different, any text between them will be
* overwritten.
*
* @param start
* @param end
* @param text
*/
insertBetween(start, end, text) {
const value = this.el.value;
const before = value.slice(0, start);
const after = value.slice(end);
this.setValue(`${before}${text}${after}`);
// Move the textarea cursor to the end of the content we just inserted.
this.moveCursorTo(start + text.length);
}
/**
* Replace existing content from the start to the current cursor position.
*
* @param start
* @param text
*/
replaceBeforeCursor(start, text) {
this.insertBetween(start, this.el.selectionStart, text);
}
/**
* Set the selected range of the textarea.
*
* @param {number} start
* @param {number} end
* @private
*/
setSelectionRange(start, end) {
this.el.setSelectionRange(start, end);
this.$.focus();
}
}

View File

@@ -4,8 +4,11 @@
* @example
* abbreviateNumber(1234);
* // "1.2K"
*
* @param {Integer} number
* @return {String}
*/
export default function abbreviateNumber(number: number): string {
export default function abbreviateNumber(number) {
// TODO: translation
if (number >= 1000000) {
return Math.floor(number / 1000000) + app.translator.trans('core.lib.number_suffix.mega_text');

View File

@@ -0,0 +1,15 @@
/**
* The `extract` utility deletes a property from an object and returns its
* value.
*
* @param {Object} object The object that owns the property
* @param {String} property The name of the property to extract
* @return {*} The value of the property
*/
export default function extract(object, property) {
const value = object[property];
delete object[property];
return value;
}

View File

@@ -1,15 +0,0 @@
/**
* The `extract` utility deletes a property from an object and returns its
* value.
*
* @param object The object that owns the property
* @param property The name of the property to extract
* @return The value of the property
*/
export default function extract<T, K extends keyof T>(object: T, property: K): T[K] {
const value = object[property];
delete object[property];
return value;
}

View File

@@ -5,7 +5,10 @@
* @example
* formatNumber(1234);
* // 1,234
*
* @param {Number} number
* @return {String}
*/
export default function formatNumber(number: number): string {
export default function formatNumber(number) {
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}

View File

@@ -1,32 +1,35 @@
/**
* The `humanTime` utility converts a date to a localized, human-readable time-
* ago string.
*
* @param {Date} time
* @return {String}
*/
export default function humanTime(time: Date): string {
let d = dayjs(time);
const now = dayjs();
export default function humanTime(time) {
let m = moment(time);
const now = moment();
// To prevent showing things like "in a few seconds" due to small offsets
// between client and server time, we always reset future dates to the
// current time. This will result in "just now" being shown instead.
if (d.isAfter(now)) {
d = now;
if (m.isAfter(now)) {
m = now;
}
const day = 864e5;
const diff = d.diff(dayjs());
let ago: string;
const diff = m.diff(moment());
let ago = null;
// If this date was more than a month ago, we'll show the name of the month
// in the string. If it wasn't this year, we'll show the year as well.
if (diff < -30 * day) {
if (d.year() === dayjs().year()) {
ago = d.format('D MMM');
if (m.year() === moment().year()) {
ago = m.format('D MMM');
} else {
ago = d.format('ll');
ago = m.format('ll');
}
} else {
ago = d.fromNow();
ago = m.fromNow();
}
return ago;

View File

@@ -1,18 +1,18 @@
import humanTime from './humanTime';
import humanTimeUtil from './humanTime';
function updateHumanTimes() {
$('[data-humantime]').each(function () {
const $this = $(this);
const ago = humanTime($this.attr('datetime'));
const ago = humanTimeUtil($this.attr('datetime'));
$this.html(ago);
});
}
/**
* The `liveHumanTimes` initializer sets up a loop every 1 second to update
* The `humanTime` initializer sets up a loop every 1 second to update
* timestamps rendered with the `humanTime` helper.
*/
export default function liveHumanTimes() {
export default function humanTime() {
setInterval(updateHumanTimes, 10000);
}

View File

@@ -1,7 +1,12 @@
/**
* Truncate a string to the given length, appending ellipses if necessary.
*
* @param {String} string
* @param {Number} length
* @param {Number} [start=0]
* @return {String}
*/
export function truncate(string: string, length: number, start: number = 0): string {
export function truncate(string, length, start = 0) {
return (start > 0 ? '...' : '') + string.substring(start, start + length) + (string.length > start + length ? '...' : '');
}
@@ -12,8 +17,11 @@ export function truncate(string: string, length: number, start: number = 0): str
* NOTE: This method does not use the comparably sophisticated transliteration
* mechanism that is employed in the backend. Therefore, it should only be used
* to *suggest* slugs that can be overridden by the user.
*
* @param {String} string
* @return {String}
*/
export function slug(string: string): string {
export function slug(string) {
return string
.toLowerCase()
.replace(/[^a-z0-9]/gi, '-')
@@ -24,8 +32,11 @@ export function slug(string: string): string {
/**
* Strip HTML tags and quotes out of the given string, replacing them with
* meaningful punctuation.
*
* @param {String} string
* @return {String}
*/
export function getPlainContent(string: string): string {
export function getPlainContent(string) {
const html = string.replace(/(<\/p>|<br>)/g, '$1 &nbsp;').replace(/<img\b[^>]*>/gi, ' ');
const dom = $('<div/>').html(html);
@@ -44,7 +55,10 @@ getPlainContent.removeSelectors = ['blockquote', 'script'];
/**
* Make a string's first character uppercase.
*
* @param {String} string
* @return {String}
*/
export function ucfirst(string: string): string {
export function ucfirst(string) {
return string.substr(0, 1).toUpperCase() + string.substr(1);
}

View File

@@ -1,6 +1,4 @@
type RGB = { r: number; g: number; b: number };
function hsvToRgb(h: number, s: number, v: number): RGB {
function hsvToRgb(h, s, v) {
let r;
let g;
let b;
@@ -53,8 +51,11 @@ function hsvToRgb(h: number, s: number, v: number): RGB {
/**
* Convert the given string to a unique color.
*
* @param {String} string
* @return {String}
*/
export default function stringToColor(string: string): string {
export default function stringToColor(string) {
let num = 0;
// Convert the username into a number based on the ASCII value of each

View File

@@ -1,6 +0,0 @@
/**
* Check if class A is the same as or a subclass of class B.
*/
export default function subclassOf(A, B) {
return A && (A === B || A.prototype instanceof B);
}

View File

@@ -1,5 +1,7 @@
import History from './utils/History';
import Pane from './utils/Pane';
import Search from './components/Search';
import ReplyComposer from './components/ReplyComposer';
import DiscussionPage from './components/DiscussionPage';
import SignUpModal from './components/SignUpModal';
import HeaderPrimary from './components/HeaderPrimary';
@@ -12,10 +14,6 @@ import routes from './routes';
import alertEmailConfirmation from './utils/alertEmailConfirmation';
import Application from '../common/Application';
import Navigation from '../common/components/Navigation';
import NotificationListState from './states/NotificationListState';
import GlobalSearchState from './states/GlobalSearchState';
import DiscussionListState from './states/DiscussionListState';
import ComposerState from './states/ComposerState';
export default class ForumApplication extends Application {
/**
@@ -36,6 +34,13 @@ export default class ForumApplication extends Application {
discussionRenamed: DiscussionRenamedPost,
};
/**
* The page's search component instance.
*
* @type {Search}
*/
search = new Search();
/**
* An object which controls the state of the page's side pane.
*
@@ -58,43 +63,10 @@ export default class ForumApplication extends Application {
*/
history = new History();
/**
* An object which controls the state of the user's notifications.
*
* @type {NotificationListState}
*/
notifications = new NotificationListState(this);
/*
* An object which stores previously searched queries and provides convenient
* tools for retrieving and managing search values.
*
* @type {GlobalSearchState}
*/
search = new GlobalSearchState();
/*
* An object which controls the state of the composer.
*/
composer = new ComposerState();
constructor() {
super();
routes(this);
/**
* An object which controls the state of the cached discussion list, which
* is used in the index page and the slideout pane.
*
* @type {DiscussionListState}
*/
this.discussions = new DiscussionListState({}, this);
/**
* @deprecated beta 14, remove in beta 15.
*/
this.cache.discussionList = this.discussions;
}
/**
@@ -119,9 +91,9 @@ export default class ForumApplication extends Application {
m.mount(document.getElementById('header-navigation'), Navigation.component());
m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component());
m.mount(document.getElementById('composer'), Composer.component({ state: this.composer }));
this.pane = new Pane(document.getElementById('app'));
this.composer = m.mount(document.getElementById('composer'), Composer.component());
m.route.mode = 'pathname';
super.mount(this.forum.attribute('basePath'));
@@ -143,6 +115,21 @@ export default class ForumApplication extends Application {
});
}
/**
* Check whether or not the user is currently composing a reply to a
* discussion.
*
* @param {Discussion} discussion
* @return {Boolean}
*/
composingReplyTo(discussion) {
return (
this.composer.component instanceof ReplyComposer &&
this.composer.component.props.discussion === discussion &&
this.composer.position !== Composer.PositionEnum.HIDDEN
);
}
/**
* Check whether or not the user is currently viewing a discussion.
*
@@ -150,7 +137,7 @@ export default class ForumApplication extends Application {
* @return {Boolean}
*/
viewingDiscussion(discussion) {
return this.current.matches(DiscussionPage, { discussion });
return this.current instanceof DiscussionPage && this.current.discussion === discussion;
}
/**
@@ -170,7 +157,8 @@ export default class ForumApplication extends Application {
if (payload.loggedIn) {
window.location.reload();
} else {
this.modal.show(SignUpModal, payload);
const modal = new SignUpModal(payload);
this.modal.show(modal);
}
}
}

View File

@@ -9,10 +9,6 @@ import DiscussionControls from './utils/DiscussionControls';
import alertEmailConfirmation from './utils/alertEmailConfirmation';
import UserControls from './utils/UserControls';
import Pane from './utils/Pane';
import DiscussionListState from './states/DiscussionListState';
import GlobalSearchState from './states/GlobalSearchState';
import NotificationListState from './states/NotificationListState';
import SearchState from './states/SearchState';
import DiscussionPage from './components/DiscussionPage';
import LogInModal from './components/LogInModal';
import ComposerBody from './components/ComposerBody';
@@ -27,6 +23,7 @@ import PostEdited from './components/PostEdited';
import PostStream from './components/PostStream';
import ChangePasswordModal from './components/ChangePasswordModal';
import IndexPage from './components/IndexPage';
import Page from './components/Page';
import DiscussionRenamedNotification from './components/DiscussionRenamedNotification';
import DiscussionsSearchSource from './components/DiscussionsSearchSource';
import HeaderSecondary from './components/HeaderSecondary';
@@ -81,10 +78,6 @@ export default Object.assign(compat, {
'utils/alertEmailConfirmation': alertEmailConfirmation,
'utils/UserControls': UserControls,
'utils/Pane': Pane,
'states/DiscussionListState': DiscussionListState,
'states/GlobalSearchState': GlobalSearchState,
'states/NotificationListState': NotificationListState,
'states/SearchState': SearchState,
'components/DiscussionPage': DiscussionPage,
'components/LogInModal': LogInModal,
'components/ComposerBody': ComposerBody,
@@ -99,6 +92,7 @@ export default Object.assign(compat, {
'components/PostStream': PostStream,
'components/ChangePasswordModal': ChangePasswordModal,
'components/IndexPage': IndexPage,
'components/Page': Page,
'components/DiscussionRenamedNotification': DiscussionRenamedNotification,
'components/DiscussionsSearchSource': DiscussionsSearchSource,
'components/HeaderSecondary': HeaderSecondary,

View File

@@ -122,7 +122,7 @@ export default class ChangeEmailModal extends Modal {
onerror(error) {
if (error.status === 401) {
error.alert.children = app.translator.trans('core.forum.change_email.incorrect_password_message');
error.alert.props.children = app.translator.trans('core.forum.change_email.incorrect_password_message');
}
super.onerror(error);

View File

@@ -31,16 +31,11 @@ export default class CommentPost extends Post {
*/
this.revealContent = false;
/**
* Whether or not the user hover card inside of PostUser is visible.
* The property must be managed in CommentPost to be able to use it in the subtree check
*
* @type {Boolean}
*/
this.cardVisible = false;
// Create an instance of the component that displays the post's author so
// that we can force the post to rerender when the user card is shown.
this.postUser = new PostUser({ post: this.props.post });
this.subtree.check(
() => this.cardVisible,
() => this.postUser.cardVisible,
() => this.isEditing()
);
}
@@ -77,7 +72,7 @@ export default class CommentPost extends Post {
}
isEditing() {
return app.composer.bodyMatches(EditPostComposer, { post: this.props.post });
return app.composer.component instanceof EditPostComposer && app.composer.component.props.post === this.props.post;
}
attrs() {
@@ -105,7 +100,7 @@ export default class CommentPost extends Post {
// body with a preview.
let preview;
const updatePreview = () => {
const content = app.composer.fields.content();
const content = app.composer.component.content();
if (preview === content) return;
@@ -134,27 +129,13 @@ export default class CommentPost extends Post {
headerItems() {
const items = new ItemList();
const post = this.props.post;
const props = { post };
items.add(
'user',
PostUser.component({
post,
cardVisible: this.cardVisible,
oncardshow: () => {
this.cardVisible = true;
m.redraw();
},
oncardhide: () => {
this.cardVisible = false;
m.redraw();
},
}),
100
);
items.add('meta', PostMeta.component({ post }));
items.add('user', this.postUser.render(), 100);
items.add('meta', PostMeta.component(props));
if (post.isEdited() && !post.isHidden()) {
items.add('edited', PostEdited.component({ post }));
items.add('edited', PostEdited.component(props));
}
// If the post is hidden, add a button that allows toggling the visibility

View File

@@ -3,21 +3,28 @@ import ItemList from '../../common/utils/ItemList';
import ComposerButton from './ComposerButton';
import listItems from '../../common/helpers/listItems';
import classList from '../../common/utils/classList';
import ComposerState from '../states/ComposerState';
/**
* The `Composer` component displays the composer. It can be loaded with a
* content component with `load` and then its position/state can be altered with
* `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`.
*/
export default class Composer extends Component {
class Composer extends Component {
init() {
/**
* The composer's "state".
* The composer's current position.
*
* @type {ComposerState}
* @type {Composer.PositionEnum}
*/
this.state = this.props.state;
this.position = Composer.PositionEnum.HIDDEN;
/**
* The composer's intended height, which can be modified by the user
* (by dragging the composer handle).
*
* @type {Integer}
*/
this.height = null;
/**
* Whether or not the composer currently has focus.
@@ -25,45 +32,39 @@ export default class Composer extends Component {
* @type {Boolean}
*/
this.active = false;
// Store the initial position so that we can trigger animations correctly.
this.prevPosition = this.state.position;
}
view() {
const body = this.state.body;
const classes = {
normal: this.state.position === ComposerState.Position.NORMAL,
minimized: this.state.position === ComposerState.Position.MINIMIZED,
fullScreen: this.state.position === ComposerState.Position.FULLSCREEN,
normal: this.position === Composer.PositionEnum.NORMAL,
minimized: this.position === Composer.PositionEnum.MINIMIZED,
fullScreen: this.position === Composer.PositionEnum.FULLSCREEN,
active: this.active,
visible: this.state.isVisible(),
};
classes.visible = classes.normal || classes.minimized || classes.fullScreen;
// Set up a handler so that clicks on the content will show the composer.
const showIfMinimized = this.state.position === ComposerState.Position.MINIMIZED ? this.state.show.bind(this.state) : undefined;
// If the composer is minimized, tell the composer's content component that
// it shouldn't let the user interact with it. Set up a handler so that if
// the content IS clicked, the composer will be shown.
if (this.component) this.component.props.disabled = classes.minimized;
const showIfMinimized = this.position === Composer.PositionEnum.MINIMIZED ? this.show.bind(this) : undefined;
return (
<div className={'Composer ' + classList(classes)}>
<div className="Composer-handle" config={this.configHandle.bind(this)} />
<ul className="Composer-controls">{listItems(this.controlItems().toArray())}</ul>
<div className="Composer-content" onclick={showIfMinimized}>
{body.componentClass ? body.componentClass.component({ ...body.attrs, composer: this.state, disabled: classes.minimized }) : ''}
{this.component ? this.component.render() : ''}
</div>
</div>
);
}
config(isInitialized, context) {
if (this.state.position === this.prevPosition) {
// 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.
this.updateHeight();
} else {
this.animatePositionChange();
this.prevPosition = this.state.position;
}
// 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.
this.updateHeight();
if (isInitialized) return;
@@ -72,7 +73,7 @@ export default class Composer extends Component {
context.retain = true;
this.initializeHeight();
this.$().hide().css('bottom', -this.state.computedHeight());
this.$().hide().css('bottom', -this.computedHeight());
// Whenever any of the inputs inside the composer are have focus, we want to
// add a class to the composer to draw attention to it.
@@ -84,6 +85,13 @@ export default class Composer extends Component {
// When the escape key is pressed on any inputs, close the composer.
this.$().on('keydown', ':input', 'esc', () => this.close());
// Don't let the user leave the page without first giving the composer's
// component a chance to scream at the user to make sure they don't
// unintentionally lose any contnet.
window.onbeforeunload = () => {
return (this.component && this.component.preventExit()) || undefined;
};
const handlers = {};
$(window)
@@ -158,20 +166,13 @@ export default class Composer extends Component {
$('body').css('cursor', '');
}
/**
* Draw focus to the first focusable content element (the text editor).
*/
focus() {
this.$('.Composer-content :input:enabled:visible:first').focus();
}
/**
* Update the DOM to reflect the composer's current height. This involves
* setting the height of the composer's root element, and adjusting the height
* of any flexible elements inside the composer's body.
*/
updateHeight() {
const height = this.state.computedHeight();
const height = this.computedHeight();
const $flexible = this.$('.Composer-flexible');
this.$().height(height);
@@ -192,59 +193,109 @@ export default class Composer extends Component {
*/
updateBodyPadding() {
const visible =
this.state.position !== ComposerState.Position.HIDDEN && this.state.position !== ComposerState.Position.MINIMIZED && app.screen() !== 'phone';
this.position !== Composer.PositionEnum.HIDDEN && this.position !== Composer.PositionEnum.MINIMIZED && this.$().css('position') !== 'absolute';
const paddingBottom = visible ? this.state.computedHeight() - parseInt($('#app').css('padding-bottom'), 10) : 0;
const paddingBottom = visible ? this.computedHeight() - parseInt($('#app').css('padding-bottom'), 10) : 0;
$('#content').css({ paddingBottom });
}
/**
* Trigger the right animation depending on the desired new position.
* Determine whether or not the Composer is covering the screen.
*
* This will be true if the Composer is in full-screen mode on desktop, or
* if the Composer is positioned absolutely as on mobile devices.
*
* @return {Boolean}
* @public
*/
animatePositionChange() {
// When exiting full-screen mode: focus content
if (this.prevPosition === ComposerState.Position.FULLSCREEN) {
this.focus();
return;
}
isFullScreen() {
return this.position === Composer.PositionEnum.FULLSCREEN || this.$().css('position') === 'absolute';
}
switch (this.state.position) {
case ComposerState.Position.HIDDEN:
return this.hide();
case ComposerState.Position.MINIMIZED:
return this.minimize();
case ComposerState.Position.FULLSCREEN:
return this.focus();
case ComposerState.Position.NORMAL:
return this.show();
/**
* Confirm with the user that they want to close the composer and lose their
* content.
*
* @return {Boolean} Whether or not the exit was cancelled.
*/
preventExit() {
if (this.component) {
const preventExit = this.component.preventExit();
if (preventExit) {
return !confirm(preventExit);
}
}
}
/**
* Animate the Composer into the new position by changing the height.
* Load a content component into the composer.
*
* @param {Component} component
* @public
*/
animateHeightChange() {
load(component) {
if (this.preventExit()) return;
// If we load a similar component into the composer, then Mithril will be
// able to diff the old/new contents and some DOM-related state from the
// old composer will remain. To prevent this from happening, we clear the
// component and force a redraw, so that the new component will be working
// on a blank slate.
if (this.component) {
this.clear();
m.redraw(true);
}
this.component = component;
}
/**
* Clear the composer's content component.
*
* @public
*/
clear() {
this.component = null;
}
/**
* Animate the Composer into the given position.
*
* @param {Composer.PositionEnum} position
*/
animateToPosition(position) {
// Before we redraw the composer to its new state, we need to save the
// current height of the composer, as well as the page's scroll position, so
// that we can smoothly transition from the old to the new state.
const oldPosition = this.position;
const $composer = this.$().stop(true);
const oldHeight = $composer.outerHeight();
const scrollTop = $(window).scrollTop();
this.position = position;
m.redraw(true);
// Now that we've redrawn and the composer's DOM has been updated, we want
// to update the composer's height. Once we've done that, we'll capture the
// real value to use as the end point for our animation later on.
$composer.show();
this.updateHeight();
const newHeight = $composer.outerHeight();
if (this.prevPosition === ComposerState.Position.HIDDEN) {
if (oldPosition === Composer.PositionEnum.HIDDEN) {
$composer.css({ bottom: -newHeight, height: newHeight });
} else {
$composer.css({ height: oldHeight });
}
const animation = $composer.animate({ bottom: 0, height: newHeight }, 'fast').promise();
$composer.animate({ bottom: 0, height: newHeight }, 'fast', () => this.component.focus());
this.updateBodyPadding();
$(window).scrollTop(scrollTop);
return animation;
}
/**
@@ -262,30 +313,40 @@ export default class Composer extends Component {
}
/**
* Animate the composer sliding up from the bottom to take its normal height.
* Show the composer.
*
* @private
* @public
*/
show() {
this.animateHeightChange().then(() => this.focus());
if (this.position === Composer.PositionEnum.NORMAL || this.position === Composer.PositionEnum.FULLSCREEN) {
return;
}
if (app.screen() === 'phone') {
this.animateToPosition(Composer.PositionEnum.NORMAL);
if (this.isFullScreen()) {
this.$().css('top', $(window).scrollTop());
this.showBackdrop();
this.component.focus();
}
}
/**
* Animate closing the composer.
* Close the composer.
*
* @private
* @public
*/
hide() {
const $composer = this.$();
// Animate the composer sliding down off the bottom edge of the viewport.
// Only when the animation is completed, update other elements on the page.
// Only when the animation is completed, update the Composer state flag and
// other elements on the page.
$composer.stop(true).animate({ bottom: -$composer.height() }, 'fast', () => {
this.position = Composer.PositionEnum.HIDDEN;
this.clear();
m.redraw();
$composer.hide();
this.hideBackdrop();
this.updateBodyPadding();
@@ -293,17 +354,60 @@ export default class Composer extends Component {
}
/**
* Shrink the composer until only its title is visible.
* Confirm with the user so they don't lose their content, then close the
* composer.
*
* @private
* @public
*/
close() {
if (!this.preventExit()) {
this.hide();
}
}
/**
* Minimize the composer. Has no effect if the composer is hidden.
*
* @public
*/
minimize() {
this.animateHeightChange();
if (this.position === Composer.PositionEnum.HIDDEN) return;
this.animateToPosition(Composer.PositionEnum.MINIMIZED);
this.$().css('top', 'auto');
this.hideBackdrop();
}
/**
* Take the composer into fullscreen mode. Has no effect if the composer is
* hidden.
*
* @public
*/
fullScreen() {
if (this.position !== Composer.PositionEnum.HIDDEN) {
this.position = Composer.PositionEnum.FULLSCREEN;
m.redraw();
this.updateHeight();
this.component.focus();
}
}
/**
* Exit fullscreen mode.
*
* @public
*/
exitFullScreen() {
if (this.position === Composer.PositionEnum.FULLSCREEN) {
this.position = Composer.PositionEnum.NORMAL;
m.redraw();
this.updateHeight();
this.component.focus();
}
}
/**
* Build an item list for the composer's controls.
*
@@ -312,23 +416,23 @@ export default class Composer extends Component {
controlItems() {
const items = new ItemList();
if (this.state.position === ComposerState.Position.FULLSCREEN) {
if (this.position === Composer.PositionEnum.FULLSCREEN) {
items.add(
'exitFullScreen',
ComposerButton.component({
icon: 'fas fa-compress',
title: app.translator.trans('core.forum.composer.exit_full_screen_tooltip'),
onclick: this.state.exitFullScreen.bind(this.state),
onclick: this.exitFullScreen.bind(this),
})
);
} else {
if (this.state.position !== ComposerState.Position.MINIMIZED) {
if (this.position !== Composer.PositionEnum.MINIMIZED) {
items.add(
'minimize',
ComposerButton.component({
icon: 'fas fa-minus minimize',
title: app.translator.trans('core.forum.composer.minimize_tooltip'),
onclick: this.state.minimize.bind(this.state),
onclick: this.minimize.bind(this),
itemClassName: 'App-backControl',
})
);
@@ -338,7 +442,7 @@ export default class Composer extends Component {
ComposerButton.component({
icon: 'fas fa-expand',
title: app.translator.trans('core.forum.composer.full_screen_tooltip'),
onclick: this.state.fullScreen.bind(this.state),
onclick: this.fullScreen.bind(this),
})
);
}
@@ -348,7 +452,7 @@ export default class Composer extends Component {
ComposerButton.component({
icon: 'fas fa-times',
title: app.translator.trans('core.forum.composer.close_tooltip'),
onclick: this.state.close.bind(this.state),
onclick: this.close.bind(this),
})
);
}
@@ -360,10 +464,10 @@ export default class Composer extends Component {
* Initialize default Composer height.
*/
initializeHeight() {
this.state.height = localStorage.getItem('composerHeight');
this.height = localStorage.getItem('composerHeight');
if (!this.state.height) {
this.state.height = this.defaultHeight();
if (!this.height) {
this.height = this.defaultHeight();
}
}
@@ -375,14 +479,60 @@ export default class Composer extends Component {
return this.$().height();
}
/**
* Minimum height of the Composer.
* @returns {Integer}
*/
minimumHeight() {
return 200;
}
/**
* Maxmimum height of the Composer.
* @returns {Integer}
*/
maximumHeight() {
return $(window).height() - $('#header').outerHeight();
}
/**
* Computed the composer's current height, based on the intended height, and
* the composer's current state. This will be applied to the composer's
* content's DOM element.
* @returns {Integer|String}
*/
computedHeight() {
// If the composer is minimized, then we don't want to set a height; we'll
// let the CSS decide how high it is. If it's fullscreen, then we need to
// make it as high as the window.
if (this.position === Composer.PositionEnum.MINIMIZED) {
return '';
} else if (this.position === Composer.PositionEnum.FULLSCREEN) {
return $(window).height();
}
// Otherwise, if it's normal or hidden, then we use the intended height.
// We don't let the composer get too small or too big, though.
return Math.max(this.minimumHeight(), Math.min(this.height, this.maximumHeight()));
}
/**
* Save a new Composer height and update the DOM.
* @param {Integer} height
*/
changeHeight(height) {
this.state.height = height;
this.height = height;
this.updateHeight();
localStorage.setItem('composerHeight', this.state.height);
localStorage.setItem('composerHeight', this.height);
}
}
Composer.PositionEnum = {
HIDDEN: 'hidden',
NORMAL: 'normal',
MINIMIZED: 'minimized',
FULLSCREEN: 'fullScreen',
};
export default Composer;

View File

@@ -1,6 +1,5 @@
import Component from '../../common/Component';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import ConfirmDocumentUnload from '../../common/components/ConfirmDocumentUnload';
import TextEditor from './TextEditor';
import avatar from '../../common/helpers/avatar';
import listItems from '../../common/helpers/listItems';
@@ -13,7 +12,6 @@ import ItemList from '../../common/utils/ItemList';
*
* ### Props
*
* - `composer`
* - `originalContent`
* - `submitLabel`
* - `placeholder`
@@ -25,8 +23,6 @@ import ItemList from '../../common/utils/ItemList';
*/
export default class ComposerBody extends Component {
init() {
this.composer = this.props.composer;
/**
* Whether or not the component is loading.
*
@@ -34,57 +30,60 @@ export default class ComposerBody extends Component {
*/
this.loading = false;
// Let the composer state know to ask for confirmation under certain
// circumstances, if the body supports / requires it and has a corresponding
// confirmation question to ask.
if (this.props.confirmExit) {
this.composer.preventClosingWhen(() => this.hasChanges(), this.props.confirmExit);
}
this.composer.fields.content(this.props.originalContent || '');
/**
* The content of the text editor.
*
* @type {Function}
*/
this.content = m.prop(this.props.originalContent);
/**
* @deprecated BC layer, remove in Beta 15.
* The text editor component instance.
*
* @type {TextEditor}
*/
this.content = this.composer.fields.content;
this.editor = this.composer;
this.editor = new TextEditor({
submitLabel: this.props.submitLabel,
placeholder: this.props.placeholder,
onchange: this.content,
onsubmit: this.onsubmit.bind(this),
value: this.content(),
});
}
view() {
// If the component is loading, we should disable the text editor.
this.editor.props.disabled = this.loading;
return (
<ConfirmDocumentUnload when={this.hasChanges.bind(this)}>
<div className={'ComposerBody ' + (this.props.className || '')}>
{avatar(this.props.user, { className: 'ComposerBody-avatar' })}
<div className="ComposerBody-content">
<ul className="ComposerBody-header">{listItems(this.headerItems().toArray())}</ul>
<div className="ComposerBody-editor">
{TextEditor.component({
submitLabel: this.props.submitLabel,
placeholder: this.props.placeholder,
disabled: this.loading || this.props.disabled,
composer: this.composer,
preview: this.jumpToPreview && this.jumpToPreview.bind(this),
onchange: this.composer.fields.content,
onsubmit: this.onsubmit.bind(this),
value: this.composer.fields.content(),
})}
</div>
</div>
{LoadingIndicator.component({ className: 'ComposerBody-loading' + (this.loading ? ' active' : '') })}
<div className={'ComposerBody ' + (this.props.className || '')}>
{avatar(this.props.user, { className: 'ComposerBody-avatar' })}
<div className="ComposerBody-content">
<ul className="ComposerBody-header">{listItems(this.headerItems().toArray())}</ul>
<div className="ComposerBody-editor">{this.editor.render()}</div>
</div>
</ConfirmDocumentUnload>
{LoadingIndicator.component({ className: 'ComposerBody-loading' + (this.loading ? ' active' : '') })}
</div>
);
}
/**
* Check if there is any unsaved data.
* Draw focus to the text editor.
*/
focus() {
this.$(':input:enabled:visible:first').focus();
}
/**
* Check if there is any unsaved data if there is, return a confirmation
* message to prompt the user with.
*
* @return {String}
*/
hasChanges() {
const content = this.composer.fields.content();
preventExit() {
const content = this.content();
return content && content !== this.props.originalContent;
return content && content !== this.props.originalContent && this.props.confirmExit;
}
/**

View File

@@ -16,14 +16,12 @@ export default class DiscussionComposer extends ComposerBody {
init() {
super.init();
this.composer.fields.title = this.composer.fields.title || m.prop('');
/**
* The value of the title input.
*
* @type {Function}
*/
this.title = this.composer.fields.title;
this.title = m.prop('');
}
static initProps(props) {
@@ -68,14 +66,14 @@ export default class DiscussionComposer extends ComposerBody {
if (e.which === 13) {
// Return
e.preventDefault();
this.composer.editor.moveCursorTo(0);
this.editor.setSelectionRange(0, 0);
}
m.redraw.strategy('none');
}
hasChanges() {
return this.title() || this.composer.fields.content();
preventExit() {
return (this.title() || this.content()) && this.props.confirmExit;
}
/**
@@ -86,7 +84,7 @@ export default class DiscussionComposer extends ComposerBody {
data() {
return {
title: this.title(),
content: this.composer.fields.content(),
content: this.content(),
};
}
@@ -99,8 +97,8 @@ export default class DiscussionComposer extends ComposerBody {
.createRecord('discussions')
.save(data)
.then((discussion) => {
this.composer.hide();
app.discussions.refresh();
app.composer.hide();
app.cache.discussionList.refresh();
m.route(app.route.discussion(discussion));
}, this.loaded.bind(this));
}

View File

@@ -9,38 +9,58 @@ import Placeholder from '../../common/components/Placeholder';
*
* ### Props
*
* - `state` A DiscussionListState object that represents the discussion lists's state.
* - `params` A map of parameters used to construct a refined parameter object
* to send along in the API request to get discussion results.
*/
export default class DiscussionList extends Component {
init() {
this.state = this.props.state;
/**
* Whether or not discussion results are loading.
*
* @type {Boolean}
*/
this.loading = true;
/**
* Whether or not there are more results that can be loaded.
*
* @type {Boolean}
*/
this.moreResults = false;
/**
* The discussions in the discussion list.
*
* @type {Discussion[]}
*/
this.discussions = [];
this.refresh();
}
view() {
const state = this.state;
const params = state.getParams();
const params = this.props.params;
let loading;
if (state.isLoading()) {
if (this.loading) {
loading = LoadingIndicator.component();
} else if (state.moreResults) {
} else if (this.moreResults) {
loading = Button.component({
children: app.translator.trans('core.forum.discussion_list.load_more_button'),
className: 'Button',
onclick: state.loadMore.bind(state),
onclick: this.loadMore.bind(this),
});
}
if (state.empty()) {
if (this.discussions.length === 0 && !this.loading) {
const text = app.translator.trans('core.forum.discussion_list.empty_text');
return <div className="DiscussionList">{Placeholder.component({ text })}</div>;
}
return (
<div className={'DiscussionList' + (state.isSearchResults() ? ' DiscussionList--searchResults' : '')}>
<div className={'DiscussionList' + (this.props.params.q ? ' DiscussionList--searchResults' : '')}>
<ul className="DiscussionList-discussions">
{state.discussions.map((discussion) => {
{this.discussions.map((discussion) => {
return (
<li key={discussion.id()} data-id={discussion.id()}>
{DiscussionListItem.component({ discussion, params })}
@@ -52,4 +72,140 @@ export default class DiscussionList extends Component {
</div>
);
}
/**
* Get the parameters that should be passed in the API request to get
* discussion results.
*
* @return {Object}
* @api
*/
requestParams() {
const params = { include: ['user', 'lastPostedUser'], filter: {} };
params.sort = this.sortMap()[this.props.params.sort];
if (this.props.params.q) {
params.filter.q = this.props.params.q;
params.include.push('mostRelevantPost', 'mostRelevantPost.user');
}
return params;
}
/**
* Get a map of sort keys (which appear in the URL, and are used for
* translation) to the API sort value that they represent.
*
* @return {Object}
*/
sortMap() {
const map = {};
if (this.props.params.q) {
map.relevance = '';
}
map.latest = '-lastPostedAt';
map.top = '-commentCount';
map.newest = '-createdAt';
map.oldest = 'createdAt';
return map;
}
/**
* Clear and reload the discussion list.
*
* @public
*/
refresh(clear = true) {
if (clear) {
this.loading = true;
this.discussions = [];
}
return this.loadResults().then(
(results) => {
this.discussions = [];
this.parseResults(results);
},
() => {
this.loading = false;
m.redraw();
}
);
}
/**
* Load a new page of discussion results.
*
* @param {Integer} offset The index to start the page at.
* @return {Promise}
*/
loadResults(offset) {
const preloadedDiscussions = app.preloadedApiDocument();
if (preloadedDiscussions) {
return m.deferred().resolve(preloadedDiscussions).promise;
}
const params = this.requestParams();
params.page = { offset };
params.include = params.include.join(',');
return app.store.find('discussions', params);
}
/**
* Load the next page of discussion results.
*
* @public
*/
loadMore() {
this.loading = true;
this.loadResults(this.discussions.length).then(this.parseResults.bind(this));
}
/**
* Parse results and append them to the discussion list.
*
* @param {Discussion[]} results
* @return {Discussion[]}
*/
parseResults(results) {
[].push.apply(this.discussions, results);
this.loading = false;
this.moreResults = !!results.payload.links.next;
m.lazyRedraw();
return results;
}
/**
* Remove a discussion from the list if it is present.
*
* @param {Discussion} discussion
* @public
*/
removeDiscussion(discussion) {
const index = this.discussions.indexOf(discussion);
if (index !== -1) {
this.discussions.splice(index, 1);
}
}
/**
* Add a discussion to the top of the list.
*
* @param {Discussion} discussion
* @public
*/
addDiscussion(discussion) {
this.discussions.unshift(discussion);
}
}

View File

@@ -1,4 +1,4 @@
import Page from '../../common/components/Page';
import Page from './Page';
import ItemList from '../../common/utils/ItemList';
import DiscussionHero from './DiscussionHero';
import PostStream from './PostStream';
@@ -7,9 +7,6 @@ import LoadingIndicator from '../../common/components/LoadingIndicator';
import SplitDropdown from '../../common/components/SplitDropdown';
import listItems from '../../common/helpers/listItems';
import DiscussionControls from '../utils/DiscussionControls';
import DiscussionList from './DiscussionList';
import PostStreamState from '../states/PostStreamState';
import ScrollListener from '../../common/utils/ScrollListener';
/**
* The `DiscussionPage` component displays a whole discussion page, including
@@ -29,24 +26,22 @@ export default class DiscussionPage extends Page {
/**
* The number of the first post that is currently visible in the viewport.
*
* @type {number}
* @type {Integer}
*/
this.near = m.route.param('near') || 0;
this.near = null;
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
this.load();
this.refresh();
// If the discussion list has been loaded, then we'll enable the pane (and
// hide it by default). Also, if we've just come from another discussion
// page, then we don't want Mithril to redraw the whole page if it did,
// then the pane would redraw which would be slow and would cause problems with
// then the pane would which would be slow and would cause problems with
// event handlers.
if (app.discussions.hasDiscussions()) {
if (app.cache.discussionList) {
app.pane.enable();
app.pane.hide();
if (app.previous.matches(DiscussionPage)) {
if (app.previous instanceof DiscussionPage) {
m.redraw.strategy('diff');
}
}
@@ -83,7 +78,7 @@ export default class DiscussionPage extends Page {
// we'll just close it.
app.pane.disable();
if (app.composer.composingReplyTo(this.discussion) && !app.composer.fields.content()) {
if (app.composingReplyTo(this.discussion) && !app.composer.component.content()) {
app.composer.hide();
} else {
app.composer.minimize();
@@ -95,9 +90,9 @@ export default class DiscussionPage extends Page {
return (
<div className="DiscussionPage">
{app.discussions.hasDiscussions() ? (
{app.cache.discussionList ? (
<div className="DiscussionPage-list" config={this.configPane.bind(this)}>
{!$('.App-navigation').is(':visible') && <DiscussionList state={app.discussions} />}
{!$('.App-navigation').is(':visible') ? app.cache.discussionList.render() : ''}
</div>
) : (
''
@@ -111,13 +106,7 @@ export default class DiscussionPage extends Page {
<nav className="DiscussionPage-nav">
<ul>{listItems(this.sidebarItems().toArray())}</ul>
</nav>
<div className="DiscussionPage-stream">
{PostStream.component({
discussion,
stream: this.stream,
targetPost: this.stream.targetPost,
})}
</div>
<div className="DiscussionPage-stream">{this.stream.render()}</div>
</div>,
]
: LoadingIndicator.component({ className: 'LoadingIndicator--block' })}
@@ -126,24 +115,21 @@ export default class DiscussionPage extends Page {
);
}
config(isInitialized, context) {
super.config(isInitialized, context);
config(...args) {
super.config(...args);
if (this.discussion) {
app.setTitle(this.discussion.title());
}
context.onunload = () => {
this.scrollListener.stop();
clearTimeout(this.calculatePositionTimeout);
};
}
/**
* Load the discussion from the API or use the preloaded one.
* Clear and reload the discussion.
*/
load() {
refresh() {
this.near = m.route.param('near') || 0;
this.discussion = null;
const preloadedDiscussion = app.preloadedApiDocument();
if (preloadedDiscussion) {
// We must wrap this in a setTimeout because if we are mounting this
@@ -210,13 +196,9 @@ export default class DiscussionPage extends Page {
// Set up the post stream for this discussion, along with the first page of
// posts we want to display. Tell the stream to scroll down and highlight
// the specific post that was routed to.
this.stream = new PostStreamState(discussion, includedPosts);
this.stream = new PostStream({ discussion, includedPosts });
this.stream.on('positionChanged', this.positionChanged.bind(this));
this.stream.goToNumber(m.route.param('near') || (includedPosts[0] && includedPosts[0].number()), true);
app.current.set('discussion', discussion);
app.current.set('stream', this.stream);
this.scrollListener.start();
}
/**
@@ -282,12 +264,8 @@ export default class DiscussionPage extends Page {
items.add(
'scrubber',
PostStreamScrubber.component({
discussion: this.discussion,
stream: this.stream,
className: 'App-titleControl',
onNavigate: this.stream.goToIndex.bind(this.stream),
count: this.stream.count(),
paused: this.stream.paused,
...this.scrubberProps(),
}),
-100
);
@@ -295,84 +273,6 @@ export default class DiscussionPage extends Page {
return items;
}
/**
* When the window is scrolled, check if either extreme of the post stream is
* in the viewport, and if so, trigger loading the next/previous page.
*
* @param {number} top
*/
onscroll(top = window.pageYOffset) {
if (this.stream.paused) return;
const marginTop = this.getMarginTop();
const viewportHeight = $(window).height() - marginTop;
const viewportTop = top + marginTop;
const loadAheadDistance = 300;
if (this.stream.visibleStart > 0) {
const $item = this.$('.PostStream-item[data-index=' + this.stream.visibleStart + ']');
if ($item.length && $item.offset().top > viewportTop - loadAheadDistance) {
this.stream.loadPrevious();
}
}
if (this.stream.visibleEnd < this.stream.count()) {
const $item = this.$('.PostStream-item[data-index=' + (this.stream.visibleEnd - 1) + ']');
if ($item.length && $item.offset().top + $item.outerHeight(true) < viewportTop + viewportHeight + loadAheadDistance) {
this.stream.loadNext();
}
}
// Throttle calculation of our position (start/end numbers of posts in the
// viewport) to 100ms.
clearTimeout(this.calculatePositionTimeout);
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this, top), 100);
// Update numbers for the scrubber if necessary
m.redraw();
}
/**
* Work out which posts (by number) are currently visible in the viewport, and
* fire an event with the information.
*/
calculatePosition(top = window.pageYOffset) {
const marginTop = this.getMarginTop();
const $window = $(window);
const viewportHeight = $window.height() - marginTop;
const scrollTop = $window.scrollTop() + marginTop;
const viewportTop = top + marginTop;
let startNumber;
let endNumber;
this.$('.PostStream-item').each(function () {
const $item = $(this);
const top = $item.offset().top;
const height = $item.outerHeight(true);
const visibleTop = Math.max(0, viewportTop - top);
const threeQuartersVisible = visibleTop / height < 0.75;
const coversQuarterOfViewport = (height - visibleTop) / viewportHeight > 0.25;
if (startNumber === undefined && (threeQuartersVisible || coversQuarterOfViewport)) {
startNumber = $item.data('number');
}
if (top + height > scrollTop) {
if (top + height < scrollTop + viewportHeight) {
if ($item.data('number')) {
endNumber = $item.data('number');
}
} else return false;
}
});
if (startNumber) {
this.positionChanged(startNumber || 1, endNumber);
}
}
/**
* When the posts that are visible in the post stream change (i.e. the user
* scrolls up or down), then we update the URL and mark the posts as read.
@@ -399,73 +299,4 @@ export default class DiscussionPage extends Page {
m.redraw();
}
}
scrubberProps(top = window.pageYOffset) {
const marginTop = this.getMarginTop();
const viewportHeight = $(window).height() - marginTop;
const viewportTop = top + marginTop;
// Before looping through all of the posts, we reset the scrollbar
// properties to a 'default' state. These values reflect what would be
// seen if the browser were scrolled right up to the top of the page,
// and the viewport had a height of 0.
const $items = this.$('.PostStream-item[data-index]');
let index = $items.first().data('index') || 0;
let visible = 0;
let period = '';
// Now loop through each of the items in the discussion. An 'item' is
// either a single post or a 'gap' of one or more posts that haven't
// been loaded yet.
$items.each(function () {
const $this = $(this);
const top = $this.offset().top;
const height = $this.outerHeight(true);
// If this item is above the top of the viewport, skip to the next
// one. If it's below the bottom of the viewport, break out of the
// loop.
if (top + height < viewportTop) {
return true;
}
if (top > viewportTop + viewportHeight) {
return false;
}
// Work out how many pixels of this item are visible inside the viewport.
// Then add the proportion of this item's total height to the index.
const visibleTop = Math.max(0, viewportTop - top);
const visibleBottom = Math.min(height, viewportTop + viewportHeight - top);
const visiblePost = visibleBottom - visibleTop;
if (top <= viewportTop) {
index = parseFloat($this.data('index')) + visibleTop / height;
}
if (visiblePost > 0) {
visible += visiblePost / height;
}
// If this item has a time associated with it, then set the
// scrollbar's current period to a formatted version of this time.
const time = $this.data('time');
if (time) period = time;
});
return {
index: index + 1,
visible: visible || 1,
description: period && dayjs(period).format('MMMM YYYY'),
};
}
/**
* Get the distance from the top of the viewport to the point at which we
* would consider a post to be the first one visible.
*
* @return {Integer}
*/
getMarginTop() {
return this.$() && $('#header').outerHeight() + parseInt(this.$().css('margin-top'), 10);
}
}

View File

@@ -1,6 +1,5 @@
import UserPage from './UserPage';
import DiscussionList from './DiscussionList';
import DiscussionListState from '../states/DiscussionListState';
/**
* The `DiscussionsUserPage` component shows a discussion list inside of a user
@@ -13,18 +12,16 @@ export default class DiscussionsUserPage extends UserPage {
this.loadUser(m.route.param('username'));
}
show(user) {
super.show(user);
this.state = new DiscussionListState({
q: 'author:' + user.username(),
sort: 'newest',
});
this.state.refresh();
}
content() {
return <div className="DiscussionsUserPage">{DiscussionList.component({ state: this.state })}</div>;
return (
<div className="DiscussionsUserPage">
{DiscussionList.component({
params: {
q: 'author:' + this.user.username(),
sort: 'newest',
},
})}
</div>
);
}
}

View File

@@ -1,5 +1,4 @@
import ComposerBody from './ComposerBody';
import Button from '../../common/components/Button';
import icon from '../../common/helpers/icon';
function minimizeComposerIfFullScreen(e) {
@@ -20,6 +19,16 @@ function minimizeComposerIfFullScreen(e) {
* - `post`
*/
export default class EditPostComposer extends ComposerBody {
init() {
super.init();
this.editor.props.preview = (e) => {
minimizeComposerIfFullScreen(e);
m.route(app.route.post(this.props.post));
};
}
static initProps(props) {
super.initProps(props);
@@ -54,15 +63,6 @@ export default class EditPostComposer extends ComposerBody {
return items;
}
/**
* Jump to the preview when triggered by the text editor.
*/
jumpToPreview(e) {
minimizeComposerIfFullScreen(e);
m.route(app.route.post(this.props.post));
}
/**
* Get the data to submit to the server when the post is saved.
*
@@ -70,43 +70,15 @@ export default class EditPostComposer extends ComposerBody {
*/
data() {
return {
content: this.composer.fields.content(),
content: this.content(),
};
}
onsubmit() {
const discussion = this.props.post.discussion();
this.loading = true;
const data = this.data();
this.props.post.save(data).then((post) => {
// If we're currently viewing the discussion which this edit was made
// in, then we can scroll to the post.
if (app.viewingDiscussion(discussion)) {
app.current.get('stream').goToNumber(post.number());
} else {
// Otherwise, we'll create an alert message to inform the user that
// their edit has been made, containing a button which will
// transition to their edited post when clicked.
let alert;
const viewButton = Button.component({
className: 'Button Button--link',
children: app.translator.trans('core.forum.composer_edit.view_button'),
onclick: () => {
m.route(app.route.post(post));
app.alerts.dismiss(alert);
},
});
alert = app.alerts.show({
type: 'success',
children: app.translator.trans('core.forum.composer_edit.edited_message'),
controls: [viewButton],
});
}
this.composer.hide();
}, this.loaded.bind(this));
this.props.post.save(data).then(() => app.composer.hide(), this.loaded.bind(this));
}
}

View File

@@ -104,7 +104,7 @@ export default class ForgotPasswordModal extends Modal {
onerror(error) {
if (error.status === 404) {
error.alert.children = app.translator.trans('core.forum.forgot_password.not_found_message');
error.alert.props.children = app.translator.trans('core.forum.forgot_password.not_found_message');
}
super.onerror(error);

View File

@@ -7,7 +7,6 @@ import SelectDropdown from '../../common/components/SelectDropdown';
import NotificationsDropdown from './NotificationsDropdown';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
import Search from '../components/Search';
/**
* The `HeaderSecondary` component displays secondary header controls, such as
@@ -34,7 +33,7 @@ export default class HeaderSecondary extends Component {
items() {
const items = new ItemList();
items.add('search', Search.component({ state: app.search }), 30);
items.add('search', app.search.render(), 30);
if (app.forum.attribute('showLanguageSelector') && Object.keys(app.data.locales).length > 1) {
const locales = [];
@@ -68,7 +67,7 @@ export default class HeaderSecondary extends Component {
}
if (app.session.user) {
items.add('notifications', NotificationsDropdown.component({ state: app.notifications }), 10);
items.add('notifications', NotificationsDropdown.component(), 10);
items.add('session', SessionDropdown.component(), 0);
} else {
if (app.forum.attribute('allowSignUp')) {
@@ -77,7 +76,7 @@ export default class HeaderSecondary extends Component {
Button.component({
children: app.translator.trans('core.forum.header.sign_up_link'),
className: 'Button Button--link',
onclick: () => app.modal.show(SignUpModal),
onclick: () => app.modal.show(new SignUpModal()),
}),
10
);
@@ -88,7 +87,7 @@ export default class HeaderSecondary extends Component {
Button.component({
children: app.translator.trans('core.forum.header.log_in_link'),
className: 'Button Button--link',
onclick: () => app.modal.show(LogInModal),
onclick: () => app.modal.show(new LogInModal()),
}),
0
);

View File

@@ -1,7 +1,8 @@
import { extend } from '../../common/extend';
import Page from '../../common/components/Page';
import Page from './Page';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
import icon from '../../common/helpers/icon';
import DiscussionList from './DiscussionList';
import WelcomeHero from './WelcomeHero';
import DiscussionComposer from './DiscussionComposer';
@@ -17,27 +18,42 @@ import SelectDropdown from '../../common/components/SelectDropdown';
* hero, the sidebar, and the discussion list.
*/
export default class IndexPage extends Page {
static providesInitialSearch = true;
init() {
super.init();
// 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
// scroll down so that this discussion is in view.
if (app.previous.matches(DiscussionPage)) {
this.lastDiscussion = app.previous.get('discussion');
if (app.previous instanceof DiscussionPage) {
this.lastDiscussion = app.previous.discussion;
}
// If the user is coming from the discussion list, then they have either
// just switched one of the parameters (filter, sort, search) or they
// probably want to refresh the results. We will clear the discussion list
// cache so that results are reloaded.
if (app.previous.matches(IndexPage)) {
app.discussions.clear();
if (app.previous instanceof IndexPage) {
app.cache.discussionList = null;
}
app.discussions.refreshParams(app.search.params());
const params = this.params();
if (app.cache.discussionList) {
// Compare the requested parameters (sort, search query) to the ones that
// are currently present in the cached discussion list. If they differ, we
// will clear the cache and set up a new discussion list component with
// the new parameters.
Object.keys(params).some((key) => {
if (app.cache.discussionList.props.params[key] !== params[key]) {
app.cache.discussionList = null;
return true;
}
});
}
if (!app.cache.discussionList) {
app.cache.discussionList = new DiscussionList({ params });
}
app.history.push('index', app.translator.trans('core.forum.header.back_to_index_tooltip'));
@@ -64,7 +80,7 @@ export default class IndexPage extends Page {
<ul className="IndexPage-toolbar-view">{listItems(this.viewItems().toArray())}</ul>
<ul className="IndexPage-toolbar-action">{listItems(this.actionItems().toArray())}</ul>
</div>
<DiscussionList state={app.discussions} />
{app.cache.discussionList.render()}
</div>
</div>
</div>
@@ -79,7 +95,7 @@ export default class IndexPage extends Page {
extend(context, 'onunload', () => $('#app').css('min-height', ''));
app.setTitle(app.translator.trans('core.forum.index.meta_title_text'));
app.setTitle('');
app.setTitleCount(0);
// Work out the difference between the height of this hero and that of the
@@ -171,7 +187,7 @@ export default class IndexPage extends Page {
*/
navItems() {
const items = new ItemList();
const params = app.search.stickyParams();
const params = this.stickyParams();
items.add(
'allDiscussions',
@@ -195,7 +211,7 @@ export default class IndexPage extends Page {
*/
viewItems() {
const items = new ItemList();
const sortMap = app.discussions.sortMap();
const sortMap = app.cache.discussionList.sortMap();
const sortOptions = {};
for (const i in sortMap) {
@@ -206,15 +222,15 @@ export default class IndexPage extends Page {
'sort',
Dropdown.component({
buttonClassName: 'Button',
label: sortOptions[app.search.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0],
label: sortOptions[this.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0],
children: Object.keys(sortOptions).map((value) => {
const label = sortOptions[value];
const active = (app.search.params().sort || Object.keys(sortMap)[0]) === value;
const active = (this.params().sort || Object.keys(sortMap)[0]) === value;
return Button.component({
children: label,
icon: active ? 'fas fa-check' : true,
onclick: app.search.changeSort.bind(app.search, value),
onclick: this.changeSort.bind(this, value),
active: active,
});
}),
@@ -240,7 +256,7 @@ export default class IndexPage extends Page {
icon: 'fas fa-sync',
className: 'Button Button--icon',
onclick: () => {
app.discussions.refresh();
app.cache.discussionList.refresh();
if (app.session.user) {
app.store.find('users', app.session.user.id());
m.redraw();
@@ -264,6 +280,72 @@ export default class IndexPage extends Page {
return items;
}
/**
* Return the current search query, if any. This is implemented to activate
* the search box in the header.
*
* @see Search
* @return {String}
*/
searching() {
return this.params().q;
}
/**
* Redirect to the index page without a search filter. This is called when the
* 'x' is clicked in the search box in the header.
*
* @see Search
*/
clearSearch() {
const params = this.params();
delete params.q;
m.route(app.route(this.props.routeName, params));
}
/**
* Redirect to the index page using the given sort parameter.
*
* @param {String} sort
*/
changeSort(sort) {
const params = this.params();
if (sort === Object.keys(app.cache.discussionList.sortMap())[0]) {
delete params.sort;
} else {
params.sort = sort;
}
m.route(app.route(this.props.routeName, params));
}
/**
* Get URL parameters that stick between filter changes.
*
* @return {Object}
*/
stickyParams() {
return {
sort: m.route.param('sort'),
q: m.route.param('q'),
};
}
/**
* Get parameters to pass to the DiscussionList component.
*
* @return {Object}
*/
params() {
const params = this.stickyParams();
params.filter = m.route.param('filter');
return params;
}
/**
* Open the composer for a new discussion or prompt the user to login.
*
@@ -273,14 +355,16 @@ export default class IndexPage extends Page {
const deferred = m.deferred();
if (app.session.user) {
app.composer.load(DiscussionComposer, { user: app.session.user });
const component = new DiscussionComposer({ user: app.session.user });
app.composer.load(component);
app.composer.show();
deferred.resolve(app.composer);
deferred.resolve(component);
} else {
deferred.reject();
app.modal.show(LogInModal);
app.modal.show(new LogInModal());
}
return deferred.promise;

View File

@@ -142,7 +142,7 @@ export default class LogInModal extends Modal {
const email = this.identification();
const props = email.indexOf('@') !== -1 ? { email } : undefined;
app.modal.show(ForgotPasswordModal, props);
app.modal.show(new ForgotPasswordModal(props));
}
/**
@@ -156,7 +156,7 @@ export default class LogInModal extends Modal {
const identification = this.identification();
props[identification.indexOf('@') !== -1 ? 'email' : 'username'] = identification;
app.modal.show(SignUpModal, props);
app.modal.show(new SignUpModal(props));
}
onready() {
@@ -179,7 +179,7 @@ export default class LogInModal extends Modal {
onerror(error) {
if (error.status === 401) {
error.alert.children = app.translator.trans('core.forum.log_in.invalid_login_message');
error.alert.props.children = app.translator.trans('core.forum.log_in.invalid_login_message');
}
super.onerror(error);

View File

@@ -21,11 +21,12 @@ export default class NotificationGrid extends Component {
this.methods = this.notificationMethods().toArray();
/**
* A map of which notification checkboxes are loading.
* A map of notification type-method combinations to the checkbox instances
* that represent them.
*
* @type {Object}
*/
this.loading = {};
this.inputs = {};
/**
* Information about the available notification types.
@@ -33,11 +34,24 @@ export default class NotificationGrid extends Component {
* @type {Array}
*/
this.types = this.notificationTypes().toArray();
// For each of the notification type-method combinations, create and store a
// new checkbox component instance, which we will render in the view.
this.types.forEach((type) => {
this.methods.forEach((method) => {
const key = this.preferenceKey(type.name, method.name);
const preference = this.props.user.preferences()[key];
this.inputs[key] = new Checkbox({
state: !!preference,
disabled: typeof preference === 'undefined',
onchange: () => this.toggle([key]),
});
});
});
}
view() {
const preferences = this.props.user.preferences();
return (
<table className="NotificationGrid">
<thead>
@@ -57,20 +71,9 @@ export default class NotificationGrid extends Component {
<td className="NotificationGrid-groupToggle" onclick={this.toggleType.bind(this, type.name)}>
{icon(type.icon)} {type.label}
</td>
{this.methods.map((method) => {
const key = this.preferenceKey(type.name, method.name);
return (
<td className="NotificationGrid-checkbox">
{Checkbox.component({
state: !!preferences[key],
loading: this.loading[key],
disabled: !(key in preferences),
onchange: () => this.toggle([key]),
})}
</td>
);
})}
{this.methods.map((method) => (
<td className="NotificationGrid-checkbox">{this.inputs[this.preferenceKey(type.name, method.name)].render()}</td>
))}
</tr>
))}
</tbody>
@@ -109,14 +112,16 @@ export default class NotificationGrid extends Component {
const enabled = !preferences[keys[0]];
keys.forEach((key) => {
this.loading[key] = true;
preferences[key] = enabled;
const control = this.inputs[key];
control.loading = true;
preferences[key] = control.props.state = enabled;
});
m.redraw();
user.save({ preferences }).then(() => {
keys.forEach((key) => (this.loading[key] = false));
keys.forEach((key) => (this.inputs[key].loading = false));
m.redraw();
});
@@ -128,7 +133,7 @@ export default class NotificationGrid extends Component {
* @param {String} 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) => !this.inputs[key].props.disabled);
this.toggle(keys);
}
@@ -139,7 +144,7 @@ export default class NotificationGrid extends Component {
* @param {String} 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) => !this.inputs[key].props.disabled);
this.toggle(keys);
}

View File

@@ -10,11 +10,23 @@ import Discussion from '../../common/models/Discussion';
*/
export default class NotificationList extends Component {
init() {
this.state = this.props.state;
/**
* Whether or not the notifications are loading.
*
* @type {Boolean}
*/
this.loading = false;
/**
* Whether or not there are more results that can be loaded.
*
* @type {Boolean}
*/
this.moreResults = false;
}
view() {
const pages = this.state.getNotificationPages();
const pages = app.cache.notifications || [];
return (
<div className="NotificationList">
@@ -24,7 +36,7 @@ export default class NotificationList extends Component {
className: 'Button Button--icon Button--link',
icon: 'fas fa-check',
title: app.translator.trans('core.forum.notifications.mark_all_as_read_tooltip'),
onclick: this.state.markAllAsRead.bind(this.state),
onclick: this.markAllAsRead.bind(this),
})}
</div>
@@ -85,7 +97,7 @@ export default class NotificationList extends Component {
});
})
: ''}
{this.state.isLoading() ? (
{this.loading ? (
<LoadingIndicator className="LoadingIndicator--block" />
) : pages.length ? (
''
@@ -109,8 +121,8 @@ export default class NotificationList extends Component {
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();
if (this.moreResults && !this.loading && scrollTop + viewportHeight >= contentTop + contentHeight) {
this.loadMore();
}
};
@@ -120,4 +132,77 @@ export default class NotificationList extends Component {
$scrollParent.off('scroll', scrollHandler);
};
}
/**
* Load notifications into the application's cache if they haven't already
* been loaded.
*/
load() {
if (app.session.user.newNotificationCount()) {
delete app.cache.notifications;
}
if (app.cache.notifications) {
return;
}
app.session.user.pushAttributes({ newNotificationCount: 0 });
this.loadMore();
}
/**
* Load the next page of notification results.
*
* @public
*/
loadMore() {
this.loading = true;
m.redraw();
const params = app.cache.notifications ? { page: { offset: app.cache.notifications.length * 10 } } : null;
return app.store
.find('notifications', params)
.then(this.parseResults.bind(this))
.catch(() => {})
.then(() => {
this.loading = false;
m.redraw();
});
}
/**
* Parse results and append them to the notification list.
*
* @param {Notification[]} results
* @return {Notification[]}
*/
parseResults(results) {
app.cache.notifications = app.cache.notifications || [];
if (results.length) app.cache.notifications.push(results);
this.moreResults = !!results.payload.links.next;
return results;
}
/**
* Mark all of the notifications as read.
*/
markAllAsRead() {
if (!app.cache.notifications) return;
app.session.user.pushAttributes({ unreadNotificationCount: 0 });
app.cache.notifications.forEach((notifications) => {
notifications.forEach((notification) => notification.pushAttributes({ isRead: true }));
});
app.request({
url: app.forum.attribute('apiUrl') + '/notifications/read',
method: 'POST',
});
}
}

View File

@@ -13,6 +13,12 @@ export default class NotificationsDropdown extends Dropdown {
super.initProps(props);
}
init() {
super.init();
this.list = new NotificationList();
}
getButton() {
const newNotifications = this.getNewCount();
const vdom = super.getButton();
@@ -38,7 +44,7 @@ export default class NotificationsDropdown extends Dropdown {
getMenu() {
return (
<div className={'Dropdown-menu ' + this.props.menuClassName} onclick={this.menuClick.bind(this)}>
{this.showing ? NotificationList.component({ state: this.props.state }) : ''}
{this.showing ? this.list.render() : ''}
</div>
);
}
@@ -47,7 +53,7 @@ export default class NotificationsDropdown extends Dropdown {
if (app.drawer.isOpen()) {
this.goToRoute();
} else {
this.props.state.load();
this.list.load();
}
}

View File

@@ -1,4 +1,4 @@
import Page from '../../common/components/Page';
import Page from './Page';
import NotificationList from './NotificationList';
/**
@@ -11,16 +11,13 @@ export default class NotificationsPage extends Page {
app.history.push('notifications');
app.notifications.load();
this.list = new NotificationList();
this.list.load();
this.bodyClass = 'App--notifications';
}
view() {
return (
<div className="NotificationsPage">
<NotificationList state={app.notifications}></NotificationList>
</div>
);
return <div className="NotificationsPage">{this.list.render()}</div>;
}
}

View File

@@ -1,5 +1,4 @@
import Component from '../Component';
import PageState from '../states/PageState';
import Component from '../../common/Component';
/**
* The `Page` component
@@ -9,7 +8,7 @@ import PageState from '../states/PageState';
export default class Page extends Component {
init() {
app.previous = app.current;
app.current = new PageState(this.constructor);
app.current = this;
app.drawer.hide();
app.modal.close();

View File

@@ -116,7 +116,6 @@ export default class Post extends Component {
let classes = (existing || '').split(' ').concat(['Post']);
const user = this.props.post.user();
const discussion = this.props.post.discussion();
if (this.loading) {
classes.push('Post--loading');
@@ -126,7 +125,7 @@ export default class Post extends Component {
classes.push('Post--by-actor');
}
if (user && user === discussion.user()) {
if (user && app.current.discussion && app.current.discussion.attribute('startUserId') == user.id()) {
classes.push('Post--by-start-user');
}

View File

@@ -1,5 +1,8 @@
import Component from '../../common/Component';
import ScrollListener from '../../common/utils/ScrollListener';
import PostLoading from './LoadingPost';
import anchorScroll from '../../common/utils/anchorScroll';
import evented from '../../common/utils/evented';
import ReplyPlaceholder from './ReplyPlaceholder';
import Button from '../../common/components/Button';
@@ -10,10 +13,9 @@ import Button from '../../common/components/Button';
* ### Props
*
* - `discussion`
* - `stream`
* - `targetPost`
* - `includedPosts`
*/
export default class PostStream extends Component {
class PostStream extends Component {
init() {
/**
* The discussion to display the post stream for.
@@ -23,11 +25,171 @@ export default class PostStream extends Component {
this.discussion = this.props.discussion;
/**
* The shared state of the post stream.
* Whether or not the infinite-scrolling auto-load functionality is
* disabled.
*
* @type {PostStreamState}
* @type {Boolean}
*/
this.stream = this.props.stream;
this.paused = false;
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
this.loadPageTimeouts = {};
this.pagesLoading = 0;
this.show(this.props.includedPosts);
}
/**
* Load and scroll to a post with a certain number.
*
* @param {Integer|String} number The post number to go to. If 'reply', go to
* the last post and scroll the reply preview into view.
* @param {Boolean} noAnimation
* @return {Promise}
*/
goToNumber(number, noAnimation) {
// If we want to go to the reply preview, then we will go to the end of the
// discussion and then scroll to the very bottom of the page.
if (number === 'reply') {
return this.goToLast().then(() => {
$('html,body')
.stop(true)
.animate(
{
scrollTop: $(document).height() - $(window).height(),
},
'fast',
() => {
this.flashItem(this.$('.PostStream-item:last-child'));
}
);
});
}
this.paused = true;
const promise = this.loadNearNumber(number);
m.redraw(true);
return promise.then(() => {
m.redraw(true);
this.scrollToNumber(number, noAnimation).done(this.unpause.bind(this));
});
}
/**
* Load and scroll to a certain index within the discussion.
*
* @param {Integer} index
* @param {Boolean} backwards Whether or not to load backwards from the given
* index.
* @param {Boolean} noAnimation
* @return {Promise}
*/
goToIndex(index, backwards, noAnimation) {
this.paused = true;
const promise = this.loadNearIndex(index);
m.redraw(true);
return promise.then(() => {
anchorScroll(this.$('.PostStream-item:' + (backwards ? 'last' : 'first')), () => m.redraw(true));
this.scrollToIndex(index, noAnimation, backwards).done(this.unpause.bind(this));
});
}
/**
* Load and scroll up to the first post in the discussion.
*
* @return {Promise}
*/
goToFirst() {
return this.goToIndex(0);
}
/**
* Load and scroll down to the last post in the discussion.
*
* @return {Promise}
*/
goToLast() {
return this.goToIndex(this.count() - 1, true);
}
/**
* Update the stream so that it loads and includes the latest posts in the
* discussion, if the end is being viewed.
*
* @public
*/
update() {
if (!this.viewingEnd) return m.deferred().resolve().promise;
this.visibleEnd = this.count();
return this.loadRange(this.visibleStart, this.visibleEnd).then(() => m.redraw());
}
/**
* Get the total number of posts in the discussion.
*
* @return {Integer}
*/
count() {
return this.discussion.postIds().length;
}
/**
* Make sure that the given index is not outside of the possible range of
* indexes in the discussion.
*
* @param {Integer} index
* @protected
*/
sanitizeIndex(index) {
return Math.max(0, Math.min(this.count(), index));
}
/**
* Set up the stream with the given array of posts.
*
* @param {Post[]} posts
*/
show(posts) {
this.visibleStart = posts.length ? this.discussion.postIds().indexOf(posts[0].id()) : 0;
this.visibleEnd = this.visibleStart + posts.length;
}
/**
* Reset the stream so that a specific range of posts is displayed. If a range
* is not specified, the first page of posts will be displayed.
*
* @param {Integer} [start]
* @param {Integer} [end]
*/
reset(start, end) {
this.visibleStart = start || 0;
this.visibleEnd = this.sanitizeIndex(end || this.constructor.loadCount);
}
/**
* Get the visible page of posts.
*
* @return {Post[]}
*/
posts() {
return this.discussion
.postIds()
.slice(this.visibleStart, this.visibleEnd)
.map((id) => {
const post = app.store.getById('posts', id);
return post && post.discussion() && typeof post.canEdit() !== 'undefined' ? post : null;
});
}
view() {
@@ -38,13 +200,15 @@ export default class PostStream extends Component {
let lastTime;
const viewingEnd = this.stream.viewingEnd();
const posts = this.stream.posts();
this.visibleEnd = this.sanitizeIndex(this.visibleEnd);
this.viewingEnd = this.visibleEnd === this.count();
const posts = this.posts();
const postIds = this.discussion.postIds();
const items = posts.map((post, i) => {
let content;
const attrs = { 'data-index': this.stream.visibleStart + i };
const attrs = { 'data-index': this.visibleStart + i };
if (post) {
const time = post.createdAt();
@@ -66,7 +230,7 @@ export default class PostStream extends Component {
if (dt > 1000 * 60 * 60 * 24 * 4) {
content = [
<div className="PostStream-timeGap">
<span>{app.translator.trans('core.forum.post_stream.time_lapsed_text', { period: dayjs().add(dt, 'ms').fromNow(true) })}</span>
<span>{app.translator.trans('core.forum.post_stream.time_lapsed_text', { period: moment.duration(dt).humanize() })}</span>
</div>,
content,
];
@@ -74,7 +238,7 @@ export default class PostStream extends Component {
lastTime = time;
} else {
attrs.key = 'post' + postIds[this.stream.visibleStart + i];
attrs.key = 'post' + postIds[this.visibleStart + i];
content = PostLoading.component();
}
@@ -86,10 +250,10 @@ export default class PostStream extends Component {
);
});
if (!viewingEnd && posts[this.stream.visibleEnd - this.stream.visibleStart - 1]) {
if (!this.viewingEnd && posts[this.visibleEnd - this.visibleStart - 1]) {
items.push(
<div className="PostStream-loadMore" key="loadMore">
<Button className="Button" onclick={this.stream.loadNext.bind(this.stream)}>
<Button className="Button" onclick={this.loadNext.bind(this)}>
{app.translator.trans('core.forum.post_stream.load_more_button')}
</Button>
</div>
@@ -98,7 +262,7 @@ export default class PostStream extends Component {
// If we're viewing the end of the discussion, the user can reply, and
// is not already doing so, then show a 'write a reply' placeholder.
if (viewingEnd && (!app.session.user || this.discussion.canReply())) {
if (this.viewingEnd && (!app.session.user || this.discussion.canReply())) {
items.push(
<div className="PostStream-item" key="reply">
{ReplyPlaceholder.component({ discussion: this.discussion })}
@@ -110,25 +274,237 @@ export default class PostStream extends Component {
}
config(isInitialized, context) {
// Start scrolling, if appropriate, to a newly-targeted post.
if (!this.props.targetPost) return;
if (isInitialized) return;
const oldTarget = this.prevTarget;
const newTarget = this.props.targetPost;
// This is wrapped in setTimeout due to the following Mithril issue:
// https://github.com/lhorie/mithril.js/issues/637
setTimeout(() => this.scrollListener.start());
if (oldTarget) {
if ('number' in oldTarget && oldTarget.number === newTarget.number) return;
if ('index' in oldTarget && oldTarget.index === newTarget.index) return;
context.onunload = () => {
this.scrollListener.stop();
clearTimeout(this.calculatePositionTimeout);
};
}
/**
* When the window is scrolled, check if either extreme of the post stream is
* in the viewport, and if so, trigger loading the next/previous page.
*
* @param {Integer} top
*/
onscroll(top) {
if (this.paused) return;
const marginTop = this.getMarginTop();
const viewportHeight = $(window).height() - marginTop;
const viewportTop = top + marginTop;
const loadAheadDistance = 300;
if (this.visibleStart > 0) {
const $item = this.$('.PostStream-item[data-index=' + this.visibleStart + ']');
if ($item.length && $item.offset().top > viewportTop - loadAheadDistance) {
this.loadPrevious();
}
}
if ('number' in newTarget) {
this.scrollToNumber(newTarget.number, this.stream.noAnimationScroll);
} else if ('index' in newTarget) {
const backwards = newTarget.index === this.stream.count() - 1;
this.scrollToIndex(newTarget.index, this.stream.noAnimationScroll, backwards);
if (this.visibleEnd < this.count()) {
const $item = this.$('.PostStream-item[data-index=' + (this.visibleEnd - 1) + ']');
if ($item.length && $item.offset().top + $item.outerHeight(true) < viewportTop + viewportHeight + loadAheadDistance) {
this.loadNext();
}
}
this.prevTarget = newTarget;
// Throttle calculation of our position (start/end numbers of posts in the
// viewport) to 100ms.
clearTimeout(this.calculatePositionTimeout);
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this), 100);
}
/**
* Load the next page of posts.
*/
loadNext() {
const start = this.visibleEnd;
const end = (this.visibleEnd = this.sanitizeIndex(this.visibleEnd + this.constructor.loadCount));
// Unload the posts which are two pages back from the page we're currently
// loading.
const twoPagesAway = start - this.constructor.loadCount * 2;
if (twoPagesAway > this.visibleStart && twoPagesAway >= 0) {
this.visibleStart = twoPagesAway + this.constructor.loadCount + 1;
if (this.loadPageTimeouts[twoPagesAway]) {
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
this.loadPageTimeouts[twoPagesAway] = null;
this.pagesLoading--;
}
}
this.loadPage(start, end);
}
/**
* Load the previous page of posts.
*/
loadPrevious() {
const end = this.visibleStart;
const start = (this.visibleStart = this.sanitizeIndex(this.visibleStart - this.constructor.loadCount));
// Unload the posts which are two pages back from the page we're currently
// loading.
const twoPagesAway = start + this.constructor.loadCount * 2;
if (twoPagesAway < this.visibleEnd && twoPagesAway <= this.count()) {
this.visibleEnd = twoPagesAway;
if (this.loadPageTimeouts[twoPagesAway]) {
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
this.loadPageTimeouts[twoPagesAway] = null;
this.pagesLoading--;
}
}
this.loadPage(start, end, true);
}
/**
* Load a page of posts into the stream and redraw.
*
* @param {Integer} start
* @param {Integer} end
* @param {Boolean} backwards
*/
loadPage(start, end, backwards) {
const redraw = () => {
if (start < this.visibleStart || end > this.visibleEnd) return;
const anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
anchorScroll(`.PostStream-item[data-index="${anchorIndex}"]`, () => m.redraw(true));
this.unpause();
};
redraw();
this.loadPageTimeouts[start] = setTimeout(
() => {
this.loadRange(start, end).then(() => {
redraw();
this.pagesLoading--;
});
this.loadPageTimeouts[start] = null;
},
this.pagesLoading ? 1000 : 0
);
this.pagesLoading++;
}
/**
* Load and inject the specified range of posts into the stream, without
* clearing it.
*
* @param {Integer} start
* @param {Integer} end
* @return {Promise}
*/
loadRange(start, end) {
const loadIds = [];
const loaded = [];
this.discussion
.postIds()
.slice(start, end)
.forEach((id) => {
const post = app.store.getById('posts', id);
if (post && post.discussion() && typeof post.canEdit() !== 'undefined') {
loaded.push(post);
} else {
loadIds.push(id);
}
});
return loadIds.length ? app.store.find('posts', loadIds) : m.deferred().resolve(loaded).promise;
}
/**
* Clear the stream and load posts near a certain number. Returns a promise.
* If the post with the given number is already loaded, the promise will be
* resolved immediately.
*
* @param {Integer} number
* @return {Promise}
*/
loadNearNumber(number) {
if (this.posts().some((post) => post && Number(post.number()) === Number(number))) {
return m.deferred().resolve().promise;
}
this.reset();
return app.store
.find('posts', {
filter: { discussion: this.discussion.id() },
page: { near: number },
})
.then(this.show.bind(this));
}
/**
* Clear the stream and load posts near a certain index. A page of posts
* surrounding the given index will be loaded. Returns a promise. If the given
* index is already loaded, the promise will be resolved immediately.
*
* @param {Integer} index
* @return {Promise}
*/
loadNearIndex(index) {
if (index >= this.visibleStart && index <= this.visibleEnd) {
return m.deferred().resolve().promise;
}
const start = this.sanitizeIndex(index - this.constructor.loadCount / 2);
const end = start + this.constructor.loadCount;
this.reset(start, end);
return this.loadRange(start, end).then(this.show.bind(this));
}
/**
* Work out which posts (by number) are currently visible in the viewport, and
* fire an event with the information.
*/
calculatePosition() {
const marginTop = this.getMarginTop();
const $window = $(window);
const viewportHeight = $window.height() - marginTop;
const scrollTop = $window.scrollTop() + marginTop;
let startNumber;
let endNumber;
this.$('.PostStream-item').each(function () {
const $item = $(this);
const top = $item.offset().top;
const height = $item.outerHeight(true);
if (top + height > scrollTop) {
if (!startNumber) {
startNumber = endNumber = $item.data('number');
}
if (top + height < scrollTop + viewportHeight) {
if ($item.data('number')) {
endNumber = $item.data('number');
}
} else return false;
}
});
if (startNumber) {
this.trigger('positionChanged', startNumber || 1, endNumber);
}
}
/**
@@ -145,46 +521,42 @@ export default class PostStream extends Component {
* Scroll down to a certain post by number and 'flash' it.
*
* @param {Integer} number
* @param {Boolean} animate
* @param {Boolean} noAnimation
* @return {jQuery.Deferred}
*/
scrollToNumber(number, animate) {
scrollToNumber(number, noAnimation) {
const $item = this.$(`.PostStream-item[data-number=${number}]`);
return this.scrollToItem($item, animate).then(this.flashItem.bind(this, $item));
return this.scrollToItem($item, noAnimation).done(this.flashItem.bind(this, $item));
}
/**
* Scroll down to a certain post by index.
*
* @param {Integer} index
* @param {Boolean} animate
* @param {Boolean} noAnimation
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
* at the given index, instead of the top of it.
* @return {jQuery.Deferred}
*/
scrollToIndex(index, animate, bottom) {
scrollToIndex(index, noAnimation, bottom) {
const $item = this.$(`.PostStream-item[data-index=${index}]`);
return this.scrollToItem($item, animate, true, bottom).then(() => {
if (index == this.stream.count() - 1) {
this.flashItem(this.$('.PostStream-item:last-child'));
}
});
return this.scrollToItem($item, noAnimation, true, bottom);
}
/**
* Scroll down to the given post.
*
* @param {jQuery} $item
* @param {Boolean} animate
* @param {Boolean} noAnimation
* @param {Boolean} force Whether or not to force scrolling to the item, even
* if it is already in the viewport.
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
* at the given index, instead of the top of it.
* @return {jQuery.Deferred}
*/
scrollToItem($item, animate, force, bottom) {
scrollToItem($item, noAnimation, force, bottom) {
const $container = $('html, body').stop(true);
if ($item.length) {
@@ -199,7 +571,7 @@ export default class PostStream extends Component {
if (force || itemTop < scrollTop || itemBottom > scrollBottom) {
const top = bottom ? itemBottom - $(window).height() + app.composer.computedHeight() : $item.is(':first-child') ? 0 : itemTop;
if (!animate) {
if (noAnimation) {
$container.scrollTop(top);
} else if (top !== scrollTop) {
$container.animate({ scrollTop: top }, 'fast');
@@ -218,4 +590,24 @@ export default class PostStream extends Component {
flashItem($item) {
$item.addClass('flash').one('animationend webkitAnimationEnd', () => $item.removeClass('flash'));
}
/**
* Resume the stream's ability to auto-load posts on scroll.
*/
unpause() {
this.paused = false;
this.scrollListener.update();
this.trigger('unpaused');
}
}
/**
* The number of posts to load per page.
*
* @type {Integer}
*/
PostStream.loadCount = 20;
Object.assign(PostStream.prototype, evented);
export default PostStream;

View File

@@ -10,22 +10,41 @@ import formatNumber from '../../common/utils/formatNumber';
*
* ### Props
*
* - `discussion`
* - `stream`
* - `className`
* - `onNavigate`
* - `count`
* - `paused`
* - `index`
* - `visible`
* - `description`
*/
export default class PostStreamScrubber extends Component {
init() {
this.handlers = {};
/**
* The index of the post that is currently at the top of the viewport.
*
* @type {Number}
*/
this.index = 0;
/**
* The number of posts that are currently visible in the viewport.
*
* @type {Number}
*/
this.visible = 1;
/**
* The description to render on the scrubber.
*
* @type {String}
*/
this.description = '';
// When the post stream begins loading posts at a certain index, we want our
// scrubber scrollbar to jump to that position.
this.props.stream.on('unpaused', (this.handlers.streamWasUnpaused = this.streamWasUnpaused.bind(this)));
// Define a handler to update the state of the scrollbar to reflect the
// current scroll position of the page.
this.scrollListener = new ScrollListener(this.renderScrollbar.bind(this, { fromScroll: true, forceHeightChange: true }));
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
// Create a subtree retainer that will always cache the subtree after the
// initial draw. We render parts of the scrubber using this because we
@@ -36,12 +55,12 @@ export default class PostStreamScrubber extends Component {
view() {
const retain = this.subtree.retain();
const { count, index, visible } = this.props;
const unreadCount = this.props.discussion.unreadCount();
const unreadPercent = count ? Math.min(count - this.props.index, unreadCount) / count : 0;
const count = this.count();
const unreadCount = this.props.stream.discussion.unreadCount();
const unreadPercent = count ? Math.min(count - this.index, unreadCount) / count : 0;
const viewing = app.translator.transChoice('core.forum.post_scrubber.viewing_text', count, {
index: <span className="Scrubber-index">{retain || formatNumber(Math.min(Math.ceil(index + visible), count))}</span>,
index: <span className="Scrubber-index">{retain || formatNumber(Math.min(Math.ceil(this.index + this.visible), count))}</span>,
count: <span className="Scrubber-count">{formatNumber(count)}</span>,
});
@@ -79,7 +98,7 @@ export default class PostStreamScrubber extends Component {
<div className="Scrubber-bar" />
<div className="Scrubber-info">
<strong>{viewing}</strong>
<span className="Scrubber-description">{retain || this.props.description}</span>
<span className="Scrubber-description">{retain || this.description}</span>
</div>
</div>
<div className="Scrubber-after" />
@@ -102,14 +121,35 @@ export default class PostStreamScrubber extends Component {
* Go to the first post in the discussion.
*/
goToFirst() {
this.navigateTo(0);
this.props.stream.goToFirst();
this.index = 0;
this.renderScrollbar(true);
}
/**
* Go to the last post in the discussion.
*/
goToLast() {
this.navigateTo(this.props.count - 1);
this.props.stream.goToLast();
this.index = this.count();
this.renderScrollbar(true);
}
/**
* Get the number of posts in the discussion.
*
* @return {Integer}
*/
count() {
return this.props.stream.count();
}
/**
* When the stream is unpaused, update the scrubber to reflect its position.
*/
streamWasUnpaused() {
this.update(window.pageYOffset);
this.renderScrollbar(true);
}
/**
@@ -119,7 +159,87 @@ export default class PostStreamScrubber extends Component {
* @return {Boolean}
*/
disabled() {
return this.props.visible >= this.props.count;
return this.visible >= this.count();
}
/**
* When the page is scrolled, update the scrollbar to reflect the visible
* posts.
*
* @param {Integer} top
*/
onscroll(top) {
const stream = this.props.stream;
if (stream.paused || !stream.$()) return;
this.update(top);
this.renderScrollbar();
}
/**
* Update the index/visible/description properties according to the window's
* current scroll position.
*
* @param {Integer} scrollTop
*/
update(scrollTop) {
const stream = this.props.stream;
const marginTop = stream.getMarginTop();
const viewportTop = scrollTop + marginTop;
const viewportHeight = $(window).height() - marginTop;
// Before looping through all of the posts, we reset the scrollbar
// properties to a 'default' state. These values reflect what would be
// seen if the browser were scrolled right up to the top of the page,
// and the viewport had a height of 0.
const $items = stream.$('> .PostStream-item[data-index]');
let index = $items.first().data('index') || 0;
let visible = 0;
let period = '';
// Now loop through each of the items in the discussion. An 'item' is
// either a single post or a 'gap' of one or more posts that haven't
// been loaded yet.
$items.each(function () {
const $this = $(this);
const top = $this.offset().top;
const height = $this.outerHeight(true);
// If this item is above the top of the viewport, skip to the next
// one. If it's below the bottom of the viewport, break out of the
// loop.
if (top + height < viewportTop) {
return true;
}
if (top > viewportTop + viewportHeight) {
return false;
}
// Work out how many pixels of this item are visible inside the viewport.
// Then add the proportion of this item's total height to the index.
const visibleTop = Math.max(0, viewportTop - top);
const visibleBottom = Math.min(height, viewportTop + viewportHeight - top);
const visiblePost = visibleBottom - visibleTop;
if (top <= viewportTop) {
index = parseFloat($this.data('index')) + visibleTop / height;
}
if (visiblePost > 0) {
visible += visiblePost / height;
}
// If this item has a time associated with it, then set the
// scrollbar's current period to a formatted version of this time.
const time = $this.data('time');
if (time) period = time;
});
this.index = index;
this.visible = visible;
this.description = period ? moment(period).format('MMMM YYYY') : '';
}
config(isInitialized, context) {
@@ -152,7 +272,6 @@ export default class PostStreamScrubber extends Component {
this.dragging = false;
this.mouseStart = 0;
this.indexStart = 0;
this.dragIndex = null;
this.$('.Scrubber-handle')
.css('cursor', 'move')
@@ -173,6 +292,8 @@ export default class PostStreamScrubber extends Component {
ondestroy() {
this.scrollListener.stop();
this.props.stream.off('unpaused', this.handlers.streamWasUnpaused);
$(window).off('resize', this.handlers.onresize);
$(document).off('mousemove touchmove', this.handlers.onmousemove).off('mouseup touchend', this.handlers.onmouseup);
@@ -184,43 +305,31 @@ export default class PostStreamScrubber extends Component {
*
* @param {Boolean} animate
*/
renderScrollbar(options = {}) {
const { count, visible, description, paused } = this.props;
renderScrollbar(animate) {
const percentPerPost = this.percentPerPost();
const index = this.dragIndex || this.props.index;
const index = this.index;
const count = this.count();
const visible = this.visible || 1;
const $scrubber = this.$();
$scrubber.find('.Scrubber-index').text(formatNumber(Math.min(Math.ceil(index + visible), count)));
$scrubber.find('.Scrubber-description').text(description);
$scrubber.find('.Scrubber-description').text(this.description);
$scrubber.toggleClass('disabled', this.disabled());
const heights = {};
heights.before = Math.max(0, percentPerPost.index * Math.min(index - 1, count - visible));
heights.before = Math.max(0, percentPerPost.index * Math.min(index, count - visible));
heights.handle = Math.min(100 - heights.before, percentPerPost.visible * visible);
heights.after = 100 - heights.before - heights.handle;
// If the stream is paused, don't change height on scroll, as the viewport is being scrolled by the JS
// If a height change animation is already in progress, don't adjust height unless overridden
if ((options.fromScroll && paused) || (this.adjustingHeight && !options.forceHeightChange)) return;
const func = options.animate ? 'animate' : 'css';
this.adjustingHeight = true;
const animationPromises = [];
const func = animate ? 'animate' : 'css';
for (const part in heights) {
const $part = $scrubber.find(`.Scrubber-${part}`);
animationPromises.push(
$part
.stop(true, true)
[func]({ height: heights[part] + '%' }, 'fast')
.promise()
);
$part.stop(true, true)[func]({ height: heights[part] + '%' }, 'fast');
// jQuery likes to put overflow:hidden, but because the scrollbar handle
// has a negative margin-left, we need to override.
if (func === 'animate') $part.css('overflow', 'visible');
}
Promise.all(animationPromises).then(() => (this.adjustingHeight = false));
}
/**
@@ -234,8 +343,8 @@ export default class PostStreamScrubber extends Component {
* scrubber.
*/
percentPerPost() {
const count = this.props.count || 1;
const visible = this.props.visible || 1;
const count = this.count() || 1;
const visible = this.visible || 1;
// To stop the handle of the scrollbar from getting too small when there
// are many posts, we define a minimum percentage height for the handle
@@ -272,13 +381,11 @@ export default class PostStreamScrubber extends Component {
}
onmousedown(e) {
e.redraw = false;
this.mouseStart = e.clientY || e.originalEvent.touches[0].clientY;
this.indexStart = this.props.index;
this.indexStart = this.index;
this.dragging = true;
this.dragIndex = null;
this.props.stream.paused = true;
$('body').css('cursor', 'move');
this.$().toggleClass('dragging', this.dragging);
}
onmousemove(e) {
@@ -291,14 +398,13 @@ export default class PostStreamScrubber extends Component {
const deltaPixels = (e.clientY || e.originalEvent.touches[0].clientY) - this.mouseStart;
const deltaPercent = (deltaPixels / this.$('.Scrubber-scrollbar').outerHeight()) * 100;
const deltaIndex = deltaPercent / this.percentPerPost().index || 0;
const newIndex = Math.min(this.indexStart + deltaIndex, this.props.count - 1);
const newIndex = Math.min(this.indexStart + deltaIndex, this.count() - 1);
this.dragIndex = Math.max(0, newIndex);
this.index = Math.max(0, newIndex);
this.renderScrollbar();
}
onmouseup() {
this.$().toggleClass('dragging', this.dragging);
if (!this.dragging) return;
this.mouseStart = 0;
@@ -310,9 +416,9 @@ export default class PostStreamScrubber extends Component {
// If the index we've landed on is in a gap, then tell the stream-
// content that we want to load those posts.
this.navigateTo(this.dragIndex);
this.dragIndex = null;
const intIndex = Math.floor(this.index);
this.props.stream.goToIndex(intIndex);
this.renderScrollbar(true);
}
onclick(e) {
@@ -332,21 +438,11 @@ export default class PostStreamScrubber extends Component {
// 3. Now we can convert the percentage into an index, and tell the stream-
// content component to jump to that index.
let offsetIndex = offsetPercent / this.percentPerPost().index;
offsetIndex = Math.max(0, Math.min(this.props.count - 1, offsetIndex));
this.navigateTo(offsetIndex);
offsetIndex = Math.max(0, Math.min(this.count() - 1, offsetIndex));
this.props.stream.goToIndex(Math.floor(offsetIndex));
this.index = offsetIndex;
this.renderScrollbar(true);
this.$().removeClass('open');
}
/**
* Trigger post stream navigation, but also animate the scrollbar according
* to the expected result.
*
* @param {number} index
*/
navigateTo(index) {
this.props.onNavigate(Math.floor(index));
this.renderScrollbar({ animate: true });
}
}

View File

@@ -13,6 +13,15 @@ import listItems from '../../common/helpers/listItems';
* - `post`
*/
export default class PostUser extends Component {
init() {
/**
* Whether or not the user hover card is visible.
*
* @type {Boolean}
*/
this.cardVisible = false;
}
view() {
const post = this.props.post;
const user = post.user();
@@ -29,7 +38,7 @@ export default class PostUser extends Component {
let card = '';
if (!post.isHidden() && this.props.cardVisible) {
if (!post.isHidden() && this.cardVisible) {
card = UserCard.component({
user,
className: 'UserCard--popover',
@@ -72,7 +81,9 @@ export default class PostUser extends Component {
* Show the user card.
*/
showCard() {
this.props.oncardshow();
this.cardVisible = true;
m.redraw();
setTimeout(() => this.$('.UserCard').addClass('in'));
}
@@ -84,7 +95,8 @@ export default class PostUser extends Component {
this.$('.UserCard')
.removeClass('in')
.one('transitionend webkitTransitionEnd oTransitionEnd', () => {
this.props.oncardhide();
this.cardVisible = false;
m.redraw();
});
}
}

View File

@@ -57,7 +57,7 @@ export default class RenameDiscussionModal extends Modal {
.save({ title })
.then(() => {
if (app.viewingDiscussion(this.discussion)) {
app.current.get('stream').update();
app.current.stream.update();
}
m.redraw();
this.hide();

View File

@@ -1,4 +1,5 @@
import ComposerBody from './ComposerBody';
import Alert from '../../common/components/Alert';
import Button from '../../common/components/Button';
import icon from '../../common/helpers/icon';
import extractText from '../../common/utils/extractText';
@@ -20,6 +21,16 @@ function minimizeComposerIfFullScreen(e) {
* - `discussion`
*/
export default class ReplyComposer extends ComposerBody {
init() {
super.init();
this.editor.props.preview = (e) => {
minimizeComposerIfFullScreen(e);
m.route(app.route.discussion(this.props.discussion, 'reply'));
};
}
static initProps(props) {
super.initProps(props);
@@ -51,15 +62,6 @@ export default class ReplyComposer extends ComposerBody {
return items;
}
/**
* Jump to the preview when triggered by the text editor.
*/
jumpToPreview(e) {
minimizeComposerIfFullScreen(e);
m.route(app.route.discussion(this.props.discussion, 'reply'));
}
/**
* Get the data to submit to the server when the reply is saved.
*
@@ -67,7 +69,7 @@ export default class ReplyComposer extends ComposerBody {
*/
data() {
return {
content: this.composer.fields.content(),
content: this.content(),
relationships: { discussion: this.props.discussion },
};
}
@@ -87,8 +89,7 @@ export default class ReplyComposer extends ComposerBody {
// If we're currently viewing the discussion which this reply was made
// in, then we can update the post stream and scroll to the post.
if (app.viewingDiscussion(discussion)) {
const stream = app.current.get('stream');
stream.update().then(() => stream.goToNumber(post.number()));
app.current.stream.update().then(() => app.current.stream.goToNumber(post.number()));
} else {
// Otherwise, we'll create an alert message to inform the user that
// their reply has been posted, containing a button which will
@@ -102,14 +103,16 @@ export default class ReplyComposer extends ComposerBody {
app.alerts.dismiss(alert);
},
});
alert = app.alerts.show({
type: 'success',
children: app.translator.trans('core.forum.composer_reply.posted_message'),
controls: [viewButton],
});
app.alerts.show(
(alert = new Alert({
type: 'success',
children: app.translator.trans('core.forum.composer_reply.posted_message'),
controls: [viewButton],
}))
);
}
this.composer.hide();
app.composer.hide();
}, this.loaded.bind(this));
}
}

View File

@@ -15,7 +15,7 @@ import DiscussionControls from '../utils/DiscussionControls';
*/
export default class ReplyPlaceholder extends Component {
view() {
if (app.composer.composingReplyTo(this.props.discussion)) {
if (app.composingReplyTo(this.props.discussion)) {
return (
<article className="Post CommentPost editing">
<header className="Post-header">
@@ -53,9 +53,9 @@ export default class ReplyPlaceholder extends Component {
const updateInterval = setInterval(() => {
// Since we're polling, the composer may have been closed in the meantime,
// so we bail in that case.
if (!app.composer.isVisible()) return;
if (!app.composer.component) return;
const content = app.composer.fields.content();
const content = app.composer.component.content();
if (preview === content) return;

View File

@@ -12,17 +12,19 @@ import UsersSearchSource from './UsersSearchSource';
* The `Search` component displays a menu of as-you-type results from a variety
* of sources.
*
* The search box will be 'activated' if the app's search state's
* getInitialSearch() value is a truthy value. If this is the case, an 'x'
* button will be shown next to the search field, and clicking it will clear the search.
*
* PROPS:
*
* - state: SearchState instance.
* The search box will be 'activated' if the app's current controller implements
* a `searching` method that returns a truthy value. If this is the case, an 'x'
* button will be shown next to the search field, and clicking it will call the
* `clearSearch` method on the controller.
*/
export default class Search extends Component {
init() {
this.state = this.props.state;
/**
* The value of the search input.
*
* @type {Function}
*/
this.value = m.prop('');
/**
* Whether or not the search input has focus.
@@ -45,6 +47,13 @@ export default class Search extends Component {
*/
this.loadingSources = 0;
/**
* A list of queries that have been searched for.
*
* @type {Array}
*/
this.searched = [];
/**
* The index of the currently-selected <li> in the results list. This can be
* a unique string (to account for the fact that an item's position may jump
@@ -57,7 +66,13 @@ export default class Search extends Component {
}
view() {
const currentSearch = this.state.getInitialSearch();
const currentSearch = this.getCurrentSearch();
// Initialize search input value in the view rather than the constructor so
// that we have access to app.current.
if (typeof this.value() === 'undefined') {
this.value(currentSearch || '');
}
// Initialize search sources in the view rather than the constructor so
// that we have access to app.forum.
@@ -73,7 +88,7 @@ export default class Search extends Component {
className={
'Search ' +
classList({
open: this.state.getValue() && this.hasFocus,
open: this.value() && this.hasFocus,
focused: this.hasFocus,
active: !!currentSearch,
loading: !!this.loadingSources,
@@ -85,8 +100,8 @@ export default class Search extends Component {
className="FormControl"
type="search"
placeholder={extractText(app.translator.trans('core.forum.header.search_placeholder'))}
value={this.state.getValue()}
oninput={m.withAttr('value', this.state.setValue.bind(this.state))}
value={this.value()}
oninput={m.withAttr('value', this.value)}
onfocus={() => (this.hasFocus = true)}
onblur={() => (this.hasFocus = false)}
/>
@@ -101,7 +116,7 @@ export default class Search extends Component {
)}
</div>
<ul className="Dropdown-menu Search-results">
{this.state.getValue() && this.hasFocus ? this.sources.map((source) => source.view(this.state.getValue())) : ''}
{this.value() && this.hasFocus ? this.sources.map((source) => source.view(this.value())) : ''}
</ul>
</div>
);
@@ -114,7 +129,6 @@ export default class Search extends Component {
if (isInitialized) return;
const search = this;
const state = this.state;
this.$('.Search-results')
.on('mousedown', (e) => e.preventDefault())
@@ -144,7 +158,7 @@ export default class Search extends Component {
clearTimeout(search.searchTimeout);
search.searchTimeout = setTimeout(() => {
if (state.isCached(query)) return;
if (search.searched.indexOf(query) !== -1) return;
if (query.length >= 3) {
search.sources.map((source) => {
@@ -159,7 +173,7 @@ export default class Search extends Component {
});
}
state.cache(query);
search.searched.push(query);
m.redraw();
}, 250);
})
@@ -171,6 +185,15 @@ export default class Search extends Component {
});
}
/**
* Get the active search in the app's current controller.
*
* @return {String}
*/
getCurrentSearch() {
return app.current && typeof app.current.searching === 'function' && app.current.searching();
}
/**
* Navigate to the currently selected search result and close the list.
*/
@@ -178,7 +201,7 @@ export default class Search extends Component {
clearTimeout(this.searchTimeout);
this.loadingSources = 0;
if (this.state.getValue()) {
if (this.value()) {
m.route(this.getItem(this.index).find('a').attr('href'));
} else {
this.clear();
@@ -188,10 +211,16 @@ export default class Search extends Component {
}
/**
* Clear the search
* Clear the search input and the current controller's active search.
*/
clear() {
this.state.clear();
this.value('');
if (this.getCurrentSearch()) {
app.current.clearSearch();
} else {
m.redraw();
}
}
/**

View File

@@ -2,8 +2,8 @@
* The `SearchSource` interface defines a section of search results in the
* search dropdown.
*
* Search sources should be registered with the `Search` component class
* by extending the `sourceItems` method. When the user types a
* Search sources should be registered with the `Search` component instance
* (app.search) by extending the `sourceItems` method. When the user types a
* query, each search source will be prompted to load search results via the
* `search` method. When the dropdown is redrawn, it will be constructed by
* putting together the output from the `view` method of each source.

View File

@@ -79,7 +79,7 @@ export default class SettingsPage extends UserPage {
Button.component({
children: app.translator.trans('core.forum.settings.change_password_button'),
className: 'Button',
onclick: () => app.modal.show(ChangePasswordModal),
onclick: () => app.modal.show(new ChangePasswordModal()),
})
);
@@ -88,7 +88,7 @@ export default class SettingsPage extends UserPage {
Button.component({
children: app.translator.trans('core.forum.settings.change_email_button'),
className: 'Button',
onclick: () => app.modal.show(ChangeEmailModal),
onclick: () => app.modal.show(new ChangeEmailModal()),
})
);
@@ -109,8 +109,6 @@ export default class SettingsPage extends UserPage {
}
/**
* @deprecated beta 14, remove in beta 15.
*
* Generate a callback that will save a value to the given preference.
*
* @param {String} key
@@ -118,11 +116,11 @@ export default class SettingsPage extends UserPage {
*/
preferenceSaver(key) {
return (value, component) => {
if (component) component.props.loading = true;
if (component) component.loading = true;
m.redraw();
this.user.savePreferences({ [key]: value }).then(() => {
if (component) component.props.loading = false;
if (component) component.loading = false;
m.redraw();
});
};
@@ -141,15 +139,10 @@ export default class SettingsPage extends UserPage {
Switch.component({
children: app.translator.trans('core.forum.settings.privacy_disclose_online_label'),
state: this.user.preferences().discloseOnline,
onchange: (value) => {
this.discloseOnlineLoading = true;
this.user.savePreferences({ discloseOnline: value }).then(() => {
this.discloseOnlineLoading = false;
m.redraw();
});
onchange: (value, component) => {
this.user.pushAttributes({ lastSeenAt: null });
this.preferenceSaver('discloseOnline')(value, component);
},
loading: this.discloseOnlineLoading,
})
);

View File

@@ -145,7 +145,7 @@ export default class SignUpModal extends Modal {
password: this.password(),
};
app.modal.show(LogInModal, props);
app.modal.show(new LogInModal(props));
}
onready() {

View File

@@ -1,6 +1,5 @@
import Component from '../../common/Component';
import ItemList from '../../common/utils/ItemList';
import SuperTextarea from '../../common/utils/SuperTextarea';
import listItems from '../../common/helpers/listItems';
import Button from '../../common/components/Button';
@@ -10,12 +9,10 @@ import Button from '../../common/components/Button';
*
* ### Props
*
* - `composer`
* - `submitLabel`
* - `value`
* - `placeholder`
* - `disabled`
* - `preview`
*/
export default class TextEditor extends Component {
init() {
@@ -24,7 +21,7 @@ export default class TextEditor extends Component {
*
* @type {String}
*/
this.value = this.props.value || '';
this.value = m.prop(this.props.value || '');
}
view() {
@@ -36,7 +33,7 @@ export default class TextEditor extends Component {
oninput={m.withAttr('value', this.oninput.bind(this))}
placeholder={this.props.placeholder || ''}
disabled={!!this.props.disabled}
value={this.value}
value={this.value()}
/>
<ul className="TextEditor-controls Composer-footer">
@@ -50,7 +47,7 @@ export default class TextEditor extends Component {
/**
* Configure the textarea element.
*
* @param {HTMLTextAreaElement} element
* @param {DOMElement} element
* @param {Boolean} isInitialized
*/
configTextarea(element, isInitialized) {
@@ -63,8 +60,6 @@ export default class TextEditor extends Component {
$(element).bind('keydown', 'meta+return', handler);
$(element).bind('keydown', 'ctrl+return', handler);
this.props.composer.editor = new SuperTextarea(element);
}
/**
@@ -111,15 +106,73 @@ export default class TextEditor extends Component {
return new ItemList();
}
/**
* Set the value of the text editor.
*
* @param {String} value
*/
setValue(value) {
this.$('textarea').val(value).trigger('input');
}
/**
* Set the selected range of the textarea.
*
* @param {Integer} start
* @param {Integer} end
*/
setSelectionRange(start, end) {
const $textarea = this.$('textarea');
if (!$textarea.length) return;
$textarea[0].setSelectionRange(start, end);
$textarea.focus();
}
/**
* Get the selected range of the textarea.
*
* @return {Array}
*/
getSelectionRange() {
const $textarea = this.$('textarea');
if (!$textarea.length) return [0, 0];
return [$textarea[0].selectionStart, $textarea[0].selectionEnd];
}
/**
* Insert content into the textarea at the position of the cursor.
*
* @param {String} insert
*/
insertAtCursor(insert) {
const textarea = this.$('textarea')[0];
const value = this.value();
const index = textarea ? textarea.selectionStart : value.length;
this.setValue(value.slice(0, index) + insert + value.slice(index));
// Move the textarea cursor to the end of the content we just inserted.
if (textarea) {
const pos = index + insert.length;
this.setSelectionRange(pos, pos);
}
textarea.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
}
/**
* Handle input into the textarea.
*
* @param {String} value
*/
oninput(value) {
this.value = value;
this.value(value);
this.props.onchange(this.value);
this.props.onchange(this.value());
m.redraw.strategy('none');
}
@@ -128,6 +181,6 @@ export default class TextEditor extends Component {
* Handle the submit button being clicked.
*/
onsubmit() {
this.props.onsubmit(this.value);
this.props.onsubmit(this.value());
}
}

View File

@@ -1,4 +1,4 @@
import Page from '../../common/components/Page';
import Page from './Page';
import ItemList from '../../common/utils/ItemList';
import affixSidebar from '../utils/affixSidebar';
import UserCard from './UserCard';
@@ -71,8 +71,6 @@ export default class UserPage extends Page {
show(user) {
this.user = user;
app.current.set('user', user);
app.setTitle(user.displayName());
m.redraw();

View File

@@ -1,285 +0,0 @@
import subclassOf from '../../common/utils/subclassOf';
import ReplyComposer from '../components/ReplyComposer';
class ComposerState {
constructor() {
/**
* The composer's current position.
*
* @type {ComposerState.Position}
*/
this.position = ComposerState.Position.HIDDEN;
/**
* The composer's intended height, which can be modified by the user
* (by dragging the composer handle).
*
* @type {Integer}
*/
this.height = null;
/**
* The dynamic component being shown inside the composer.
*
* @type {Object}
*/
this.body = { attrs: {} };
/**
* A reference to the text editor that allows text manipulation.
*
* @type {SuperTextArea|null}
*/
this.editor = null;
this.clear();
/**
* @deprecated BC layer, remove in Beta 15.
*/
this.component = this;
}
/**
* Load a content component into the composer.
*
* @param {ComposerBody} componentClass
* @public
*/
load(componentClass, attrs) {
const body = { componentClass, attrs };
if (this.preventExit()) return;
// If we load a similar component into the composer, then Mithril will be
// able to diff the old/new contents and some DOM-related state from the
// old composer will remain. To prevent this from happening, we clear the
// component and force a redraw, so that the new component will be working
// on a blank slate.
if (this.isVisible()) {
this.clear();
m.redraw(true);
}
this.body = body;
}
/**
* Clear the composer's content component.
*/
clear() {
this.position = ComposerState.Position.HIDDEN;
this.body = { attrs: {} };
this.editor = null;
this.onExit = null;
this.fields = {
content: m.prop(''),
};
/**
* @deprecated BC layer, remove in Beta 15.
*/
this.content = this.fields.content;
this.value = this.fields.content;
}
/**
* Show the composer.
*
* @public
*/
show() {
if (this.position === ComposerState.Position.NORMAL || this.position === ComposerState.Position.FULLSCREEN) return;
this.position = ComposerState.Position.NORMAL;
m.redraw();
}
/**
* Close the composer.
*
* @public
*/
hide() {
this.clear();
m.redraw();
}
/**
* Confirm with the user so they don't lose their content, then close the
* composer.
*
* @public
*/
close() {
if (this.preventExit()) return;
this.hide();
}
/**
* Minimize the composer. Has no effect if the composer is hidden.
*
* @public
*/
minimize() {
if (!this.isVisible()) return;
this.position = ComposerState.Position.MINIMIZED;
m.redraw();
}
/**
* Take the composer into fullscreen mode. Has no effect if the composer is
* hidden.
*
* @public
*/
fullScreen() {
if (!this.isVisible()) return;
this.position = ComposerState.Position.FULLSCREEN;
m.redraw();
}
/**
* Exit fullscreen mode.
*
* @public
*/
exitFullScreen() {
if (this.position !== ComposerState.Position.FULLSCREEN) return;
this.position = ComposerState.Position.NORMAL;
m.redraw();
}
/**
* Determine whether the body matches the given component class and data.
*
* @param {object} type The component class to check against. Subclasses are
* accepted as well.
* @param {object} data
* @return {boolean}
*/
bodyMatches(type, data = {}) {
// Fail early when the body is of a different type
if (!subclassOf(this.body.componentClass, type)) return false;
// Now that the type is known to be correct, we loop through the provided
// data to see whether it matches the data in the attributes for the body.
return Object.keys(data).every((key) => this.body.attrs[key] === data[key]);
}
/**
* Determine whether or not the Composer is visible.
*
* True when the composer is displayed on the screen and has a body component.
* It could be open in "normal" or full-screen mode, or even minimized.
*
* @returns {boolean}
*/
isVisible() {
return this.position !== ComposerState.Position.HIDDEN;
}
/**
* Determine whether or not the Composer is covering the screen.
*
* This will be true if the Composer is in full-screen mode on desktop,
* or if we are on a mobile device, where we always consider the composer as full-screen..
*
* @return {Boolean}
* @public
*/
isFullScreen() {
return this.position === ComposerState.Position.FULLSCREEN || app.screen() === 'phone';
}
/**
* Check whether or not the user is currently composing a reply to a
* discussion.
*
* @param {Discussion} discussion
* @return {Boolean}
*/
composingReplyTo(discussion) {
return this.isVisible() && this.bodyMatches(ReplyComposer, { discussion });
}
/**
* Confirm with the user that they want to close the composer and lose their
* content.
*
* @return {Boolean} Whether or not the exit was cancelled.
*/
preventExit() {
if (!this.isVisible()) return;
if (!this.onExit) return;
if (this.onExit.callback()) {
return !confirm(this.onExit.message);
}
}
/**
* Configure when / what to ask the user before closing the composer.
*
* The provided callback will be used to determine whether asking for
* confirmation is necessary. If the callback returns true at the time of
* closing, the provided text will be shown in a standard confirmation dialog.
*
* @param {Function} callback
* @param {String} message
*/
preventClosingWhen(callback, message) {
this.onExit = { callback, message };
}
/**
* Minimum height of the Composer.
* @returns {Integer}
*/
minimumHeight() {
return 200;
}
/**
* Maxmimum height of the Composer.
* @returns {Integer}
*/
maximumHeight() {
return $(window).height() - $('#header').outerHeight();
}
/**
* Computed the composer's current height, based on the intended height, and
* the composer's current state. This will be applied to the composer's
* content's DOM element.
* @returns {Integer|String}
*/
computedHeight() {
// If the composer is minimized, then we don't want to set a height; we'll
// let the CSS decide how high it is. If it's fullscreen, then we need to
// make it as high as the window.
if (this.position === ComposerState.Position.MINIMIZED) {
return '';
} else if (this.position === ComposerState.Position.FULLSCREEN) {
return $(window).height();
}
// Otherwise, if it's normal or hidden, then we use the intended height.
// We don't let the composer get too small or too big, though.
return Math.max(this.minimumHeight(), Math.min(this.height, this.maximumHeight()));
}
}
ComposerState.Position = {
HIDDEN: 'hidden',
NORMAL: 'normal',
MINIMIZED: 'minimized',
FULLSCREEN: 'fullScreen',
};
export default ComposerState;

View File

@@ -1,190 +0,0 @@
export default class DiscussionListState {
constructor(params = {}, app = window.app) {
this.params = params;
this.app = app;
this.discussions = [];
this.moreResults = false;
this.loading = false;
}
/**
* Get the parameters that should be passed in the API request to get
* discussion results.
*
* @api
*/
requestParams() {
const params = { include: ['user', 'lastPostedUser'], filter: {} };
params.sort = this.sortMap()[this.params.sort];
if (this.params.q) {
params.filter.q = this.params.q;
params.include.push('mostRelevantPost', 'mostRelevantPost.user');
}
return params;
}
/**
* Get a map of sort keys (which appear in the URL, and are used for
* translation) to the API sort value that they represent.
*/
sortMap() {
const map = {};
if (this.params.q) {
map.relevance = '';
}
map.latest = '-lastPostedAt';
map.top = '-commentCount';
map.newest = '-createdAt';
map.oldest = 'createdAt';
return map;
}
/**
* Get the search parameters.
*/
getParams() {
return this.params;
}
/**
* Clear cached discussions.
*/
clear() {
this.discussions = [];
m.redraw();
}
/**
* If there are no cached discussions or the new params differ from the
* old ones, update params and refresh the discussion list from the database.
*/
refreshParams(newParams) {
if (!this.hasDiscussions() || Object.keys(newParams).some((key) => this.getParams()[key] !== newParams[key])) {
this.params = newParams;
this.refresh();
}
}
/**
* Clear and reload the discussion list.
*/
refresh({ clear = true } = {}) {
this.loading = true;
if (clear) {
this.clear();
}
return this.loadResults().then(
(results) => {
this.parseResults(results);
},
() => {
this.loading = false;
m.redraw();
}
);
}
/**
* Load a new page of discussion results.
*
* @param offset The index to start the page at.
*/
loadResults(offset) {
const preloadedDiscussions = this.app.preloadedApiDocument();
if (preloadedDiscussions) {
return Promise.resolve(preloadedDiscussions);
}
const params = this.requestParams();
params.page = { offset };
params.include = params.include.join(',');
return this.app.store.find('discussions', params);
}
/**
* Load the next page of discussion results.
*/
loadMore() {
this.loading = true;
this.loadResults(this.discussions.length).then(this.parseResults.bind(this));
}
/**
* Parse results and append them to the discussion list.
*/
parseResults(results) {
this.discussions.push(...results);
this.loading = false;
this.moreResults = !!results.payload.links && !!results.payload.links.next;
m.redraw();
return results;
}
/**
* Remove a discussion from the list if it is present.
*/
removeDiscussion(discussion) {
const index = this.discussions.indexOf(discussion);
if (index !== -1) {
this.discussions.splice(index, 1);
}
m.redraw();
}
/**
* Add a discussion to the top of the list.
*/
addDiscussion(discussion) {
this.discussions.unshift(discussion);
m.redraw();
}
/**
* Are there discussions stored in the discussion list state?
*/
hasDiscussions() {
return this.discussions.length > 0;
}
/**
* Are discussions currently being loaded?
*/
isLoading() {
return this.loading;
}
/**
* In the last request, has the user searched for a discussion?
*/
isSearchResults() {
return !!this.params.q;
}
/**
* Have the search results come up empty?
*/
empty() {
return !this.hasDiscussions() && !this.isLoading();
}
}

View File

@@ -1,95 +0,0 @@
import SearchState from './SearchState';
export default class GlobalSearchState extends SearchState {
constructor(cachedSearches = [], searchRoute = 'index') {
super(cachedSearches);
this.searchRoute = searchRoute;
}
getValue() {
if (this.value === undefined) {
this.value = this.getInitialSearch() || '';
}
return super.getValue();
}
/**
* Clear the search input and the current controller's active search.
*/
clear() {
super.clear();
if (this.getInitialSearch()) {
this.clearInitialSearch();
} else {
m.redraw();
}
}
/**
* Get URL parameters that stick between filter changes.
*
* @return {Object}
*/
stickyParams() {
return {
sort: m.route.param('sort'),
q: m.route.param('q'),
};
}
/**
* Get parameters to pass to the DiscussionList component.
*
* @return {Object}
*/
params() {
const params = this.stickyParams();
params.filter = m.route.param('filter');
return params;
}
/**
* Redirect to the index page using the given sort parameter.
*
* @param {String} sort
*/
changeSort(sort) {
const params = this.params();
if (sort === Object.keys(app.discussions.sortMap())[0]) {
delete params.sort;
} else {
params.sort = sort;
}
m.route(app.route(this.searchRoute, params));
}
/**
* Return the current search query, if any. This is implemented to activate
* the search box in the header.
*
* @see Search
* @return {String}
*/
getInitialSearch() {
return app.current.type.providesInitialSearch && this.params().q;
}
/**
* Redirect to the index page without a search filter. This is called when the
* 'x' is clicked in the search box in the header.
*
* @see Search
*/
clearInitialSearch() {
const params = this.params();
delete params.q;
m.route(app.route(this.searchRoute, params));
}
}

View File

@@ -1,98 +0,0 @@
export default class NotificationListState {
constructor(app) {
this.app = app;
this.notificationPages = [];
this.loading = false;
this.moreResults = false;
}
clear() {
this.notificationPages = [];
}
getNotificationPages() {
return this.notificationPages;
}
isLoading() {
return this.loading;
}
hasMoreResults() {
return this.moreResults;
}
/**
* Load notifications into the application's cache if they haven't already
* been loaded.
*/
load() {
if (this.app.session.user.newNotificationCount()) {
this.notificationPages = [];
}
if (this.notificationPages.length > 0) {
return;
}
this.app.session.user.pushAttributes({ newNotificationCount: 0 });
this.loadMore();
}
/**
* Load the next page of notification results.
*
* @public
*/
loadMore() {
this.loading = true;
m.redraw();
const params = this.notificationPages.length > 0 ? { page: { offset: this.notificationPages.length * 10 } } : null;
return this.app.store
.find('notifications', params)
.then(this.parseResults.bind(this))
.catch(() => {})
.then(() => {
this.loading = false;
m.redraw();
});
}
/**
* Parse results and append them to the notification list.
*
* @param {Notification[]} results
* @return {Notification[]}
*/
parseResults(results) {
if (results.length) this.notificationPages.push(results);
this.moreResults = !!results.payload.links.next;
return results;
}
/**
* Mark all of the notifications as read.
*/
markAllAsRead() {
if (this.notificationPages.length === 0) return;
this.app.session.user.pushAttributes({ unreadNotificationCount: 0 });
this.notificationPages.forEach((notifications) => {
notifications.forEach((notification) => notification.pushAttributes({ isRead: true }));
});
this.app.request({
url: this.app.forum.attribute('apiUrl') + '/notifications/read',
method: 'POST',
});
}
}

View File

@@ -1,329 +0,0 @@
import anchorScroll from '../../common/utils/anchorScroll';
class PostStreamState {
constructor(discussion, includedPosts = []) {
/**
* The discussion to display the post stream for.
*
* @type {Discussion}
*/
this.discussion = discussion;
/**
* Whether or not the infinite-scrolling auto-load functionality is
* disabled.
*
* @type {Boolean}
*/
this.paused = false;
this.loadPageTimeouts = {};
this.pagesLoading = 0;
this.show(includedPosts);
}
/**
* Update the stream so that it loads and includes the latest posts in the
* discussion, if the end is being viewed.
*
* @public
*/
update() {
if (!this.viewingEnd()) return m.deferred().resolve().promise;
this.visibleEnd = this.count();
return this.loadRange(this.visibleStart, this.visibleEnd);
}
/**
* Load and scroll up to the first post in the discussion.
*
* @return {Promise}
*/
goToFirst() {
return this.goToIndex(0);
}
/**
* Load and scroll down to the last post in the discussion.
*
* @return {Promise}
*/
goToLast() {
return this.goToIndex(this.count() - 1, true);
}
/**
* Load and scroll to a post with a certain number.
*
* @param {number|String} number The post number to go to. If 'reply', go to
* the last post and scroll the reply preview into view.
* @param {Boolean} noAnimation
* @return {Promise}
*/
goToNumber(number, noAnimation = false) {
// If we want to go to the reply preview, then we will go to the end of the
// discussion and then scroll to the very bottom of the page.
if (number === 'reply') {
return this.goToLast();
}
this.paused = true;
this.targetPost = { number };
this.noAnimationScroll = noAnimation;
// In this case, the redraw is only called after the response has been loaded
// because we need to know the indices of the post range before we can
// start scrolling to items. Calling redraw early causes issues.
// Since this is only used for external navigation to the post stream, the delay
// before the stream is moved is not an issue.
return this.loadNearNumber(number).then(() => {
this.paused = false;
m.redraw();
});
}
/**
* Load and scroll to a certain index within the discussion.
*
* @param {number} index
* @param {Boolean} noAnimation
* @return {Promise}
*/
goToIndex(index, noAnimation = false) {
this.paused = true;
const promise = this.loadNearIndex(index);
this.targetPost = { index };
this.noAnimationScroll = noAnimation;
this.index = index;
m.redraw();
return promise.then(() => (this.paused = false));
}
/**
* Clear the stream and load posts near a certain number. Returns a promise.
* If the post with the given number is already loaded, the promise will be
* resolved immediately.
*
* @param {number} number
* @return {Promise}
*/
loadNearNumber(number) {
if (this.posts().some((post) => post && Number(post.number()) === Number(number))) {
return m.deferred().resolve().promise;
}
this.reset();
return app.store
.find('posts', {
filter: { discussion: this.discussion.id() },
page: { near: number },
})
.then(this.show.bind(this));
}
/**
* Clear the stream and load posts near a certain index. A page of posts
* surrounding the given index will be loaded. Returns a promise. If the given
* index is already loaded, the promise will be resolved immediately.
*
* @param {number} index
* @return {Promise}
*/
loadNearIndex(index) {
if (index >= this.visibleStart && index <= this.visibleEnd) {
return m.deferred().resolve().promise;
}
const start = this.sanitizeIndex(index - this.constructor.loadCount / 2);
const end = start + this.constructor.loadCount;
this.reset(start, end);
return this.loadRange(start, end).then(this.show.bind(this));
}
/**
* Load the next page of posts.
*/
loadNext() {
const start = this.visibleEnd;
const end = (this.visibleEnd = this.sanitizeIndex(this.visibleEnd + this.constructor.loadCount));
// Unload the posts which are two pages back from the page we're currently
// loading.
const twoPagesAway = start - this.constructor.loadCount * 2;
if (twoPagesAway > this.visibleStart && twoPagesAway >= 0) {
this.visibleStart = twoPagesAway + this.constructor.loadCount + 1;
if (this.loadPageTimeouts[twoPagesAway]) {
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
this.loadPageTimeouts[twoPagesAway] = null;
this.pagesLoading--;
}
}
this.loadPage(start, end);
}
/**
* Load the previous page of posts.
*/
loadPrevious() {
const end = this.visibleStart;
const start = (this.visibleStart = this.sanitizeIndex(this.visibleStart - this.constructor.loadCount));
// Unload the posts which are two pages back from the page we're currently
// loading.
const twoPagesAway = start + this.constructor.loadCount * 2;
if (twoPagesAway < this.visibleEnd && twoPagesAway <= this.count()) {
this.visibleEnd = twoPagesAway;
if (this.loadPageTimeouts[twoPagesAway]) {
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
this.loadPageTimeouts[twoPagesAway] = null;
this.pagesLoading--;
}
}
this.loadPage(start, end, true);
}
/**
* Load a page of posts into the stream and redraw.
*
* @param {number} start
* @param {number} end
* @param {Boolean} backwards
*/
loadPage(start, end, backwards = false) {
m.redraw();
this.loadPageTimeouts[start] = setTimeout(
() => {
this.loadRange(start, end).then(() => {
if (start >= this.visibleStart && end <= this.visibleEnd) {
const anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
anchorScroll(`.PostStream-item[data-index="${anchorIndex}"]`, () => m.redraw(true));
}
this.pagesLoading--;
});
this.loadPageTimeouts[start] = null;
},
this.pagesLoading ? 1000 : 0
);
this.pagesLoading++;
}
/**
* Load and inject the specified range of posts into the stream, without
* clearing it.
*
* @param {number} start
* @param {number} end
* @return {Promise}
*/
loadRange(start, end) {
const loadIds = [];
const loaded = [];
this.discussion
.postIds()
.slice(start, end)
.forEach((id) => {
const post = app.store.getById('posts', id);
if (post && post.discussion() && typeof post.canEdit() !== 'undefined') {
loaded.push(post);
} else {
loadIds.push(id);
}
});
return loadIds.length ? app.store.find('posts', loadIds) : m.deferred().resolve(loaded).promise;
}
/**
* Set up the stream with the given array of posts.
*
* @param {Post[]} posts
*/
show(posts) {
this.visibleStart = posts.length ? this.discussion.postIds().indexOf(posts[0].id()) : 0;
this.visibleEnd = this.sanitizeIndex(this.visibleStart + posts.length);
}
/**
* Reset the stream so that a specific range of posts is displayed. If a range
* is not specified, the first page of posts will be displayed.
*
* @param {number} [start]
* @param {number} [end]
*/
reset(start, end) {
this.visibleStart = start || 0;
this.visibleEnd = this.sanitizeIndex(end || this.constructor.loadCount);
}
/**
* Get the visible page of posts.
*
* @return {Post[]}
*/
posts() {
return this.discussion
.postIds()
.slice(this.visibleStart, this.visibleEnd)
.map((id) => {
const post = app.store.getById('posts', id);
return post && post.discussion() && typeof post.canEdit() !== 'undefined' ? post : null;
});
}
/**
* Get the total number of posts in the discussion.
*
* @return {number}
*/
count() {
return this.discussion.postIds().length;
}
/**
* Are we currently viewing the end of the discussion?
*
* @return {boolean}
*/
viewingEnd() {
return this.visibleEnd === this.count();
}
/**
* Make sure that the given index is not outside of the possible range of
* indexes in the discussion.
*
* @param {number} index
*/
sanitizeIndex(index) {
return Math.max(0, Math.min(this.count(), Math.floor(index)));
}
}
/**
* The number of posts to load per page.
*
* @type {number}
*/
PostStreamState.loadCount = 20;
export default PostStreamState;

View File

@@ -1,35 +0,0 @@
export default class SearchState {
constructor(cachedSearches = []) {
this.cachedSearches = cachedSearches;
}
getValue() {
return this.value;
}
setValue(value) {
this.value = value;
}
/**
* Clear the search value.
*/
clear() {
this.setValue('');
}
/**
* Mark that we have already searched for this query so that we don't
* have to ping the endpoint again.
*/
cache(query) {
this.cachedSearches.push(query);
}
/**
* Check if this query has been searched before.
*/
isCached(query) {
return this.cachedSearches.indexOf(query) !== -1;
}
}

View File

@@ -167,26 +167,28 @@ export default {
if (app.session.user) {
if (this.canReply()) {
if (!app.composer.composingReplyTo(this) || forceRefresh) {
app.composer.load(ReplyComposer, {
let component = app.composer.component;
if (!app.composingReplyTo(this) || forceRefresh) {
component = new ReplyComposer({
user: app.session.user,
discussion: this,
});
app.composer.load(component);
}
app.composer.show();
if (goToLast && app.viewingDiscussion(this) && !app.composer.isFullScreen()) {
app.current.get('stream').goToNumber('reply');
app.current.stream.goToNumber('reply');
}
deferred.resolve(app.composer);
deferred.resolve(component);
} else {
deferred.reject();
}
} else {
deferred.reject();
app.modal.show(LogInModal);
app.modal.show(new LogInModal());
}
return deferred.promise;
@@ -227,7 +229,13 @@ export default {
app.history.back();
}
return this.delete().then(() => app.discussions.removeDiscussion(this));
return this.delete().then(() => {
// If there is a discussion list in the cache, remove this discussion.
if (app.cache.discussionList) {
app.cache.discussionList.removeDiscussion(this);
m.redraw();
}
});
}
},
@@ -237,9 +245,11 @@ export default {
* @return {Promise}
*/
renameAction() {
return app.modal.show(RenameDiscussionModal, {
currentTitle: this.title(),
discussion: this,
});
return app.modal.show(
new RenameDiscussionModal({
currentTitle: this.title(),
discussion: this,
})
);
},
};

View File

@@ -130,10 +130,12 @@ export default {
editAction() {
const deferred = m.deferred();
app.composer.load(EditPostComposer, { post: this });
const component = new EditPostComposer({ post: this });
app.composer.load(component);
app.composer.show();
deferred.resolve(app.composer);
deferred.resolve(component);
return deferred.promise;
},
@@ -179,7 +181,10 @@ export default {
// If this was the last post in the discussion, then we will assume that
// the whole discussion was deleted too.
if (!discussion.postIds().length) {
app.discussions.removeDiscussion(discussion);
// If there is a discussion list in the cache, remove this discussion.
if (app.cache.discussionList) {
app.cache.discussionList.removeDiscussion(discussion);
}
if (app.viewingDiscussion(discussion)) {
app.history.back();

View File

@@ -1,3 +1,4 @@
import Alert from '../../common/components/Alert';
import Button from '../../common/components/Button';
import Separator from '../../common/components/Separator';
import EditUserModal from '../components/EditUserModal';
@@ -111,7 +112,7 @@ export default {
.delete()
.then(() => {
this.showDeletionAlert(user, 'success');
if (app.current.matches(UserPage, { user })) {
if (app.current instanceof UserPage && app.current.user === user) {
app.history.back();
} else {
window.location.reload();
@@ -133,10 +134,12 @@ export default {
error: 'core.forum.user_controls.delete_error_message',
}[type];
app.alerts.show({
type,
children: app.translator.trans(message, { username, email }),
});
app.alerts.show(
new Alert({
type,
children: app.translator.trans(message, { username, email }),
})
);
},
/**
@@ -145,6 +148,6 @@ export default {
* @param {User} user
*/
editAction(user) {
app.modal.show(EditUserModal, { user });
app.modal.show(new EditUserModal({ user }));
},
};

View File

@@ -1,16 +1,12 @@
const config = require('flarum-webpack-config');
const webpack = require('webpack');
const merge = require('webpack-merge');
module.exports = merge(config(), {
output: {
library: 'flarum.core'
},
// temporary TS configuration
resolve: {
extensions: ['.ts', '.tsx', '.js', '.json'],
},
plugins: [
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)
]
});
module.exports['module'].rules[0].test = /\.(tsx?|js)$/;
module.exports['module'].rules[0].use.options.presets.push('@babel/preset-typescript');

View File

@@ -11,7 +11,7 @@
}
}
.DashboardWidget {
.Widget {
background: @body-bg;
color: @text-color;
border-radius: @border-radius;

View File

@@ -236,12 +236,16 @@
.App-header {
padding: 8px;
height: @header-height;
position: fixed;
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: @zindex-header;
.affix & {
position: fixed;
}
& when (@config-colored-header = true) {
.light-contents(@header-color, @header-control-bg, @header-control-color);
}

View File

@@ -1,14 +1,3 @@
// Store the current responsive screen mode in a CSS variable, to make it
// available to the JS code.
:root {
--flarum-screen: none;
@media @phone { --flarum-screen: phone }
@media @tablet { --flarum-screen: tablet }
@media @desktop { --flarum-screen: desktop }
@media @desktop-hd { --flarum-screen: desktop-hd }
}
* {
&,
&:before,

View File

@@ -1,6 +1,5 @@
.NotificationList {
overflow: hidden;
& .loading-indicator {
height: 100px;
}

View File

@@ -1,6 +1,7 @@
.NotificationsDropdown {
.Dropdown-menu {
padding: 0;
overflow: hidden;
.NotificationList-content {
max-height: 70vh;

View File

@@ -288,7 +288,6 @@
margin-top: -5px;
float: right;
position: relative;
z-index: 1;
.transition(opacity 0.2s);

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