mirror of
https://github.com/flarum/core.git
synced 2025-08-13 11:54:32 +02:00
Compare commits
86 Commits
ds/fronten
...
as/comment
Author | SHA1 | Date | |
---|---|---|---|
|
ed01f389a8 | ||
|
71e313e677 | ||
|
88366fe8af | ||
|
b82504b4b1 | ||
|
898d68d9f3 | ||
|
69f0172b92 | ||
|
62fe9db732 | ||
|
ed566cd18f | ||
|
5c1663d8f1 | ||
|
c5d3b058ba | ||
|
4a804dbbbc | ||
|
f4afb006ed | ||
|
646b35374d | ||
|
4fc06336df | ||
|
65f2d5fb75 | ||
|
5bca4fda9d | ||
|
b87c7189cc | ||
|
17c239388a | ||
|
4da2994d1f | ||
|
293e2251ca | ||
|
3b1f5ca07b | ||
|
d1750fecc0 | ||
|
63242edeb3 | ||
|
0aed3764c4 | ||
|
7b1269207e | ||
|
bab084a75f | ||
|
3c87f800dd | ||
|
26256c436f | ||
|
63397bb466 | ||
|
4b6864534b | ||
|
c4f4f218bf | ||
|
4866e7d9ba | ||
|
d6acf28fcb | ||
|
e627616750 | ||
|
bbd815a9ab | ||
|
acf4e9c80d | ||
|
1bb5f99a27 | ||
|
b0822df759 | ||
|
998e32c208 | ||
|
f89f114fad | ||
|
9b936d4baa | ||
|
7e661df15d | ||
|
b7355db2b7 | ||
|
5dc9451c21 | ||
|
220c8c66b0 | ||
|
484933db7d | ||
|
f6347dcc46 | ||
|
107b4be726 | ||
|
93d4192b54 | ||
|
ecdce44d55 | ||
|
a5e286e662 | ||
|
443949f7b9 | ||
|
4884aad2f0 | ||
|
365eb15d29 | ||
|
85e2623622 | ||
|
7d99727168 | ||
|
84784c9839 | ||
|
a9470b463f | ||
|
deb48bd173 | ||
|
b38bd60362 | ||
|
260e7cd48f | ||
|
41a56c4ad1 | ||
|
d0ae2839f0 | ||
|
d31a747631 | ||
|
526081bd06 | ||
|
cbdd3c5cc7 | ||
|
7d1ef9d891 | ||
|
7794546845 | ||
|
c43cc874ee | ||
|
33cf94c192 | ||
|
036e519865 | ||
|
9386c91af9 | ||
|
8306cef963 | ||
|
51ea326959 | ||
|
15bed971e6 | ||
|
c896cd8696 | ||
|
54ac83d0b6 | ||
|
1592cd1013 | ||
|
6e8884f190 | ||
|
df8f73bd3d | ||
|
3f0f89afb1 | ||
|
f0f301c5f4 | ||
|
3045bde167 | ||
|
ee7a4627d8 | ||
|
b9fb92d49a | ||
|
b5accca957 |
4
.github/workflows/lint.yml
vendored
4
.github/workflows/lint.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Lint code
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
prettier:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
name: Lint JS code with Prettier
|
||||
name: JS / Prettier
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
53
CHANGELOG.md
53
CHANGELOG.md
@@ -1,5 +1,58 @@
|
||||
# Changelog
|
||||
|
||||
## [0.1.0-beta.13](https://github.com/flarum/core/compare/v0.1.0-beta.12...v0.1.0-beta.13)
|
||||
|
||||
### Added
|
||||
- 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
|
||||
- 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 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)
|
||||
|
||||
### 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
|
||||
|
||||
## [0.1.0-beta.12](https://github.com/flarum/core/compare/v0.1.0-beta.11.1...v0.1.0-beta.12)
|
||||
|
||||
### Added
|
||||
|
@@ -38,24 +38,24 @@
|
||||
"php": ">=7.2",
|
||||
"axy/sourcemap": "^0.1.4",
|
||||
"components/font-awesome": "5.9.*",
|
||||
"dflydev/fig-cookies": "^1.0.2",
|
||||
"dflydev/fig-cookies": "^2.0.1",
|
||||
"doctrine/dbal": "^2.7",
|
||||
"franzl/whoops-middleware": "^0.4.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.*",
|
||||
"illuminate/bus": "5.8.*",
|
||||
"illuminate/cache": "5.8.*",
|
||||
"illuminate/config": "5.8.*",
|
||||
"illuminate/container": "5.8.*",
|
||||
"illuminate/contracts": "5.8.*",
|
||||
"illuminate/database": "5.8.*",
|
||||
"illuminate/events": "5.8.*",
|
||||
"illuminate/filesystem": "5.8.*",
|
||||
"illuminate/hashing": "5.8.*",
|
||||
"illuminate/mail": "5.8.*",
|
||||
"illuminate/queue": "5.8.*",
|
||||
"illuminate/session": "5.8.*",
|
||||
"illuminate/support": "5.8.*",
|
||||
"illuminate/validation": "5.8.*",
|
||||
"illuminate/view": "5.8.*",
|
||||
"intervention/image": "^2.5.0",
|
||||
"laminas/laminas-diactoros": "^1.8.4",
|
||||
"laminas/laminas-httphandlerrunner": "^1.0",
|
||||
|
4
js/dist/admin.js
vendored
4
js/dist/admin.js
vendored
File diff suppressed because one or more lines are too long
2
js/dist/admin.js.map
vendored
2
js/dist/admin.js.map
vendored
File diff suppressed because one or more lines are too long
8
js/dist/forum.js
vendored
8
js/dist/forum.js
vendored
File diff suppressed because one or more lines are too long
2
js/dist/forum.js.map
vendored
2
js/dist/forum.js.map
vendored
File diff suppressed because one or more lines are too long
828
js/package-lock.json
generated
828
js/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,8 +15,8 @@
|
||||
"moment": "^2.22.2",
|
||||
"punycode": "^2.1.1",
|
||||
"spin.js": "^3.1.0",
|
||||
"webpack": "^4.41.2",
|
||||
"webpack-cli": "^3.1.2",
|
||||
"webpack": "^4.43.0",
|
||||
"webpack-cli": "^3.3.11",
|
||||
"webpack-merge": "^4.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@@ -6,7 +6,6 @@ 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';
|
||||
@@ -15,7 +14,6 @@ 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';
|
||||
@@ -37,7 +35,6 @@ 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,
|
||||
@@ -46,7 +43,6 @@ 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,
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import Page from './Page';
|
||||
import Page from '../../common/components/Page';
|
||||
import Button from '../../common/components/Button';
|
||||
import Switch from '../../common/components/Switch';
|
||||
import EditCustomCssModal from './EditCustomCssModal';
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import Page from './Page';
|
||||
import Page from '../../common/components/Page';
|
||||
import FieldSet from '../../common/components/FieldSet';
|
||||
import Select from '../../common/components/Select';
|
||||
import Button from '../../common/components/Button';
|
||||
@@ -21,6 +21,7 @@ export default class BasicsPage extends Page {
|
||||
'default_route',
|
||||
'welcome_title',
|
||||
'welcome_message',
|
||||
'display_name_driver',
|
||||
];
|
||||
this.values = {};
|
||||
|
||||
@@ -33,6 +34,14 @@ 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);
|
||||
}
|
||||
|
||||
@@ -114,6 +123,20 @@ 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',
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import Page from './Page';
|
||||
import Page from '../../common/components/Page';
|
||||
import StatusWidget from './StatusWidget';
|
||||
|
||||
export default class DashboardPage extends Page {
|
||||
|
@@ -1,17 +1,8 @@
|
||||
/*
|
||||
* 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 Widget extends Component {
|
||||
export default class DashboardWidget extends Component {
|
||||
view() {
|
||||
return <div className={'Widget ' + this.className()}>{this.content()}</div>;
|
||||
return <div className={'DashboardWidget Widget ' + this.className()}>{this.content()}</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -3,6 +3,7 @@ import Button from '../../common/components/Button';
|
||||
import Badge from '../../common/components/Badge';
|
||||
import Group from '../../common/models/Group';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Switch from '../../common/components/Switch';
|
||||
|
||||
/**
|
||||
* The `EditGroupModal` component shows a modal dialog which allows the user
|
||||
@@ -16,6 +17,7 @@ export default class EditGroupModal extends Modal {
|
||||
this.namePlural = m.prop(this.group.namePlural() || '');
|
||||
this.icon = m.prop(this.group.icon() || '');
|
||||
this.color = m.prop(this.group.color() || '');
|
||||
this.isHidden = m.prop(this.group.isHidden() || false);
|
||||
}
|
||||
|
||||
className() {
|
||||
@@ -89,6 +91,18 @@ export default class EditGroupModal extends Modal {
|
||||
10
|
||||
);
|
||||
|
||||
items.add(
|
||||
'hidden',
|
||||
<div className="Form-group">
|
||||
{Switch.component({
|
||||
state: !!Number(this.isHidden()),
|
||||
children: app.translator.trans('core.admin.edit_group.hide_label'),
|
||||
onchange: this.isHidden,
|
||||
})}
|
||||
</div>,
|
||||
10
|
||||
);
|
||||
|
||||
items.add(
|
||||
'submit',
|
||||
<div className="Form-group">
|
||||
@@ -118,6 +132,7 @@ export default class EditGroupModal extends Modal {
|
||||
namePlural: this.namePlural(),
|
||||
color: this.color(),
|
||||
icon: this.icon(),
|
||||
isHidden: this.isHidden(),
|
||||
};
|
||||
}
|
||||
|
||||
|
@@ -1,13 +1,10 @@
|
||||
import Page from './Page';
|
||||
import LinkButton from '../../common/components/LinkButton';
|
||||
import Page from '../../common/components/Page';
|
||||
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() {
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import Page from './Page';
|
||||
import Page from '../../common/components/Page';
|
||||
import FieldSet from '../../common/components/FieldSet';
|
||||
import Button from '../../common/components/Button';
|
||||
import Alert from '../../common/components/Alert';
|
||||
@@ -11,6 +11,7 @@ export default class MailPage extends Page {
|
||||
super.init();
|
||||
|
||||
this.saving = false;
|
||||
this.sendingTest = false;
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
@@ -28,7 +29,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'];
|
||||
@@ -121,11 +122,27 @@ export default class MailPage extends Page {
|
||||
],
|
||||
})}
|
||||
|
||||
{Button.component({
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
children: app.translator.trans('core.admin.email.submit_button'),
|
||||
disabled: !this.changed(),
|
||||
<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(),
|
||||
}),
|
||||
],
|
||||
})}
|
||||
</form>
|
||||
</div>
|
||||
@@ -149,10 +166,34 @@ 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;
|
||||
app.alerts.show(
|
||||
(this.testEmailSuccessAlert = new Alert({ 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) return;
|
||||
if (this.saving || this.sendingTest) return;
|
||||
|
||||
this.saving = true;
|
||||
app.alerts.dismiss(this.successAlert);
|
||||
|
@@ -1,32 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
@@ -112,6 +112,16 @@ export default class PermissionGrid extends Component {
|
||||
100
|
||||
);
|
||||
|
||||
items.add(
|
||||
'viewHiddenGroups',
|
||||
{
|
||||
icon: 'fas fa-users',
|
||||
label: app.translator.trans('core.admin.permissions.view_hidden_groups_label'),
|
||||
permission: 'viewHiddenGroups',
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
items.add(
|
||||
'viewUserList',
|
||||
{
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import Page from './Page';
|
||||
import Page from '../../common/components/Page';
|
||||
import GroupBadge from '../../common/components/GroupBadge';
|
||||
import EditGroupModal from './EditGroupModal';
|
||||
import Group from '../../common/models/Group';
|
||||
|
@@ -1,34 +0,0 @@
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* (c) Toby Zerner <toby.zerner@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
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 [];
|
||||
}
|
||||
}
|
@@ -21,6 +21,7 @@ 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';
|
||||
|
||||
/**
|
||||
* The `App` class provides a container for an application, as well as various
|
||||
@@ -115,6 +116,28 @@ export default class Application {
|
||||
*/
|
||||
requestError = 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);
|
||||
|
||||
data;
|
||||
|
||||
title = '';
|
||||
@@ -324,12 +347,15 @@ 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 = new Alert({
|
||||
type: 'error',
|
||||
children,
|
||||
controls: isDebug && [
|
||||
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error)}>
|
||||
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error, formattedError)}>
|
||||
Debug
|
||||
</Button>,
|
||||
],
|
||||
@@ -338,6 +364,17 @@ export default class Application {
|
||||
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.alerts.show(error.alert);
|
||||
}
|
||||
|
||||
@@ -350,12 +387,13 @@ export default class Application {
|
||||
|
||||
/**
|
||||
* @param {RequestError} error
|
||||
* @param {string[]} [formattedError]
|
||||
* @private
|
||||
*/
|
||||
showDebug(error) {
|
||||
showDebug(error, formattedError) {
|
||||
this.alerts.dismiss(this.requestError.alert);
|
||||
|
||||
this.modal.show(new RequestErrorModal({ error }));
|
||||
this.modal.show(new RequestErrorModal({ error, formattedError }));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -30,6 +30,7 @@ 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';
|
||||
@@ -94,6 +95,7 @@ export default {
|
||||
Component: Component,
|
||||
Translator: Translator,
|
||||
'components/AlertManager': AlertManager,
|
||||
'components/Page': Page,
|
||||
'components/Switch': Switch,
|
||||
'components/Badge': Badge,
|
||||
'components/LoadingIndicator': LoadingIndicator,
|
||||
|
@@ -30,6 +30,6 @@ export default class Badge extends Component {
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
|
||||
if (this.props.label) this.$().tooltip({ container: 'body' });
|
||||
if (this.props.label) this.$().tooltip();
|
||||
}
|
||||
}
|
||||
|
@@ -10,23 +10,14 @@ 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() {
|
||||
let className = 'Checkbox ' + (this.props.state ? 'on' : 'off') + ' ' + (this.props.className || '');
|
||||
if (this.loading) className += ' loading';
|
||||
if (this.props.loading) className += ' loading';
|
||||
if (this.props.disabled) className += ' disabled';
|
||||
|
||||
return (
|
||||
@@ -45,7 +36,7 @@ export default class Checkbox extends Component {
|
||||
* @protected
|
||||
*/
|
||||
getDisplay() {
|
||||
return this.loading ? LoadingIndicator.component({ size: 'tiny' }) : icon(this.props.state ? 'fas fa-check' : 'fas fa-times');
|
||||
return this.props.loading ? LoadingIndicator.component({ size: 'tiny' }) : icon(this.props.state ? 'fas fa-check' : 'fas fa-times');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -43,8 +43,6 @@ export default class ModalManager extends Component {
|
||||
this.showing = true;
|
||||
this.component = component;
|
||||
|
||||
if (app.current) app.current.retain = true;
|
||||
|
||||
m.redraw(true);
|
||||
|
||||
const dismissible = !!this.component.isDismissible();
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import Component from '../../common/Component';
|
||||
import Component from '../Component';
|
||||
import PageState from '../states/PageState';
|
||||
|
||||
/**
|
||||
* The `Page` component
|
||||
@@ -8,7 +9,7 @@ import Component from '../../common/Component';
|
||||
export default class Page extends Component {
|
||||
init() {
|
||||
app.previous = app.current;
|
||||
app.current = this;
|
||||
app.current = new PageState(this.constructor);
|
||||
|
||||
app.drawer.hide();
|
||||
app.modal.close();
|
@@ -6,16 +6,26 @@ 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;
|
||||
|
||||
try {
|
||||
responseText = JSON.stringify(JSON.parse(this.props.error.responseText), null, 2);
|
||||
} catch (e) {
|
||||
responseText = this.props.error.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;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
@@ -12,6 +12,6 @@ export default class Switch extends Checkbox {
|
||||
}
|
||||
|
||||
getDisplay() {
|
||||
return this.loading ? super.getDisplay() : '';
|
||||
return this.props.loading ? super.getDisplay() : '';
|
||||
}
|
||||
}
|
||||
|
@@ -7,6 +7,7 @@ Object.assign(Group.prototype, {
|
||||
namePlural: Model.attribute('namePlural'),
|
||||
color: Model.attribute('color'),
|
||||
icon: Model.attribute('icon'),
|
||||
isHidden: Model.attribute('isHidden'),
|
||||
});
|
||||
|
||||
Group.ADMINISTRATOR_ID = '1';
|
||||
|
33
js/src/common/states/PageState.js
Normal file
33
js/src/common/states/PageState.js
Normal file
@@ -0,0 +1,33 @@
|
||||
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;
|
||||
}
|
||||
}
|
6
js/src/common/utils/subclassOf.js
Normal file
6
js/src/common/utils/subclassOf.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
@@ -14,6 +13,9 @@ 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 './state/DiscussionListState';
|
||||
|
||||
export default class ForumApplication extends Application {
|
||||
/**
|
||||
@@ -34,13 +36,6 @@ 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.
|
||||
*
|
||||
@@ -63,10 +58,38 @@ 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();
|
||||
|
||||
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({ forumApp: this });
|
||||
|
||||
/**
|
||||
* @deprecated beta 14, remove in beta 15.
|
||||
*/
|
||||
this.cache.discussionList = this.discussions;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,7 +160,7 @@ export default class ForumApplication extends Application {
|
||||
* @return {Boolean}
|
||||
*/
|
||||
viewingDiscussion(discussion) {
|
||||
return this.current instanceof DiscussionPage && this.current.discussion === discussion;
|
||||
return this.current.matches(DiscussionPage, { discussion });
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -23,7 +23,6 @@ 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';
|
||||
@@ -92,7 +91,6 @@ 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,
|
||||
|
@@ -31,13 +31,7 @@ export default class CommentPost extends Post {
|
||||
*/
|
||||
this.revealContent = 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.postUser.cardVisible,
|
||||
() => this.isEditing()
|
||||
);
|
||||
this.subtree.check(() => this.isEditing());
|
||||
}
|
||||
|
||||
content() {
|
||||
@@ -129,13 +123,12 @@ export default class CommentPost extends Post {
|
||||
headerItems() {
|
||||
const items = new ItemList();
|
||||
const post = this.props.post;
|
||||
const props = { post };
|
||||
|
||||
items.add('user', this.postUser.render(), 100);
|
||||
items.add('meta', PostMeta.component(props));
|
||||
items.add('user', PostUser.component({ post }), 100);
|
||||
items.add('meta', PostMeta.component({ post }));
|
||||
|
||||
if (post.isEdited() && !post.isHidden()) {
|
||||
items.add('edited', PostEdited.component(props));
|
||||
items.add('edited', PostEdited.component({ post }));
|
||||
}
|
||||
|
||||
// If the post is hidden, add a button that allows toggling the visibility
|
||||
|
@@ -98,7 +98,7 @@ export default class DiscussionComposer extends ComposerBody {
|
||||
.save(data)
|
||||
.then((discussion) => {
|
||||
app.composer.hide();
|
||||
app.cache.discussionList.refresh();
|
||||
app.discussions.refresh();
|
||||
m.route(app.route.discussion(discussion));
|
||||
}, this.loaded.bind(this));
|
||||
}
|
||||
|
@@ -11,56 +11,38 @@ import Placeholder from '../../common/components/Placeholder';
|
||||
*
|
||||
* - `params` A map of parameters used to construct a refined parameter object
|
||||
* to send along in the API request to get discussion results.
|
||||
* - `state` A DiscussionListState object that represents the discussion lists's state.
|
||||
*/
|
||||
export default class DiscussionList extends Component {
|
||||
init() {
|
||||
/**
|
||||
* 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();
|
||||
this.state = this.props.state;
|
||||
}
|
||||
|
||||
view() {
|
||||
const params = this.props.params;
|
||||
const state = this.state;
|
||||
|
||||
const params = state.getParams();
|
||||
let loading;
|
||||
|
||||
if (this.loading) {
|
||||
if (state.isLoading()) {
|
||||
loading = LoadingIndicator.component();
|
||||
} else if (this.moreResults) {
|
||||
} else if (state.moreResults) {
|
||||
loading = Button.component({
|
||||
children: app.translator.trans('core.forum.discussion_list.load_more_button'),
|
||||
className: 'Button',
|
||||
onclick: this.loadMore.bind(this),
|
||||
onclick: state.loadMore.bind(state),
|
||||
});
|
||||
}
|
||||
|
||||
if (this.discussions.length === 0 && !this.loading) {
|
||||
if (state.empty()) {
|
||||
const text = app.translator.trans('core.forum.discussion_list.empty_text');
|
||||
return <div className="DiscussionList">{Placeholder.component({ text })}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'DiscussionList' + (this.props.params.q ? ' DiscussionList--searchResults' : '')}>
|
||||
<div className={'DiscussionList' + (state.isSearchResults() ? ' DiscussionList--searchResults' : '')}>
|
||||
<ul className="DiscussionList-discussions">
|
||||
{this.discussions.map((discussion) => {
|
||||
{state.discussions.map((discussion) => {
|
||||
return (
|
||||
<li key={discussion.id()} data-id={discussion.id()}>
|
||||
{DiscussionListItem.component({ discussion, params })}
|
||||
@@ -72,140 +54,4 @@ 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);
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import Page from './Page';
|
||||
import Page from '../../common/components/Page';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import DiscussionHero from './DiscussionHero';
|
||||
import PostStream from './PostStream';
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
|
||||
/**
|
||||
* The `DiscussionPage` component displays a whole discussion page, including
|
||||
@@ -35,13 +36,13 @@ export default class DiscussionPage extends Page {
|
||||
// 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 which would be slow and would cause problems with
|
||||
// then the pane would redraw which would be slow and would cause problems with
|
||||
// event handlers.
|
||||
if (app.cache.discussionList) {
|
||||
if (app.discussions.hasDiscussions()) {
|
||||
app.pane.enable();
|
||||
app.pane.hide();
|
||||
|
||||
if (app.previous instanceof DiscussionPage) {
|
||||
if (app.previous.matches(DiscussionPage)) {
|
||||
m.redraw.strategy('diff');
|
||||
}
|
||||
}
|
||||
@@ -90,9 +91,9 @@ export default class DiscussionPage extends Page {
|
||||
|
||||
return (
|
||||
<div className="DiscussionPage">
|
||||
{app.cache.discussionList ? (
|
||||
{app.discussions.hasDiscussions() ? (
|
||||
<div className="DiscussionPage-list" config={this.configPane.bind(this)}>
|
||||
{!$('.App-navigation').is(':visible') ? app.cache.discussionList.render() : ''}
|
||||
{!$('.App-navigation').is(':visible') && <DiscussionList state={app.discussions} />}
|
||||
</div>
|
||||
) : (
|
||||
''
|
||||
@@ -199,6 +200,9 @@ export default class DiscussionPage extends Page {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -1,4 +1,6 @@
|
||||
import ComposerBody from './ComposerBody';
|
||||
import Alert from '../../common/components/Alert';
|
||||
import Button from '../../common/components/Button';
|
||||
import icon from '../../common/helpers/icon';
|
||||
|
||||
function minimizeComposerIfFullScreen(e) {
|
||||
@@ -75,10 +77,40 @@ export default class EditPostComposer extends ComposerBody {
|
||||
}
|
||||
|
||||
onsubmit() {
|
||||
const discussion = this.props.post.discussion();
|
||||
|
||||
this.loading = true;
|
||||
|
||||
const data = this.data();
|
||||
|
||||
this.props.post.save(data).then(() => app.composer.hide(), this.loaded.bind(this));
|
||||
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.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);
|
||||
},
|
||||
});
|
||||
app.alerts.show(
|
||||
(alert = new Alert({
|
||||
type: 'success',
|
||||
children: app.translator.trans('core.forum.composer_edit.edited_message'),
|
||||
controls: [viewButton],
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
app.composer.hide();
|
||||
}, this.loaded.bind(this));
|
||||
}
|
||||
}
|
||||
|
@@ -7,6 +7,7 @@ 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
|
||||
@@ -33,7 +34,7 @@ export default class HeaderSecondary extends Component {
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('search', app.search.render(), 30);
|
||||
items.add('search', Search.component({ state: app.search }), 30);
|
||||
|
||||
if (app.forum.attribute('showLanguageSelector') && Object.keys(app.data.locales).length > 1) {
|
||||
const locales = [];
|
||||
@@ -67,7 +68,7 @@ export default class HeaderSecondary extends Component {
|
||||
}
|
||||
|
||||
if (app.session.user) {
|
||||
items.add('notifications', NotificationsDropdown.component(), 10);
|
||||
items.add('notifications', NotificationsDropdown.component({ state: app.notifications }), 10);
|
||||
items.add('session', SessionDropdown.component(), 0);
|
||||
} else {
|
||||
if (app.forum.attribute('allowSignUp')) {
|
||||
|
@@ -1,8 +1,7 @@
|
||||
import { extend } from '../../common/extend';
|
||||
import Page from './Page';
|
||||
import Page from '../../common/components/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';
|
||||
@@ -18,42 +17,27 @@ 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 instanceof DiscussionPage) {
|
||||
this.lastDiscussion = app.previous.discussion;
|
||||
if (app.previous.matches(DiscussionPage)) {
|
||||
this.lastDiscussion = app.previous.get('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 instanceof IndexPage) {
|
||||
app.cache.discussionList = null;
|
||||
if (app.previous.matches(IndexPage)) {
|
||||
app.discussions.clear();
|
||||
}
|
||||
|
||||
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.discussions.refreshParams(app.search.params());
|
||||
|
||||
app.history.push('index', app.translator.trans('core.forum.header.back_to_index_tooltip'));
|
||||
|
||||
@@ -80,7 +64,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>
|
||||
{app.cache.discussionList.render()}
|
||||
<DiscussionList state={app.discussions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -187,7 +171,7 @@ export default class IndexPage extends Page {
|
||||
*/
|
||||
navItems() {
|
||||
const items = new ItemList();
|
||||
const params = this.stickyParams();
|
||||
const params = app.search.stickyParams();
|
||||
|
||||
items.add(
|
||||
'allDiscussions',
|
||||
@@ -211,7 +195,7 @@ export default class IndexPage extends Page {
|
||||
*/
|
||||
viewItems() {
|
||||
const items = new ItemList();
|
||||
const sortMap = app.cache.discussionList.sortMap();
|
||||
const sortMap = app.discussions.sortMap();
|
||||
|
||||
const sortOptions = {};
|
||||
for (const i in sortMap) {
|
||||
@@ -222,15 +206,15 @@ export default class IndexPage extends Page {
|
||||
'sort',
|
||||
Dropdown.component({
|
||||
buttonClassName: 'Button',
|
||||
label: sortOptions[this.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0],
|
||||
label: sortOptions[app.search.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0],
|
||||
children: Object.keys(sortOptions).map((value) => {
|
||||
const label = sortOptions[value];
|
||||
const active = (this.params().sort || Object.keys(sortMap)[0]) === value;
|
||||
const active = (app.search.params().sort || Object.keys(sortMap)[0]) === value;
|
||||
|
||||
return Button.component({
|
||||
children: label,
|
||||
icon: active ? 'fas fa-check' : true,
|
||||
onclick: this.changeSort.bind(this, value),
|
||||
onclick: app.search.changeSort.bind(app.search, value),
|
||||
active: active,
|
||||
});
|
||||
}),
|
||||
@@ -256,7 +240,7 @@ export default class IndexPage extends Page {
|
||||
icon: 'fas fa-sync',
|
||||
className: 'Button Button--icon',
|
||||
onclick: () => {
|
||||
app.cache.discussionList.refresh();
|
||||
app.discussions.refresh();
|
||||
if (app.session.user) {
|
||||
app.store.find('users', app.session.user.id());
|
||||
m.redraw();
|
||||
@@ -280,72 +264,6 @@ 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.
|
||||
*
|
||||
|
@@ -21,12 +21,11 @@ export default class NotificationGrid extends Component {
|
||||
this.methods = this.notificationMethods().toArray();
|
||||
|
||||
/**
|
||||
* A map of notification type-method combinations to the checkbox instances
|
||||
* that represent them.
|
||||
* A map of which notification checkboxes are loading.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
this.inputs = {};
|
||||
this.loading = {};
|
||||
|
||||
/**
|
||||
* Information about the available notification types.
|
||||
@@ -34,24 +33,11 @@ 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>
|
||||
@@ -71,9 +57,20 @@ 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) => (
|
||||
<td className="NotificationGrid-checkbox">{this.inputs[this.preferenceKey(type.name, method.name)].render()}</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>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -112,16 +109,14 @@ export default class NotificationGrid extends Component {
|
||||
const enabled = !preferences[keys[0]];
|
||||
|
||||
keys.forEach((key) => {
|
||||
const control = this.inputs[key];
|
||||
|
||||
control.loading = true;
|
||||
preferences[key] = control.props.state = enabled;
|
||||
this.loading[key] = true;
|
||||
preferences[key] = enabled;
|
||||
});
|
||||
|
||||
m.redraw();
|
||||
|
||||
user.save({ preferences }).then(() => {
|
||||
keys.forEach((key) => (this.inputs[key].loading = false));
|
||||
keys.forEach((key) => (this.loading[key] = false));
|
||||
|
||||
m.redraw();
|
||||
});
|
||||
@@ -133,7 +128,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) => !this.inputs[key].props.disabled);
|
||||
const keys = this.types.map((type) => this.preferenceKey(type.name, method)).filter((key) => key in this.props.user.preferences());
|
||||
|
||||
this.toggle(keys);
|
||||
}
|
||||
@@ -144,7 +139,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) => !this.inputs[key].props.disabled);
|
||||
const keys = this.methods.map((method) => this.preferenceKey(type, method.name)).filter((key) => key in this.props.user.preferences());
|
||||
|
||||
this.toggle(keys);
|
||||
}
|
||||
|
@@ -10,23 +10,11 @@ import Discussion from '../../common/models/Discussion';
|
||||
*/
|
||||
export default class NotificationList extends Component {
|
||||
init() {
|
||||
/**
|
||||
* 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;
|
||||
this.state = this.props.state;
|
||||
}
|
||||
|
||||
view() {
|
||||
const pages = app.cache.notifications || [];
|
||||
const pages = this.state.getNotificationPages();
|
||||
|
||||
return (
|
||||
<div className="NotificationList">
|
||||
@@ -36,7 +24,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.markAllAsRead.bind(this),
|
||||
onclick: this.state.markAllAsRead.bind(this.state),
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -97,7 +85,7 @@ export default class NotificationList extends Component {
|
||||
});
|
||||
})
|
||||
: ''}
|
||||
{this.loading ? (
|
||||
{this.state.isLoading() ? (
|
||||
<LoadingIndicator className="LoadingIndicator--block" />
|
||||
) : pages.length ? (
|
||||
''
|
||||
@@ -121,8 +109,8 @@ export default class NotificationList extends Component {
|
||||
const contentTop = $scrollParent === $notifications ? 0 : $notifications.offset().top;
|
||||
const contentHeight = $notifications[0].scrollHeight;
|
||||
|
||||
if (this.moreResults && !this.loading && scrollTop + viewportHeight >= contentTop + contentHeight) {
|
||||
this.loadMore();
|
||||
if (this.state.hasMoreResults() && !this.state.isLoading() && scrollTop + viewportHeight >= contentTop + contentHeight) {
|
||||
this.state.loadMore();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -132,77 +120,4 @@ 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -15,8 +15,6 @@ export default class NotificationsDropdown extends Dropdown {
|
||||
|
||||
init() {
|
||||
super.init();
|
||||
|
||||
this.list = new NotificationList();
|
||||
}
|
||||
|
||||
getButton() {
|
||||
@@ -44,7 +42,7 @@ export default class NotificationsDropdown extends Dropdown {
|
||||
getMenu() {
|
||||
return (
|
||||
<div className={'Dropdown-menu ' + this.props.menuClassName} onclick={this.menuClick.bind(this)}>
|
||||
{this.showing ? this.list.render() : ''}
|
||||
{this.showing ? NotificationList.component({ state: this.props.state }) : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +51,7 @@ export default class NotificationsDropdown extends Dropdown {
|
||||
if (app.drawer.isOpen()) {
|
||||
this.goToRoute();
|
||||
} else {
|
||||
this.list.load();
|
||||
this.props.state.load();
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import Page from './Page';
|
||||
import Page from '../../common/components/Page';
|
||||
import NotificationList from './NotificationList';
|
||||
|
||||
/**
|
||||
@@ -11,13 +11,16 @@ export default class NotificationsPage extends Page {
|
||||
|
||||
app.history.push('notifications');
|
||||
|
||||
this.list = new NotificationList();
|
||||
this.list.load();
|
||||
app.notifications.load();
|
||||
|
||||
this.bodyClass = 'App--notifications';
|
||||
}
|
||||
|
||||
view() {
|
||||
return <div className="NotificationsPage">{this.list.render()}</div>;
|
||||
return (
|
||||
<div className="NotificationsPage">
|
||||
<NotificationList state={app.notifications}></NotificationList>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -116,6 +116,7 @@ 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');
|
||||
@@ -125,7 +126,7 @@ export default class Post extends Component {
|
||||
classes.push('Post--by-actor');
|
||||
}
|
||||
|
||||
if (user && app.current.discussion && app.current.discussion.attribute('startUserId') == user.id()) {
|
||||
if (user && user === discussion.user()) {
|
||||
classes.push('Post--by-start-user');
|
||||
}
|
||||
|
||||
|
@@ -13,15 +13,6 @@ 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();
|
||||
@@ -38,7 +29,7 @@ export default class PostUser extends Component {
|
||||
|
||||
let card = '';
|
||||
|
||||
if (!post.isHidden() && this.cardVisible) {
|
||||
if (!post.isHidden()) {
|
||||
card = UserCard.component({
|
||||
user,
|
||||
className: 'UserCard--popover',
|
||||
@@ -81,10 +72,6 @@ export default class PostUser extends Component {
|
||||
* Show the user card.
|
||||
*/
|
||||
showCard() {
|
||||
this.cardVisible = true;
|
||||
|
||||
m.redraw();
|
||||
|
||||
setTimeout(() => this.$('.UserCard').addClass('in'));
|
||||
}
|
||||
|
||||
@@ -92,11 +79,6 @@ export default class PostUser extends Component {
|
||||
* Hide the user card.
|
||||
*/
|
||||
hideCard() {
|
||||
this.$('.UserCard')
|
||||
.removeClass('in')
|
||||
.one('transitionend webkitTransitionEnd oTransitionEnd', () => {
|
||||
this.cardVisible = false;
|
||||
m.redraw();
|
||||
});
|
||||
this.$('.UserCard').removeClass('in');
|
||||
}
|
||||
}
|
||||
|
@@ -57,7 +57,7 @@ export default class RenameDiscussionModal extends Modal {
|
||||
.save({ title })
|
||||
.then(() => {
|
||||
if (app.viewingDiscussion(this.discussion)) {
|
||||
app.current.stream.update();
|
||||
app.current.get('stream').update();
|
||||
}
|
||||
m.redraw();
|
||||
this.hide();
|
||||
|
@@ -89,7 +89,8 @@ 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)) {
|
||||
app.current.stream.update().then(() => app.current.stream.goToNumber(post.number()));
|
||||
const stream = app.current.get('stream');
|
||||
stream.update().then(() => 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
|
||||
|
@@ -12,19 +12,17 @@ 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 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.
|
||||
* The search box will be 'activated' if the app's seach 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: AlertState instance.
|
||||
*/
|
||||
export default class Search extends Component {
|
||||
init() {
|
||||
/**
|
||||
* The value of the search input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.value = m.prop('');
|
||||
this.state = this.props.state;
|
||||
|
||||
/**
|
||||
* Whether or not the search input has focus.
|
||||
@@ -47,13 +45,6 @@ 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
|
||||
@@ -66,13 +57,7 @@ export default class Search extends Component {
|
||||
}
|
||||
|
||||
view() {
|
||||
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 || '');
|
||||
}
|
||||
const currentSearch = this.state.getInitialSearch();
|
||||
|
||||
// Initialize search sources in the view rather than the constructor so
|
||||
// that we have access to app.forum.
|
||||
@@ -88,7 +73,7 @@ export default class Search extends Component {
|
||||
className={
|
||||
'Search ' +
|
||||
classList({
|
||||
open: this.value() && this.hasFocus,
|
||||
open: this.state.getValue() && this.hasFocus,
|
||||
focused: this.hasFocus,
|
||||
active: !!currentSearch,
|
||||
loading: !!this.loadingSources,
|
||||
@@ -100,8 +85,8 @@ export default class Search extends Component {
|
||||
className="FormControl"
|
||||
type="search"
|
||||
placeholder={extractText(app.translator.trans('core.forum.header.search_placeholder'))}
|
||||
value={this.value()}
|
||||
oninput={m.withAttr('value', this.value)}
|
||||
value={this.state.getValue()}
|
||||
oninput={m.withAttr('value', this.state.setValue.bind(this.state))}
|
||||
onfocus={() => (this.hasFocus = true)}
|
||||
onblur={() => (this.hasFocus = false)}
|
||||
/>
|
||||
@@ -116,7 +101,7 @@ export default class Search extends Component {
|
||||
)}
|
||||
</div>
|
||||
<ul className="Dropdown-menu Search-results">
|
||||
{this.value() && this.hasFocus ? this.sources.map((source) => source.view(this.value())) : ''}
|
||||
{this.state.getValue() && this.hasFocus ? this.sources.map((source) => source.view(this.state.getValue())) : ''}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
@@ -129,6 +114,7 @@ export default class Search extends Component {
|
||||
if (isInitialized) return;
|
||||
|
||||
const search = this;
|
||||
const state = this.state;
|
||||
|
||||
this.$('.Search-results')
|
||||
.on('mousedown', (e) => e.preventDefault())
|
||||
@@ -158,7 +144,7 @@ export default class Search extends Component {
|
||||
|
||||
clearTimeout(search.searchTimeout);
|
||||
search.searchTimeout = setTimeout(() => {
|
||||
if (search.searched.indexOf(query) !== -1) return;
|
||||
if (state.isCached(query)) return;
|
||||
|
||||
if (query.length >= 3) {
|
||||
search.sources.map((source) => {
|
||||
@@ -173,7 +159,7 @@ export default class Search extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
search.searched.push(query);
|
||||
state.cache(query);
|
||||
m.redraw();
|
||||
}, 250);
|
||||
})
|
||||
@@ -185,15 +171,6 @@ 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.
|
||||
*/
|
||||
@@ -201,7 +178,7 @@ export default class Search extends Component {
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.loadingSources = 0;
|
||||
|
||||
if (this.value()) {
|
||||
if (this.state.getValue()) {
|
||||
m.route(this.getItem(this.index).find('a').attr('href'));
|
||||
} else {
|
||||
this.clear();
|
||||
@@ -211,16 +188,10 @@ export default class Search extends Component {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the search input and the current controller's active search.
|
||||
* Clear the search
|
||||
*/
|
||||
clear() {
|
||||
this.value('');
|
||||
|
||||
if (this.getCurrentSearch()) {
|
||||
app.current.clearSearch();
|
||||
} else {
|
||||
m.redraw();
|
||||
}
|
||||
this.state.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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 instance
|
||||
* (app.search) by extending the `sourceItems` method. When the user types a
|
||||
* Search sources should be registered with the `Search` component class
|
||||
* 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.
|
||||
|
@@ -109,6 +109,8 @@ 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
|
||||
@@ -116,11 +118,11 @@ export default class SettingsPage extends UserPage {
|
||||
*/
|
||||
preferenceSaver(key) {
|
||||
return (value, component) => {
|
||||
if (component) component.loading = true;
|
||||
if (component) component.props.loading = true;
|
||||
m.redraw();
|
||||
|
||||
this.user.savePreferences({ [key]: value }).then(() => {
|
||||
if (component) component.loading = false;
|
||||
if (component) component.props.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
};
|
||||
@@ -139,10 +141,15 @@ 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, component) => {
|
||||
this.user.pushAttributes({ lastSeenAt: null });
|
||||
this.preferenceSaver('discloseOnline')(value, component);
|
||||
onchange: (value) => {
|
||||
this.discloseOnlineLoading = true;
|
||||
|
||||
this.user.savePreferences({ discloseOnline: value }).then(() => {
|
||||
this.discloseOnlineLoading = false;
|
||||
m.redraw();
|
||||
});
|
||||
},
|
||||
loading: this.discloseOnlineLoading,
|
||||
})
|
||||
);
|
||||
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import Page from './Page';
|
||||
import Page from '../../common/components/Page';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import affixSidebar from '../utils/affixSidebar';
|
||||
import UserCard from './UserCard';
|
||||
@@ -71,6 +71,8 @@ export default class UserPage extends Page {
|
||||
show(user) {
|
||||
this.user = user;
|
||||
|
||||
app.current.set('user', user);
|
||||
|
||||
app.setTitle(user.displayName());
|
||||
|
||||
m.redraw();
|
||||
|
190
js/src/forum/state/DiscussionListState.js
Normal file
190
js/src/forum/state/DiscussionListState.js
Normal file
@@ -0,0 +1,190 @@
|
||||
export default class DiscussionListState {
|
||||
constructor({ params = {}, forumApp = app } = {}) {
|
||||
this.params = params;
|
||||
|
||||
this.app = forumApp;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
95
js/src/forum/states/GlobalSearchState.js
Normal file
95
js/src/forum/states/GlobalSearchState.js
Normal file
@@ -0,0 +1,95 @@
|
||||
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));
|
||||
}
|
||||
}
|
94
js/src/forum/states/NotificationListState.js
Normal file
94
js/src/forum/states/NotificationListState.js
Normal file
@@ -0,0 +1,94 @@
|
||||
export default class NotificationListState {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
|
||||
this.notificationPages = [];
|
||||
|
||||
this.loading = false;
|
||||
|
||||
this.moreResults = false;
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
35
js/src/forum/states/SearchState.js
Normal file
35
js/src/forum/states/SearchState.js
Normal file
@@ -0,0 +1,35 @@
|
||||
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;
|
||||
}
|
||||
}
|
@@ -178,7 +178,7 @@ export default {
|
||||
app.composer.show();
|
||||
|
||||
if (goToLast && app.viewingDiscussion(this) && !app.composer.isFullScreen()) {
|
||||
app.current.stream.goToNumber('reply');
|
||||
app.current.get('stream').goToNumber('reply');
|
||||
}
|
||||
|
||||
deferred.resolve(component);
|
||||
@@ -229,13 +229,7 @@ export default {
|
||||
app.history.back();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
return this.delete().then(() => app.discussions.removeDiscussion(this));
|
||||
}
|
||||
},
|
||||
|
||||
|
@@ -2,6 +2,7 @@ import EditPostComposer from '../components/EditPostComposer';
|
||||
import Button from '../../common/components/Button';
|
||||
import Separator from '../../common/components/Separator';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
|
||||
/**
|
||||
* The `PostControls` utility constructs a list of buttons for a post which
|
||||
@@ -145,6 +146,7 @@ export default {
|
||||
* @return {Promise}
|
||||
*/
|
||||
hideAction() {
|
||||
if (!confirm(extractText(app.translator.trans('core.forum.post_controls.hide_confirmation')))) return;
|
||||
this.pushAttributes({ hiddenAt: new Date(), hiddenUser: app.session.user });
|
||||
|
||||
return this.save({ isHidden: true }).then(() => m.redraw());
|
||||
@@ -167,6 +169,7 @@ export default {
|
||||
* @return {Promise}
|
||||
*/
|
||||
deleteAction(context) {
|
||||
if (!confirm(extractText(app.translator.trans('core.forum.post_controls.delete_confirmation')))) return;
|
||||
if (context) context.loading = true;
|
||||
|
||||
return this.delete()
|
||||
@@ -178,10 +181,7 @@ 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) {
|
||||
// If there is a discussion list in the cache, remove this discussion.
|
||||
if (app.cache.discussionList) {
|
||||
app.cache.discussionList.removeDiscussion(discussion);
|
||||
}
|
||||
app.discussions.removeDiscussion(discussion);
|
||||
|
||||
if (app.viewingDiscussion(discussion)) {
|
||||
app.history.back();
|
||||
|
@@ -112,7 +112,7 @@ export default {
|
||||
.delete()
|
||||
.then(() => {
|
||||
this.showDeletionAlert(user, 'success');
|
||||
if (app.current instanceof UserPage && app.current.user === user) {
|
||||
if (app.current.matches(UserPage, { user })) {
|
||||
app.history.back();
|
||||
} else {
|
||||
window.location.reload();
|
||||
|
@@ -11,7 +11,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.Widget {
|
||||
.DashboardWidget {
|
||||
background: @body-bg;
|
||||
color: @text-color;
|
||||
border-radius: @border-radius;
|
||||
|
@@ -236,16 +236,12 @@
|
||||
.App-header {
|
||||
padding: 8px;
|
||||
height: @header-height;
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
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);
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
|
||||
.NotificationList {
|
||||
overflow: hidden;
|
||||
& .loading-indicator {
|
||||
height: 100px;
|
||||
}
|
||||
|
@@ -1,7 +1,6 @@
|
||||
.NotificationsDropdown {
|
||||
.Dropdown-menu {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.NotificationList-content {
|
||||
max-height: 70vh;
|
||||
|
@@ -288,6 +288,7 @@
|
||||
margin-top: -5px;
|
||||
float: right;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
.transition(opacity 0.2s);
|
||||
|
||||
|
@@ -7,11 +7,8 @@
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Event;
|
||||
use Flarum\Database\Migration;
|
||||
|
||||
/**
|
||||
* @deprecated Will be removed in Beta.14. Use Flarum\Extend\Routes instead.
|
||||
*/
|
||||
class ConfigureApiRoutes extends AbstractConfigureRoutes
|
||||
{
|
||||
}
|
||||
return Migration::addColumns('groups', [
|
||||
'is_hidden' => ['boolean', 'default' => false]
|
||||
]);
|
@@ -12,7 +12,6 @@ namespace Flarum\Admin;
|
||||
use Flarum\Extension\Event\Disabled;
|
||||
use Flarum\Extension\Event\Enabled;
|
||||
use Flarum\Foundation\AbstractServiceProvider;
|
||||
use Flarum\Foundation\Application;
|
||||
use Flarum\Foundation\ErrorHandling\Registry;
|
||||
use Flarum\Foundation\ErrorHandling\Reporter;
|
||||
use Flarum\Foundation\ErrorHandling\ViewFormatter;
|
||||
@@ -50,6 +49,7 @@ class AdminServiceProvider extends AbstractServiceProvider
|
||||
|
||||
$this->app->singleton('flarum.admin.middleware', function () {
|
||||
return [
|
||||
'flarum.admin.error_handler',
|
||||
HttpMiddleware\ParseJsonBody::class,
|
||||
HttpMiddleware\StartSession::class,
|
||||
HttpMiddleware\RememberFromCookie::class,
|
||||
@@ -60,15 +60,16 @@ class AdminServiceProvider extends AbstractServiceProvider
|
||||
];
|
||||
});
|
||||
|
||||
$this->app->singleton('flarum.admin.handler', function (Application $app) {
|
||||
$pipe = new MiddlewarePipe;
|
||||
$this->app->bind('flarum.admin.error_handler', function () {
|
||||
return new HttpMiddleware\HandleErrors(
|
||||
$this->app->make(Registry::class),
|
||||
$this->app['flarum']->inDebugMode() ? $this->app->make(WhoopsFormatter::class) : $this->app->make(ViewFormatter::class),
|
||||
$this->app->tagged(Reporter::class)
|
||||
);
|
||||
});
|
||||
|
||||
// All requests should first be piped through our global error handler
|
||||
$pipe->pipe(new HttpMiddleware\HandleErrors(
|
||||
$app->make(Registry::class),
|
||||
$app->inDebugMode() ? $app->make(WhoopsFormatter::class) : $app->make(ViewFormatter::class),
|
||||
$app->tagged(Reporter::class)
|
||||
));
|
||||
$this->app->singleton('flarum.admin.handler', function () {
|
||||
$pipe = new MiddlewarePipe;
|
||||
|
||||
foreach ($this->app->make('flarum.admin.middleware') as $middleware) {
|
||||
$pipe->pipe($this->app->make($middleware));
|
||||
|
@@ -14,12 +14,18 @@ use Flarum\Frontend\Document;
|
||||
use Flarum\Group\Permission;
|
||||
use Flarum\Settings\Event\Deserializing;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
|
||||
class AdminPayload
|
||||
{
|
||||
/**
|
||||
* @var Container;
|
||||
*/
|
||||
protected $container;
|
||||
|
||||
/**
|
||||
* @var SettingsRepositoryInterface
|
||||
*/
|
||||
@@ -36,13 +42,20 @@ class AdminPayload
|
||||
protected $db;
|
||||
|
||||
/**
|
||||
* @param Container $container
|
||||
* @param SettingsRepositoryInterface $settings
|
||||
* @param ExtensionManager $extensions
|
||||
* @param ConnectionInterface $db
|
||||
* @param Dispatcher $events
|
||||
*/
|
||||
public function __construct(SettingsRepositoryInterface $settings, ExtensionManager $extensions, ConnectionInterface $db, Dispatcher $events)
|
||||
{
|
||||
public function __construct(
|
||||
Container $container,
|
||||
SettingsRepositoryInterface $settings,
|
||||
ExtensionManager $extensions,
|
||||
ConnectionInterface $db,
|
||||
Dispatcher $events
|
||||
) {
|
||||
$this->container = $container;
|
||||
$this->settings = $settings;
|
||||
$this->extensions = $extensions;
|
||||
$this->db = $db;
|
||||
@@ -61,6 +74,8 @@ class AdminPayload
|
||||
$document->payload['permissions'] = Permission::map();
|
||||
$document->payload['extensions'] = $this->extensions->getExtensions()->toArray();
|
||||
|
||||
$document->payload['displayNameDrivers'] = array_keys($this->container->make('flarum.user.display_name.supported_drivers'));
|
||||
|
||||
$document->payload['phpVersion'] = PHP_VERSION;
|
||||
$document->payload['mysqlVersion'] = $this->db->selectOne('select version() as version')->version;
|
||||
}
|
||||
|
@@ -13,10 +13,8 @@ use Flarum\Api\Controller\AbstractSerializeController;
|
||||
use Flarum\Api\Serializer\AbstractSerializer;
|
||||
use Flarum\Api\Serializer\BasicDiscussionSerializer;
|
||||
use Flarum\Api\Serializer\NotificationSerializer;
|
||||
use Flarum\Event\ConfigureApiRoutes;
|
||||
use Flarum\Event\ConfigureNotificationTypes;
|
||||
use Flarum\Foundation\AbstractServiceProvider;
|
||||
use Flarum\Foundation\Application;
|
||||
use Flarum\Foundation\ErrorHandling\JsonApiFormatter;
|
||||
use Flarum\Foundation\ErrorHandling\Registry;
|
||||
use Flarum\Foundation\ErrorHandling\Reporter;
|
||||
@@ -46,6 +44,7 @@ class ApiServiceProvider extends AbstractServiceProvider
|
||||
|
||||
$this->app->singleton('flarum.api.middleware', function () {
|
||||
return [
|
||||
'flarum.api.error_handler',
|
||||
HttpMiddleware\ParseJsonBody::class,
|
||||
Middleware\FakeHttpMethods::class,
|
||||
HttpMiddleware\StartSession::class,
|
||||
@@ -57,14 +56,16 @@ class ApiServiceProvider extends AbstractServiceProvider
|
||||
];
|
||||
});
|
||||
|
||||
$this->app->singleton('flarum.api.handler', function (Application $app) {
|
||||
$pipe = new MiddlewarePipe;
|
||||
$this->app->bind('flarum.api.error_handler', function () {
|
||||
return new HttpMiddleware\HandleErrors(
|
||||
$this->app->make(Registry::class),
|
||||
new JsonApiFormatter($this->app['flarum']->inDebugMode()),
|
||||
$this->app->tagged(Reporter::class)
|
||||
);
|
||||
});
|
||||
|
||||
$pipe->pipe(new HttpMiddleware\HandleErrors(
|
||||
$app->make(Registry::class),
|
||||
new JsonApiFormatter($app->inDebugMode()),
|
||||
$app->tagged(Reporter::class)
|
||||
));
|
||||
$this->app->singleton('flarum.api.handler', function () {
|
||||
$pipe = new MiddlewarePipe;
|
||||
|
||||
foreach ($this->app->make('flarum.api.middleware') as $middleware) {
|
||||
$pipe->pipe($this->app->make($middleware));
|
||||
@@ -120,9 +121,5 @@ class ApiServiceProvider extends AbstractServiceProvider
|
||||
|
||||
$callback = include __DIR__.'/routes.php';
|
||||
$callback($routes, $factory);
|
||||
|
||||
$this->app->make('events')->dispatch(
|
||||
new ConfigureApiRoutes($routes, $factory)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -26,6 +26,8 @@ class ListGroupsController extends AbstractListController
|
||||
*/
|
||||
protected function data(ServerRequestInterface $request, Document $document)
|
||||
{
|
||||
return Group::all();
|
||||
$actor = $request->getAttribute('actor');
|
||||
|
||||
return Group::whereVisibleTo($actor)->get();
|
||||
}
|
||||
}
|
||||
|
53
src/Api/Controller/SendTestMailController.php
Normal file
53
src/Api/Controller/SendTestMailController.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\User\AssertPermissionTrait;
|
||||
use Illuminate\Container\Container;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Mail\Message;
|
||||
use Laminas\Diactoros\Response\EmptyResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Symfony\Component\Translation\TranslatorInterface;
|
||||
|
||||
class SendTestMailController implements RequestHandlerInterface
|
||||
{
|
||||
use AssertPermissionTrait;
|
||||
|
||||
protected $container;
|
||||
|
||||
protected $mailer;
|
||||
|
||||
protected $translator;
|
||||
|
||||
public function __construct(Container $container, Mailer $mailer, TranslatorInterface $translator)
|
||||
{
|
||||
$this->container = $container;
|
||||
$this->mailer = $mailer;
|
||||
$this->translator = $translator;
|
||||
}
|
||||
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$actor = $request->getAttribute('actor');
|
||||
$this->assertAdmin($actor);
|
||||
|
||||
$body = $this->translator->trans('core.email.send_test.body', ['{username}' => $actor->username]);
|
||||
|
||||
$this->mailer->raw($body, function (Message $message) use ($actor) {
|
||||
$message->to($actor->email);
|
||||
$message->subject($this->translator->trans('core.email.send_test.subject'));
|
||||
});
|
||||
|
||||
return new EmptyResponse();
|
||||
}
|
||||
}
|
@@ -12,7 +12,7 @@ namespace Flarum\Api\Controller;
|
||||
use Flarum\Api\Serializer\CurrentUserSerializer;
|
||||
use Flarum\Api\Serializer\UserSerializer;
|
||||
use Flarum\User\Command\EditUser;
|
||||
use Flarum\User\Exception\PermissionDeniedException;
|
||||
use Flarum\User\Exception\NotAuthenticatedException;
|
||||
use Illuminate\Contracts\Bus\Dispatcher;
|
||||
use Illuminate\Support\Arr;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
@@ -62,7 +62,7 @@ class UpdateUserController extends AbstractShowController
|
||||
$password = Arr::get($request->getParsedBody(), 'meta.password');
|
||||
|
||||
if (! $actor->checkPassword($password)) {
|
||||
throw new PermissionDeniedException;
|
||||
throw new NotAuthenticatedException;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -45,6 +45,10 @@ class BasicUserSerializer extends AbstractSerializer
|
||||
*/
|
||||
protected function groups($user)
|
||||
{
|
||||
return $this->hasMany($user, GroupSerializer::class);
|
||||
if ($this->getActor()->can('viewHiddenGroups')) {
|
||||
return $this->hasMany($user, GroupSerializer::class);
|
||||
}
|
||||
|
||||
return $this->hasMany($user, GroupSerializer::class, 'visibleGroups');
|
||||
}
|
||||
}
|
||||
|
@@ -10,40 +10,24 @@
|
||||
namespace Flarum\Api\Serializer;
|
||||
|
||||
use Flarum\Discussion\Discussion;
|
||||
use Flarum\User\Gate;
|
||||
|
||||
class DiscussionSerializer extends BasicDiscussionSerializer
|
||||
{
|
||||
/**
|
||||
* @var \Flarum\User\Gate
|
||||
*/
|
||||
protected $gate;
|
||||
|
||||
/**
|
||||
* @param \Flarum\User\Gate $gate
|
||||
*/
|
||||
public function __construct(Gate $gate)
|
||||
{
|
||||
$this->gate = $gate;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function getDefaultAttributes($discussion)
|
||||
{
|
||||
$gate = $this->gate->forUser($this->actor);
|
||||
|
||||
$attributes = parent::getDefaultAttributes($discussion) + [
|
||||
'commentCount' => (int) $discussion->comment_count,
|
||||
'participantCount' => (int) $discussion->participant_count,
|
||||
'createdAt' => $this->formatDate($discussion->created_at),
|
||||
'lastPostedAt' => $this->formatDate($discussion->last_posted_at),
|
||||
'lastPostNumber' => (int) $discussion->last_post_number,
|
||||
'canReply' => $gate->allows('reply', $discussion),
|
||||
'canRename' => $gate->allows('rename', $discussion),
|
||||
'canDelete' => $gate->allows('delete', $discussion),
|
||||
'canHide' => $gate->allows('hide', $discussion)
|
||||
'canReply' => $this->actor->can('reply', $discussion),
|
||||
'canRename' => $this->actor->can('rename', $discussion),
|
||||
'canDelete' => $this->actor->can('delete', $discussion),
|
||||
'canHide' => $this->actor->can('hide', $discussion)
|
||||
];
|
||||
|
||||
if ($discussion->hidden_at) {
|
||||
|
@@ -85,7 +85,7 @@ class ForumSerializer extends AbstractSerializer
|
||||
|
||||
if ($this->actor->can('administrate')) {
|
||||
$attributes['adminUrl'] = $this->url->to('admin')->base();
|
||||
$attributes['version'] = $this->app->version();
|
||||
$attributes['version'] = Application::VERSION;
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
|
@@ -52,6 +52,7 @@ class GroupSerializer extends AbstractSerializer
|
||||
'namePlural' => $this->translateGroupName($group->name_plural),
|
||||
'color' => $group->color,
|
||||
'icon' => $group->icon,
|
||||
'isHidden' => $group->is_hidden
|
||||
];
|
||||
}
|
||||
|
||||
|
@@ -10,23 +10,9 @@
|
||||
namespace Flarum\Api\Serializer;
|
||||
|
||||
use Flarum\Post\CommentPost;
|
||||
use Flarum\User\Gate;
|
||||
|
||||
class PostSerializer extends BasicPostSerializer
|
||||
{
|
||||
/**
|
||||
* @var \Flarum\User\Gate
|
||||
*/
|
||||
protected $gate;
|
||||
|
||||
/**
|
||||
* @param \Flarum\User\Gate $gate
|
||||
*/
|
||||
public function __construct(Gate $gate)
|
||||
{
|
||||
$this->gate = $gate;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
@@ -36,15 +22,13 @@ class PostSerializer extends BasicPostSerializer
|
||||
|
||||
unset($attributes['content']);
|
||||
|
||||
$gate = $this->gate->forUser($this->actor);
|
||||
|
||||
$canEdit = $gate->allows('edit', $post);
|
||||
$canEdit = $this->actor->can('edit', $post);
|
||||
|
||||
if ($post instanceof CommentPost) {
|
||||
if ($canEdit) {
|
||||
$attributes['content'] = $post->content;
|
||||
}
|
||||
if ($gate->allows('viewIps', $post)) {
|
||||
if ($this->actor->can('viewIps', $post)) {
|
||||
$attributes['ipAddress'] = $post->ip_address;
|
||||
}
|
||||
} else {
|
||||
@@ -62,8 +46,8 @@ class PostSerializer extends BasicPostSerializer
|
||||
|
||||
$attributes += [
|
||||
'canEdit' => $canEdit,
|
||||
'canDelete' => $gate->allows('delete', $post),
|
||||
'canHide' => $gate->allows('hide', $post)
|
||||
'canDelete' => $this->actor->can('delete', $post),
|
||||
'canHide' => $this->actor->can('hide', $post)
|
||||
];
|
||||
|
||||
return $attributes;
|
||||
|
@@ -9,23 +9,8 @@
|
||||
|
||||
namespace Flarum\Api\Serializer;
|
||||
|
||||
use Flarum\User\Gate;
|
||||
|
||||
class UserSerializer extends BasicUserSerializer
|
||||
{
|
||||
/**
|
||||
* @var \Flarum\User\Gate
|
||||
*/
|
||||
protected $gate;
|
||||
|
||||
/**
|
||||
* @param Gate $gate
|
||||
*/
|
||||
public function __construct(Gate $gate)
|
||||
{
|
||||
$this->gate = $gate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Flarum\User\User $user
|
||||
* @return array
|
||||
@@ -34,16 +19,14 @@ class UserSerializer extends BasicUserSerializer
|
||||
{
|
||||
$attributes = parent::getDefaultAttributes($user);
|
||||
|
||||
$gate = $this->gate->forUser($this->actor);
|
||||
|
||||
$canEdit = $gate->allows('edit', $user);
|
||||
$canEdit = $this->actor->can('edit', $user);
|
||||
|
||||
$attributes += [
|
||||
'joinTime' => $this->formatDate($user->joined_at),
|
||||
'discussionCount' => (int) $user->discussion_count,
|
||||
'commentCount' => (int) $user->comment_count,
|
||||
'canEdit' => $canEdit,
|
||||
'canDelete' => $gate->allows('delete', $user),
|
||||
'canDelete' => $this->actor->can('delete', $user),
|
||||
];
|
||||
|
||||
if ($user->getPreference('discloseOnline') || $this->actor->can('viewLastSeenAt', $user)) {
|
||||
|
@@ -309,8 +309,15 @@ return function (RouteCollection $map, RouteHandlerFactory $route) {
|
||||
|
||||
// List available mail drivers, available fields and validation status
|
||||
$map->get(
|
||||
'/mail-settings',
|
||||
'/mail/settings',
|
||||
'mailSettings.index',
|
||||
$route->toController(Controller\ShowMailSettingsController::class)
|
||||
);
|
||||
|
||||
// Send test mail post
|
||||
$map->post(
|
||||
'/mail/test',
|
||||
'mailTest',
|
||||
$route->toController(Controller\SendTestMailController::class)
|
||||
);
|
||||
};
|
||||
|
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Console\Event;
|
||||
|
||||
use Flarum\Foundation\Application;
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Console\Application as ConsoleApplication;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
class Configuring
|
||||
{
|
||||
/**
|
||||
* @var Application
|
||||
*/
|
||||
public $app;
|
||||
|
||||
/**
|
||||
* @var ConsoleApplication
|
||||
*/
|
||||
public $console;
|
||||
|
||||
/**
|
||||
* @param Application $app
|
||||
* @param ConsoleApplication $console
|
||||
*/
|
||||
public function __construct(Application $app, ConsoleApplication $console)
|
||||
{
|
||||
$this->app = $app;
|
||||
$this->console = $console;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a console command to the flarum binary.
|
||||
*
|
||||
* @param Command|string $command
|
||||
*/
|
||||
public function addCommand($command)
|
||||
{
|
||||
if (is_string($command)) {
|
||||
$command = $this->app->make($command);
|
||||
}
|
||||
|
||||
if ($command instanceof Command) {
|
||||
$command->setLaravel($this->app);
|
||||
}
|
||||
|
||||
$this->console->add($command);
|
||||
}
|
||||
}
|
@@ -9,13 +9,11 @@
|
||||
|
||||
namespace Flarum\Console;
|
||||
|
||||
use Flarum\Console\Event\Configuring;
|
||||
use Flarum\Foundation\Application;
|
||||
use Flarum\Foundation\ErrorHandling\Registry;
|
||||
use Flarum\Foundation\ErrorHandling\Reporter;
|
||||
use Flarum\Foundation\SiteInterface;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Symfony\Component\Console\Application as ConsoleApplication;
|
||||
use Illuminate\Container\Container;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\ConsoleEvents;
|
||||
use Symfony\Component\Console\Event\ConsoleErrorEvent;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||
@@ -33,43 +31,30 @@ class Server
|
||||
{
|
||||
$app = $this->site->bootApp();
|
||||
|
||||
$console = new ConsoleApplication('Flarum', Application::VERSION);
|
||||
$console = new Application('Flarum', \Flarum\Foundation\Application::VERSION);
|
||||
|
||||
foreach ($app->getConsoleCommands() as $command) {
|
||||
$console->add($command);
|
||||
}
|
||||
|
||||
$this->extend($console); // deprecated
|
||||
$this->handleErrors($console);
|
||||
|
||||
exit($console->run());
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
private function extend(ConsoleApplication $console)
|
||||
{
|
||||
$app = Application::getInstance();
|
||||
|
||||
$this->handleErrors($app, $console);
|
||||
|
||||
$events = $app->make(Dispatcher::class);
|
||||
|
||||
$events->dispatch(new Configuring($app, $console));
|
||||
}
|
||||
|
||||
private function handleErrors(Application $app, ConsoleApplication $console)
|
||||
private function handleErrors(Application $console)
|
||||
{
|
||||
$dispatcher = new EventDispatcher();
|
||||
|
||||
$dispatcher->addListener(ConsoleEvents::ERROR, function (ConsoleErrorEvent $event) use ($app) {
|
||||
/** @var Registry $registry */
|
||||
$registry = $app->make(Registry::class);
|
||||
$dispatcher->addListener(ConsoleEvents::ERROR, function (ConsoleErrorEvent $event) {
|
||||
$container = Container::getInstance();
|
||||
|
||||
/** @var Registry $registry */
|
||||
$registry = $container->make(Registry::class);
|
||||
$error = $registry->handle($event->getError());
|
||||
|
||||
/** @var Reporter[] $reporters */
|
||||
$reporters = $app->tagged(Reporter::class);
|
||||
$reporters = $container->tagged(Reporter::class);
|
||||
|
||||
if ($error->shouldBeReported()) {
|
||||
foreach ($reporters as $reporter) {
|
||||
|
@@ -9,11 +9,9 @@
|
||||
|
||||
namespace Flarum\Database;
|
||||
|
||||
use Flarum\Event\ConfigureModelDates;
|
||||
use Flarum\Event\ConfigureModelDefaultAttributes;
|
||||
use Flarum\Event\GetModelRelationship;
|
||||
use Illuminate\Database\Eloquent\Model as Eloquent;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Arr;
|
||||
use LogicException;
|
||||
|
||||
/**
|
||||
@@ -46,6 +44,12 @@ abstract class AbstractModel extends Eloquent
|
||||
*/
|
||||
protected $afterDeleteCallbacks = [];
|
||||
|
||||
public static $customRelations = [];
|
||||
|
||||
public static $dateAttributes = [];
|
||||
|
||||
public static $defaults = [];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
@@ -71,13 +75,15 @@ abstract class AbstractModel extends Eloquent
|
||||
*/
|
||||
public function __construct(array $attributes = [])
|
||||
{
|
||||
$defaults = [];
|
||||
$this->attributes = [];
|
||||
|
||||
static::$dispatcher->dispatch(
|
||||
new ConfigureModelDefaultAttributes($this, $defaults)
|
||||
);
|
||||
foreach (array_merge(array_reverse(class_parents($this)), [static::class]) as $class) {
|
||||
$this->attributes = array_merge($this->attributes, Arr::get(static::$defaults, $class, []));
|
||||
}
|
||||
|
||||
$this->attributes = $defaults;
|
||||
$this->attributes = array_map(function ($item) {
|
||||
return is_callable($item) ? $item() : $item;
|
||||
}, $this->attributes);
|
||||
|
||||
parent::__construct($attributes);
|
||||
}
|
||||
@@ -89,19 +95,13 @@ abstract class AbstractModel extends Eloquent
|
||||
*/
|
||||
public function getDates()
|
||||
{
|
||||
static $dates = [];
|
||||
$dates = $this->dates;
|
||||
|
||||
$class = get_class($this);
|
||||
|
||||
if (! isset($dates[$class])) {
|
||||
static::$dispatcher->dispatch(
|
||||
new ConfigureModelDates($this, $this->dates)
|
||||
);
|
||||
|
||||
$dates[$class] = $this->dates;
|
||||
foreach (array_merge(array_reverse(class_parents($this)), [static::class]) as $class) {
|
||||
$dates = array_merge($dates, Arr::get(static::$dateAttributes, $class, []));
|
||||
}
|
||||
|
||||
return $dates[$class];
|
||||
return $dates;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,9 +139,12 @@ abstract class AbstractModel extends Eloquent
|
||||
*/
|
||||
protected function getCustomRelation($name)
|
||||
{
|
||||
return static::$dispatcher->until(
|
||||
new GetModelRelationship($this, $name)
|
||||
);
|
||||
foreach (array_merge([static::class], class_parents($this)) as $class) {
|
||||
$relation = Arr::get(static::$customRelations, $class.".$name", null);
|
||||
if (! is_null($relation)) {
|
||||
return $relation($this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -13,23 +13,32 @@ use Flarum\Console\AbstractCommand;
|
||||
use Flarum\Database\Migrator;
|
||||
use Flarum\Extension\ExtensionManager;
|
||||
use Flarum\Foundation\Application;
|
||||
use Flarum\Foundation\Paths;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\Schema\Builder;
|
||||
|
||||
class MigrateCommand extends AbstractCommand
|
||||
{
|
||||
/**
|
||||
* @var Application
|
||||
* @var Container
|
||||
*/
|
||||
protected $app;
|
||||
protected $container;
|
||||
|
||||
/**
|
||||
* @param Application $application
|
||||
* @var Paths
|
||||
*/
|
||||
public function __construct(Application $application)
|
||||
protected $paths;
|
||||
|
||||
/**
|
||||
* @param Container $container
|
||||
* @param Paths $paths
|
||||
*/
|
||||
public function __construct(Container $container, Paths $paths)
|
||||
{
|
||||
$this->app = $application;
|
||||
$this->container = $container;
|
||||
$this->paths = $paths;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -58,16 +67,16 @@ class MigrateCommand extends AbstractCommand
|
||||
|
||||
public function upgrade()
|
||||
{
|
||||
$this->app->bind(Builder::class, function ($app) {
|
||||
return $app->make(ConnectionInterface::class)->getSchemaBuilder();
|
||||
$this->container->bind(Builder::class, function ($container) {
|
||||
return $container->make(ConnectionInterface::class)->getSchemaBuilder();
|
||||
});
|
||||
|
||||
$migrator = $this->app->make(Migrator::class);
|
||||
$migrator = $this->container->make(Migrator::class);
|
||||
$migrator->setOutput($this->output);
|
||||
|
||||
$migrator->run(__DIR__.'/../../../migrations');
|
||||
|
||||
$extensions = $this->app->make(ExtensionManager::class);
|
||||
$extensions = $this->container->make(ExtensionManager::class);
|
||||
$extensions->getMigrator()->setOutput($this->output);
|
||||
|
||||
foreach ($extensions->getEnabledExtensions() as $name => $extension) {
|
||||
@@ -78,13 +87,13 @@ class MigrateCommand extends AbstractCommand
|
||||
}
|
||||
}
|
||||
|
||||
$this->app->make(SettingsRepositoryInterface::class)->set('version', $this->app->version());
|
||||
$this->container->make(SettingsRepositoryInterface::class)->set('version', Application::VERSION);
|
||||
|
||||
$this->info('Publishing assets...');
|
||||
|
||||
$this->app->make('files')->copyDirectory(
|
||||
$this->app->vendorPath().'/components/font-awesome/webfonts',
|
||||
$this->app->publicPath().'/assets/fonts'
|
||||
$this->container->make('files')->copyDirectory(
|
||||
$this->paths->vendor.'/components/font-awesome/webfonts',
|
||||
$this->paths->public.'/assets/fonts'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -24,7 +24,7 @@ class DatabaseServiceProvider extends AbstractServiceProvider
|
||||
$this->app->singleton(Manager::class, function ($app) {
|
||||
$manager = new Manager($app);
|
||||
|
||||
$config = $app->config('database');
|
||||
$config = $this->app['flarum']->config('database');
|
||||
$config['engine'] = 'InnoDB';
|
||||
$config['prefix_indexes'] = true;
|
||||
|
||||
@@ -54,6 +54,10 @@ class DatabaseServiceProvider extends AbstractServiceProvider
|
||||
|
||||
$this->app->alias(ConnectionInterface::class, 'db.connection');
|
||||
$this->app->alias(ConnectionInterface::class, 'flarum.db');
|
||||
|
||||
$this->app->singleton(MigrationRepositoryInterface::class, function ($app) {
|
||||
return new DatabaseMigrationRepository($app['flarum.db'], 'migrations');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -9,7 +9,7 @@
|
||||
|
||||
namespace Flarum\Database;
|
||||
|
||||
use Flarum\Extension\Extension;
|
||||
use Flarum\Foundation\Paths;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
|
||||
class MigrationCreator
|
||||
@@ -22,27 +22,27 @@ class MigrationCreator
|
||||
protected $files;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
* @var Paths
|
||||
*/
|
||||
protected $publicPath;
|
||||
protected $paths;
|
||||
|
||||
/**
|
||||
* Create a new migrator instance.
|
||||
*
|
||||
* @param Filesystem $files
|
||||
* @param string $publicPath
|
||||
* @param Paths $paths
|
||||
*/
|
||||
public function __construct(Filesystem $files, $publicPath)
|
||||
public function __construct(Filesystem $files, Paths $paths)
|
||||
{
|
||||
$this->files = $files;
|
||||
$this->publicPath = $publicPath;
|
||||
$this->paths = $paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new migration for the given extension.
|
||||
*
|
||||
* @param string $name
|
||||
* @param Extension $extension
|
||||
* @param string $extension
|
||||
* @param string $table
|
||||
* @param bool $create
|
||||
* @return string
|
||||
@@ -105,9 +105,11 @@ class MigrationCreator
|
||||
*/
|
||||
protected function getMigrationPath($extension)
|
||||
{
|
||||
$parent = $extension ? public_path('extensions/'.$extension) : __DIR__.'/../..';
|
||||
|
||||
return $parent.'/migrations';
|
||||
if ($extension) {
|
||||
return $this->paths->vendor.'/'.$extension.'/migrations';
|
||||
} else {
|
||||
return __DIR__.'/../../migrations';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Database;
|
||||
|
||||
use Flarum\Foundation\AbstractServiceProvider;
|
||||
use Flarum\Foundation\Application;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
|
||||
class MigrationServiceProvider extends AbstractServiceProvider
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$this->app->singleton(MigrationRepositoryInterface::class, function ($app) {
|
||||
return new DatabaseMigrationRepository($app['flarum.db'], 'migrations');
|
||||
});
|
||||
|
||||
$this->app->bind(MigrationCreator::class, function (Application $app) {
|
||||
return new MigrationCreator($app->make(Filesystem::class), $app->basePath());
|
||||
});
|
||||
}
|
||||
}
|
@@ -12,7 +12,6 @@ namespace Flarum\Discussion;
|
||||
use Flarum\Event\ScopeModelVisibility;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Flarum\User\AbstractPolicy;
|
||||
use Flarum\User\Gate;
|
||||
use Flarum\User\User;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -29,11 +28,6 @@ class DiscussionPolicy extends AbstractPolicy
|
||||
*/
|
||||
protected $settings;
|
||||
|
||||
/**
|
||||
* @var Gate
|
||||
*/
|
||||
protected $gate;
|
||||
|
||||
/**
|
||||
* @var Dispatcher
|
||||
*/
|
||||
@@ -41,13 +35,11 @@ class DiscussionPolicy extends AbstractPolicy
|
||||
|
||||
/**
|
||||
* @param SettingsRepositoryInterface $settings
|
||||
* @param Gate $gate
|
||||
* @param Dispatcher $events
|
||||
*/
|
||||
public function __construct(SettingsRepositoryInterface $settings, Gate $gate, Dispatcher $events)
|
||||
public function __construct(SettingsRepositoryInterface $settings, Dispatcher $events)
|
||||
{
|
||||
$this->settings = $settings;
|
||||
$this->gate = $gate;
|
||||
$this->events = $events;
|
||||
}
|
||||
|
||||
|
@@ -1,90 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Event;
|
||||
|
||||
use Flarum\Http\RouteCollection;
|
||||
use Flarum\Http\RouteHandlerFactory;
|
||||
|
||||
/**
|
||||
* @deprecated Will be removed in Beta.14.
|
||||
*/
|
||||
abstract class AbstractConfigureRoutes
|
||||
{
|
||||
/**
|
||||
* @var RouteCollection
|
||||
*/
|
||||
public $routes;
|
||||
|
||||
/**
|
||||
* @var RouteHandlerFactory
|
||||
*/
|
||||
protected $route;
|
||||
|
||||
/**
|
||||
* @param RouteCollection $routes
|
||||
* @param \Flarum\Http\RouteHandlerFactory $route
|
||||
*/
|
||||
public function __construct(RouteCollection $routes, RouteHandlerFactory $route)
|
||||
{
|
||||
$this->routes = $routes;
|
||||
$this->route = $route;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $url
|
||||
* @param string $name
|
||||
* @param string $controller
|
||||
*/
|
||||
public function get($url, $name, $controller)
|
||||
{
|
||||
$this->route('get', $url, $name, $controller);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $url
|
||||
* @param string $name
|
||||
* @param string $controller
|
||||
*/
|
||||
public function post($url, $name, $controller)
|
||||
{
|
||||
$this->route('post', $url, $name, $controller);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $url
|
||||
* @param string $name
|
||||
* @param string $controller
|
||||
*/
|
||||
public function patch($url, $name, $controller)
|
||||
{
|
||||
$this->route('patch', $url, $name, $controller);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $url
|
||||
* @param string $name
|
||||
* @param string $controller
|
||||
*/
|
||||
public function delete($url, $name, $controller)
|
||||
{
|
||||
$this->route('delete', $url, $name, $controller);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $method
|
||||
* @param string $url
|
||||
* @param string $name
|
||||
* @param string $controller
|
||||
*/
|
||||
protected function route($method, $url, $name, $controller)
|
||||
{
|
||||
$this->routes->$method($url, $name, $this->route->toController($controller));
|
||||
}
|
||||
}
|
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Event;
|
||||
|
||||
use Flarum\Forum\Controller\FrontendController;
|
||||
|
||||
/**
|
||||
* @deprecated Will be removed in Beta.14. Use Flarum\Extend\Routes or Flarum\Extend\Frontend instead.
|
||||
*/
|
||||
class ConfigureForumRoutes extends AbstractConfigureRoutes
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function get($url, $name, $handler = FrontendController::class)
|
||||
{
|
||||
parent::get($url, $name, $handler);
|
||||
}
|
||||
}
|
@@ -1,79 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Event;
|
||||
|
||||
use DirectoryIterator;
|
||||
use Flarum\Locale\LocaleManager;
|
||||
use Illuminate\Support\Arr;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* @deprecated Will be removed in Beta.14. Use Flarum\Extend\LanguagePack instead.
|
||||
*/
|
||||
class ConfigureLocales
|
||||
{
|
||||
/**
|
||||
* @var LocaleManager
|
||||
*/
|
||||
public $locales;
|
||||
|
||||
/**
|
||||
* @param LocaleManager $locales
|
||||
*/
|
||||
public function __construct(LocaleManager $locales)
|
||||
{
|
||||
$this->locales = $locales;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load language pack resources from the given directory.
|
||||
*
|
||||
* @param string $directory
|
||||
*/
|
||||
public function loadLanguagePackFrom($directory)
|
||||
{
|
||||
$name = $title = basename($directory);
|
||||
|
||||
if (file_exists($manifest = $directory.'/composer.json')) {
|
||||
$json = json_decode(file_get_contents($manifest), true);
|
||||
|
||||
if (empty($json)) {
|
||||
throw new RuntimeException("Error parsing composer.json in $name: ".json_last_error_msg());
|
||||
}
|
||||
|
||||
$locale = Arr::get($json, 'extra.flarum-locale.code');
|
||||
$title = Arr::get($json, 'extra.flarum-locale.title', $title);
|
||||
}
|
||||
|
||||
if (! isset($locale)) {
|
||||
throw new RuntimeException("Language pack $name must define \"extra.flarum-locale.code\" in composer.json.");
|
||||
}
|
||||
|
||||
$this->locales->addLocale($locale, $title);
|
||||
|
||||
if (! is_dir($localeDir = $directory.'/locale')) {
|
||||
throw new RuntimeException("Language pack $name must have a \"locale\" subdirectory.");
|
||||
}
|
||||
|
||||
if (file_exists($file = $localeDir.'/config.js')) {
|
||||
$this->locales->addJsFile($locale, $file);
|
||||
}
|
||||
|
||||
if (file_exists($file = $localeDir.'/config.css')) {
|
||||
$this->locales->addCssFile($locale, $file);
|
||||
}
|
||||
|
||||
foreach (new DirectoryIterator($localeDir) as $file) {
|
||||
if ($file->isFile() && in_array($file->getExtension(), ['yml', 'yaml'])) {
|
||||
$this->locales->addTranslations($locale, $file->getPathname());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Event;
|
||||
|
||||
use Flarum\Database\AbstractModel;
|
||||
|
||||
/**
|
||||
* The `ConfigureModelDates` event is called to retrieve a list of fields for a model
|
||||
* that should be converted into date objects.
|
||||
*/
|
||||
class ConfigureModelDates
|
||||
{
|
||||
/**
|
||||
* @var AbstractModel
|
||||
*/
|
||||
public $model;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
public $dates;
|
||||
|
||||
/**
|
||||
* @param AbstractModel $model
|
||||
* @param array $dates
|
||||
*/
|
||||
public function __construct(AbstractModel $model, array &$dates)
|
||||
{
|
||||
$this->model = $model;
|
||||
$this->dates = &$dates;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $model
|
||||
* @return bool
|
||||
*/
|
||||
public function isModel($model)
|
||||
{
|
||||
return $this->model instanceof $model;
|
||||
}
|
||||
}
|
@@ -1,44 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Event;
|
||||
|
||||
use Flarum\Database\AbstractModel;
|
||||
|
||||
class ConfigureModelDefaultAttributes
|
||||
{
|
||||
/**
|
||||
* @var AbstractModel
|
||||
*/
|
||||
public $model;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
public $attributes;
|
||||
|
||||
/**
|
||||
* @param AbstractModel $model
|
||||
* @param array $attributes
|
||||
*/
|
||||
public function __construct(AbstractModel $model, array &$attributes)
|
||||
{
|
||||
$this->model = $model;
|
||||
$this->attributes = &$attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $model
|
||||
* @return bool
|
||||
*/
|
||||
public function isModel($model)
|
||||
{
|
||||
return $this->model instanceof $model;
|
||||
}
|
||||
}
|
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Event;
|
||||
|
||||
use Flarum\Database\AbstractModel;
|
||||
|
||||
/**
|
||||
* The `GetModelRelationship` event is called to retrieve Relation object for a
|
||||
* model. Listeners should return an Eloquent Relation object.
|
||||
*/
|
||||
class GetModelRelationship
|
||||
{
|
||||
/**
|
||||
* @var AbstractModel
|
||||
*/
|
||||
public $model;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $relationship;
|
||||
|
||||
/**
|
||||
* @param AbstractModel $model
|
||||
* @param string $relationship
|
||||
*/
|
||||
public function __construct(AbstractModel $model, $relationship)
|
||||
{
|
||||
$this->model = $model;
|
||||
$this->relationship = $relationship;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $model
|
||||
* @param string $relationship
|
||||
* @return bool
|
||||
*/
|
||||
public function isRelationship($model, $relationship)
|
||||
{
|
||||
return $this->model instanceof $model && $this->relationship === $relationship;
|
||||
}
|
||||
}
|
@@ -11,13 +11,20 @@ namespace Flarum\Extend;
|
||||
|
||||
use DirectoryIterator;
|
||||
use Flarum\Extension\Extension;
|
||||
use Flarum\Extension\ExtensionManager;
|
||||
use Flarum\Locale\LocaleManager;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use SplFileInfo;
|
||||
|
||||
class LanguagePack implements ExtenderInterface, LifecycleInterface
|
||||
{
|
||||
private const CORE_LOCALE_FILES = [
|
||||
'core',
|
||||
'validation',
|
||||
];
|
||||
|
||||
private $path;
|
||||
|
||||
/**
|
||||
@@ -49,13 +56,13 @@ class LanguagePack implements ExtenderInterface, LifecycleInterface
|
||||
|
||||
$container->resolving(
|
||||
LocaleManager::class,
|
||||
function (LocaleManager $locales) use ($extension, $locale, $title) {
|
||||
$this->registerLocale($locales, $extension, $locale, $title);
|
||||
function (LocaleManager $locales, Container $container) use ($extension, $locale, $title) {
|
||||
$this->registerLocale($container, $locales, $extension, $locale, $title);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private function registerLocale(LocaleManager $locales, Extension $extension, $locale, $title)
|
||||
private function registerLocale(Container $container, LocaleManager $locales, Extension $extension, $locale, $title)
|
||||
{
|
||||
$locales->addLocale($locale, $title);
|
||||
|
||||
@@ -76,12 +83,41 @@ class LanguagePack implements ExtenderInterface, LifecycleInterface
|
||||
}
|
||||
|
||||
foreach (new DirectoryIterator($directory) as $file) {
|
||||
if ($file->isFile() && in_array($file->getExtension(), ['yml', 'yaml'])) {
|
||||
if ($this->shouldLoad($file, $container)) {
|
||||
$locales->addTranslations($locale, $file->getPathname());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldLoad(SplFileInfo $file, Container $container)
|
||||
{
|
||||
if (! $file->isFile()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We are only interested in YAML files
|
||||
if (! in_array($file->getExtension(), ['yml', 'yaml'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Some language packs include translations for many extensions
|
||||
// from the ecosystems. For performance reasons, we should only
|
||||
// load those that belong to core, or extensions that are enabled.
|
||||
// To identify them, we compare the filename (without the YAML
|
||||
// extension) with the list of known names and all extension IDs.
|
||||
$slug = $file->getBasename(".{$file->getExtension()}");
|
||||
|
||||
if (in_array($slug, self::CORE_LOCALE_FILES, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @var ExtensionManager $extensions */
|
||||
static $extensions;
|
||||
$extensions = $extensions ?? $container->make(ExtensionManager::class);
|
||||
|
||||
return $extensions->isEnabled($slug);
|
||||
}
|
||||
|
||||
public function onEnable(Container $container, Extension $extension)
|
||||
{
|
||||
$container->make('flarum.locales')->clearCache();
|
||||
|
182
src/Extend/Model.php
Normal file
182
src/Extend/Model.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Extend;
|
||||
|
||||
use Flarum\Database\AbstractModel;
|
||||
use Flarum\Extension\Extension;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class Model implements ExtenderInterface
|
||||
{
|
||||
private $modelClass;
|
||||
|
||||
/**
|
||||
* @param string $modelClass The ::class attribute of the model you are modifying.
|
||||
* This model should extend from \Flarum\Database\AbstractModel.
|
||||
*/
|
||||
public function __construct(string $modelClass)
|
||||
{
|
||||
$this->modelClass = $modelClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an attribute to be treated as a date.
|
||||
*
|
||||
* @param string $attribute
|
||||
* @return self
|
||||
*/
|
||||
public function dateAttribute(string $attribute)
|
||||
{
|
||||
Arr::set(
|
||||
AbstractModel::$dateAttributes,
|
||||
$this->modelClass,
|
||||
array_merge(
|
||||
Arr::get(AbstractModel::$dateAttributes, $this->modelClass, []),
|
||||
[$attribute]
|
||||
)
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a default value for a given attribute, which can be an explicit value, or a closure.
|
||||
*
|
||||
* @param string $attribute
|
||||
* @param mixed $value
|
||||
* @return self
|
||||
*/
|
||||
public function default(string $attribute, $value)
|
||||
{
|
||||
Arr::set(AbstractModel::$defaults, "$this->modelClass.$attribute", $value);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish a simple belongsTo relationship from this model to another model.
|
||||
* This represents an inverse one-to-one or inverse one-to-many relationship.
|
||||
* For more complex relationships, use the ->relationship method.
|
||||
*
|
||||
* @param string $name: The name of the relation. This doesn't have to be anything in particular,
|
||||
* but has to be unique from other relation names for this model, and should
|
||||
* work as the name of a method.
|
||||
* @param string $related: The ::class attribute of the model, which should extend \Flarum\Database\AbstractModel.
|
||||
* @param string $foreignKey: The foreign key attribute of the parent model.
|
||||
* @param string $ownerKey: The primary key attribute of the parent model.
|
||||
* @return self
|
||||
*/
|
||||
public function belongsTo(string $name, string $related, string $foreignKey = null, string $ownerKey = null)
|
||||
{
|
||||
return $this->relationship($name, function (AbstractModel $model) use ($related, $foreignKey, $ownerKey, $name) {
|
||||
return $model->belongsTo($related, $foreignKey, $ownerKey, $name);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish a simple belongsToMany relationship from this model to another model.
|
||||
* This represents a many-to-many relationship.
|
||||
* For more complex relationships, use the ->relationship method.
|
||||
*
|
||||
* @param string $name: The name of the relation. This doesn't have to be anything in particular,
|
||||
* but has to be unique from other relation names for this model, and should
|
||||
* work as the name of a method.
|
||||
* @param string $related: The ::class attribute of the model, which should extend \Flarum\Database\AbstractModel.
|
||||
* @param string $table: The intermediate table for this relation
|
||||
* @param string $foreignPivotKey: The foreign key attribute of the parent model.
|
||||
* @param string $relatedPivotKey: The associated key attribute of the relation.
|
||||
* @param string $parentKey: The key name of the parent model.
|
||||
* @param string $relatedKey: The key name of the related model.
|
||||
* @return self
|
||||
*/
|
||||
public function belongsToMany(
|
||||
string $name,
|
||||
string $related,
|
||||
string $table = null,
|
||||
string $foreignPivotKey = null,
|
||||
string $relatedPivotKey = null,
|
||||
string $parentKey = null,
|
||||
string $relatedKey = null
|
||||
) {
|
||||
return $this->relationship($name, function (AbstractModel $model) use ($related, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $name) {
|
||||
return $model->belongsToMany($related, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $name);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish a simple hasOne relationship from this model to another model.
|
||||
* This represents a one-to-one relationship.
|
||||
* For more complex relationships, use the ->relationship method.
|
||||
*
|
||||
* @param string $name: The name of the relation. This doesn't have to be anything in particular,
|
||||
* but has to be unique from other relation names for this model, and should
|
||||
* work as the name of a method.
|
||||
* @param string $related: The ::class attribute of the model, which should extend \Flarum\Database\AbstractModel.
|
||||
* @param string $foreignKey: The foreign key attribute of the parent model.
|
||||
* @param string $localKey: The primary key attribute of the parent model.
|
||||
* @return self
|
||||
*/
|
||||
public function hasOne(string $name, string $related, string $foreignKey = null, string $localKey = null)
|
||||
{
|
||||
return $this->relationship($name, function (AbstractModel $model) use ($related, $foreignKey, $localKey) {
|
||||
return $model->hasOne($related, $foreignKey, $localKey);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish a simple hasMany relationship from this model to another model.
|
||||
* This represents a one-to-many relationship.
|
||||
* For more complex relationships, use the ->relationship method.
|
||||
*
|
||||
* @param string $name: The name of the relation. This doesn't have to be anything in particular,
|
||||
* but has to be unique from other relation names for this model, and should
|
||||
* work as the name of a method.
|
||||
* @param string $related: The ::class attribute of the model, which should extend \Flarum\Database\AbstractModel.
|
||||
* @param string $foreignKey: The foreign key attribute of the parent model.
|
||||
* @param string $localKey: The primary key attribute of the parent model.
|
||||
* @return self
|
||||
*/
|
||||
public function hasMany(string $name, string $related, string $foreignKey = null, string $localKey = null)
|
||||
{
|
||||
return $this->relationship($name, function (AbstractModel $model) use ($related, $foreignKey, $localKey) {
|
||||
return $model->hasMany($related, $foreignKey, $localKey);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a relationship from this model to another model.
|
||||
*
|
||||
* @param string $name: The name of the relation. This doesn't have to be anything in particular,
|
||||
* but has to be unique from other relation names for this model, and should
|
||||
* work as the name of a method.
|
||||
* @param callable $callable
|
||||
*
|
||||
* The callable can be a closure or invokable class, and should accept:
|
||||
* - $instance: An instance of this model.
|
||||
*
|
||||
* The callable should return:
|
||||
* - $relationship: A Laravel Relationship object. See relevant methods of models
|
||||
* like \Flarum\User\User for examples of how relationships should be returned.
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function relationship(string $name, callable $callable)
|
||||
{
|
||||
Arr::set(AbstractModel::$customRelations, "$this->modelClass.$name", $callable);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function extend(Container $container, Extension $extension = null)
|
||||
{
|
||||
// Nothing needed here.
|
||||
}
|
||||
}
|
38
src/Extend/User.php
Normal file
38
src/Extend/User.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Extend;
|
||||
|
||||
use Flarum\Extension\Extension;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
|
||||
class User implements ExtenderInterface
|
||||
{
|
||||
private $displayNameDrivers = [];
|
||||
|
||||
/**
|
||||
* Add a mail driver.
|
||||
*
|
||||
* @param string $identifier Identifier for display name driver. E.g. 'username' for UserNameDriver
|
||||
* @param string $driver ::class attribute of driver class, which must implement Flarum\User\DisplayName\DriverInterface
|
||||
*/
|
||||
public function displayNameDriver(string $identifier, $driver)
|
||||
{
|
||||
$this->drivers[$identifier] = $driver;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function extend(Container $container, Extension $extension = null)
|
||||
{
|
||||
$container->extend('flarum.user.display_name.supported_drivers', function ($existingDrivers) {
|
||||
return array_merge($existingDrivers, $this->drivers);
|
||||
});
|
||||
}
|
||||
}
|
@@ -15,7 +15,7 @@ use Flarum\Extension\Event\Disabling;
|
||||
use Flarum\Extension\Event\Enabled;
|
||||
use Flarum\Extension\Event\Enabling;
|
||||
use Flarum\Extension\Event\Uninstalled;
|
||||
use Flarum\Foundation\Application;
|
||||
use Flarum\Foundation\Paths;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
@@ -29,7 +29,12 @@ class ExtensionManager
|
||||
{
|
||||
protected $config;
|
||||
|
||||
protected $app;
|
||||
/**
|
||||
* @var Paths
|
||||
*/
|
||||
protected $paths;
|
||||
|
||||
protected $container;
|
||||
|
||||
protected $migrator;
|
||||
|
||||
@@ -50,13 +55,15 @@ class ExtensionManager
|
||||
|
||||
public function __construct(
|
||||
SettingsRepositoryInterface $config,
|
||||
Application $app,
|
||||
Paths $paths,
|
||||
Container $container,
|
||||
Migrator $migrator,
|
||||
Dispatcher $dispatcher,
|
||||
Filesystem $filesystem
|
||||
) {
|
||||
$this->config = $config;
|
||||
$this->app = $app;
|
||||
$this->paths = $paths;
|
||||
$this->container = $container;
|
||||
$this->migrator = $migrator;
|
||||
$this->dispatcher = $dispatcher;
|
||||
$this->filesystem = $filesystem;
|
||||
@@ -67,18 +74,26 @@ class ExtensionManager
|
||||
*/
|
||||
public function getExtensions()
|
||||
{
|
||||
if (is_null($this->extensions) && $this->filesystem->exists($this->app->vendorPath().'/composer/installed.json')) {
|
||||
if (is_null($this->extensions) && $this->filesystem->exists($this->paths->vendor.'/composer/installed.json')) {
|
||||
$extensions = new Collection();
|
||||
|
||||
// Load all packages installed by composer.
|
||||
$installed = json_decode($this->filesystem->get($this->app->vendorPath().'/composer/installed.json'), true);
|
||||
$installed = json_decode($this->filesystem->get($this->paths->vendor.'/composer/installed.json'), true);
|
||||
|
||||
// Composer 2.0 changes the structure of the installed.json manifest
|
||||
$installed = $installed['packages'] ?? $installed;
|
||||
|
||||
foreach ($installed as $package) {
|
||||
if (Arr::get($package, 'type') != 'flarum-extension' || empty(Arr::get($package, 'name'))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = isset($package['install-path'])
|
||||
? $this->paths->vendor.'/composer/'.$package['install-path']
|
||||
: $this->paths->vendor.'/'.Arr::get($package, 'name');
|
||||
|
||||
// Instantiates an Extension object using the package path and composer.json file.
|
||||
$extension = new Extension($this->getExtensionsDir().'/'.Arr::get($package, 'name'), $package);
|
||||
$extension = new Extension($path, $package);
|
||||
|
||||
// Per default all extensions are installed if they are registered in composer.
|
||||
$extension->setInstalled(true);
|
||||
@@ -130,7 +145,7 @@ class ExtensionManager
|
||||
|
||||
$this->setEnabled($enabled);
|
||||
|
||||
$extension->enable($this->app);
|
||||
$extension->enable($this->container);
|
||||
|
||||
$this->dispatcher->dispatch(new Enabled($extension));
|
||||
}
|
||||
@@ -156,7 +171,7 @@ class ExtensionManager
|
||||
|
||||
$this->setEnabled($enabled);
|
||||
|
||||
$extension->disable($this->app);
|
||||
$extension->disable($this->container);
|
||||
|
||||
$this->dispatcher->dispatch(new Disabled($extension));
|
||||
}
|
||||
@@ -191,7 +206,7 @@ class ExtensionManager
|
||||
if ($extension->hasAssets()) {
|
||||
$this->filesystem->copyDirectory(
|
||||
$extension->getPath().'/assets',
|
||||
$this->app->publicPath().'/assets/extensions/'.$extension->getId()
|
||||
$this->paths->public.'/assets/extensions/'.$extension->getId()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -203,7 +218,7 @@ class ExtensionManager
|
||||
*/
|
||||
protected function unpublishAssets(Extension $extension)
|
||||
{
|
||||
$this->filesystem->deleteDirectory($this->app->publicPath().'/assets/extensions/'.$extension->getId());
|
||||
$this->filesystem->deleteDirectory($this->paths->public.'/assets/extensions/'.$extension->getId());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,7 +230,7 @@ class ExtensionManager
|
||||
*/
|
||||
public function getAsset(Extension $extension, $path)
|
||||
{
|
||||
return $this->app->publicPath().'/assets/extensions/'.$extension->getId().$path;
|
||||
return $this->paths->public.'/assets/extensions/'.$extension->getId().$path;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,7 +242,7 @@ class ExtensionManager
|
||||
*/
|
||||
public function migrate(Extension $extension, $direction = 'up')
|
||||
{
|
||||
$this->app->bind(Builder::class, function ($container) {
|
||||
$this->container->bind(Builder::class, function ($container) {
|
||||
return $container->make(ConnectionInterface::class)->getSchemaBuilder();
|
||||
});
|
||||
|
||||
@@ -320,14 +335,4 @@ class ExtensionManager
|
||||
|
||||
return isset($enabled[$extension]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The extensions path.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getExtensionsDir()
|
||||
{
|
||||
return $this->app->vendorPath();
|
||||
}
|
||||
}
|
||||
|
@@ -11,7 +11,6 @@ namespace Flarum\Extension;
|
||||
|
||||
use Flarum\Extension\Event\Disabling;
|
||||
use Flarum\Foundation\AbstractServiceProvider;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
|
||||
class ExtensionServiceProvider extends AbstractServiceProvider
|
||||
{
|
||||
@@ -27,8 +26,8 @@ class ExtensionServiceProvider extends AbstractServiceProvider
|
||||
// listener on the app rather than in the service provider's boot method
|
||||
// below, so that extensions have a chance to register things on the
|
||||
// container before the core boots up (and starts resolving services).
|
||||
$this->app->booting(function (Container $app) {
|
||||
$app->make('flarum.extensions')->extend($app);
|
||||
$this->app['flarum']->booting(function () {
|
||||
$this->app->make('flarum.extensions')->extend($this->app);
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -10,6 +10,7 @@
|
||||
namespace Flarum\Formatter;
|
||||
|
||||
use Flarum\Foundation\AbstractServiceProvider;
|
||||
use Flarum\Foundation\Paths;
|
||||
use Illuminate\Cache\Repository;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
|
||||
@@ -24,7 +25,7 @@ class FormatterServiceProvider extends AbstractServiceProvider
|
||||
return new Formatter(
|
||||
new Repository($container->make('cache.filestore')),
|
||||
$container->make('events'),
|
||||
$this->app->storagePath().'/formatter'
|
||||
$this->app[Paths::class]->storage.'/formatter'
|
||||
);
|
||||
});
|
||||
|
||||
|
@@ -9,12 +9,10 @@
|
||||
|
||||
namespace Flarum\Forum;
|
||||
|
||||
use Flarum\Event\ConfigureForumRoutes;
|
||||
use Flarum\Extension\Event\Disabled;
|
||||
use Flarum\Extension\Event\Enabled;
|
||||
use Flarum\Formatter\Formatter;
|
||||
use Flarum\Foundation\AbstractServiceProvider;
|
||||
use Flarum\Foundation\Application;
|
||||
use Flarum\Foundation\ErrorHandling\Registry;
|
||||
use Flarum\Foundation\ErrorHandling\Reporter;
|
||||
use Flarum\Foundation\ErrorHandling\ViewFormatter;
|
||||
@@ -60,6 +58,7 @@ class ForumServiceProvider extends AbstractServiceProvider
|
||||
|
||||
$this->app->singleton('flarum.forum.middleware', function () {
|
||||
return [
|
||||
'flarum.forum.error_handler',
|
||||
HttpMiddleware\ParseJsonBody::class,
|
||||
HttpMiddleware\CollectGarbage::class,
|
||||
HttpMiddleware\StartSession::class,
|
||||
@@ -71,15 +70,16 @@ class ForumServiceProvider extends AbstractServiceProvider
|
||||
];
|
||||
});
|
||||
|
||||
$this->app->singleton('flarum.forum.handler', function (Application $app) {
|
||||
$pipe = new MiddlewarePipe;
|
||||
$this->app->bind('flarum.forum.error_handler', function () {
|
||||
return new HttpMiddleware\HandleErrors(
|
||||
$this->app->make(Registry::class),
|
||||
$this->app['flarum']->inDebugMode() ? $this->app->make(WhoopsFormatter::class) : $this->app->make(ViewFormatter::class),
|
||||
$this->app->tagged(Reporter::class)
|
||||
);
|
||||
});
|
||||
|
||||
// All requests should first be piped through our global error handler
|
||||
$pipe->pipe(new HttpMiddleware\HandleErrors(
|
||||
$app->make(Registry::class),
|
||||
$app->inDebugMode() ? $app->make(WhoopsFormatter::class) : $app->make(ViewFormatter::class),
|
||||
$app->tagged(Reporter::class)
|
||||
));
|
||||
$this->app->singleton('flarum.forum.handler', function () {
|
||||
$pipe = new MiddlewarePipe;
|
||||
|
||||
foreach ($this->app->make('flarum.forum.middleware') as $middleware) {
|
||||
$pipe->pipe($this->app->make($middleware));
|
||||
@@ -186,10 +186,6 @@ class ForumServiceProvider extends AbstractServiceProvider
|
||||
|
||||
$callback = include __DIR__.'/routes.php';
|
||||
$callback($routes, $factory);
|
||||
|
||||
$this->app->make('events')->dispatch(
|
||||
new ConfigureForumRoutes($routes, $factory)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user