1
0
mirror of https://github.com/flarum/core.git synced 2025-09-01 20:24:25 +02:00

Compare commits

...

67 Commits

Author SHA1 Message Date
flarum-bot
ed01f389a8 Bundled output for commit 71e313e677 [skip ci] 2020-06-19 21:42:28 +00:00
Alexander Skvortsov
71e313e677 Clean up app.current, app.previous in JS (#2156)
- Encapsulate app.current, app.previous in PageState objects
- Reorganize Page classes to use one central base class in common

Co-authored-by: Franz Liedke <franz@develophp.org>
2020-06-19 17:41:26 -04:00
Franz Liedke
88366fe8af Clean up usages / deprecate path helpers (#2155)
* Write source map without creating temp file

Less I/O, and one less place where we access the global path helpers.

* Drop useless app_path() helper

This was probably taken straight from Laravel. There is no equivalent
concept in Flarum, so this should be safe to remove.

* Deprecate global path helpers

Developers using these helpers can inject the `Paths` class instead.

* Stop storing paths as strings in container

* Avoid using path helpers from Application class

* Deprecate path helpers from Application class

* Avoid using public_path() in prerequisite check

a) The comparison was already outdated, as a different path was passed.
b) We're trying to get rid of these global helpers.
2020-06-19 16:16:03 -04:00
flarum-bot
b82504b4b1 Bundled output for commit 898d68d9f3 [skip ci] 2020-06-19 00:30:16 +00:00
Franz Liedke
898d68d9f3 Remove leftover property
Refs #2150.
2020-06-19 02:27:01 +02:00
flarum-bot
69f0172b92 Bundled output for commit 62fe9db732 [skip ci] 2020-06-19 00:11:51 +00:00
Alexander Skvortsov
62fe9db732 Don't store PostUser instance in CommentPost (#2184)
* Don't save component state in CommentPost
2020-06-18 20:10:25 -04:00
flarum-bot
ed566cd18f Bundled output for commit 5c1663d8f1 [skip ci] 2020-06-18 23:54:42 +00:00
Alexander Skvortsov
5c1663d8f1 Move Discussion List State into its own class (#2150)
Extract discussion list state
2020-06-18 19:53:40 -04:00
flarum-bot
c5d3b058ba Bundled output for commit 4a804dbbbc [skip ci] 2020-06-18 22:48:18 +00:00
Alexander Skvortsov
4a804dbbbc Remove app.search instance, cache app.cache.searched (#2151)
* Moved search state logic into search state
2020-06-18 18:47:01 -04:00
flarum-bot
f4afb006ed Bundled output for commit 646b35374d [skip ci] 2020-06-18 21:29:07 +00:00
Alexander Skvortsov
646b35374d Don't store checkbox instances in NotificationGrid (#2183)
* Don't store checkbox states in NotificaitonGrid, use props for loading in Checkbox and Switch, replace preferenceSaver with internal management of loading state
2020-06-18 17:28:05 -04:00
flarum-bot
4fc06336df Bundled output for commit 65f2d5fb75 [skip ci] 2020-06-18 21:09:49 +00:00
Alexander Skvortsov
65f2d5fb75 Extract NotificationList state (#2185)
* Extract NotificationList state
2020-06-18 17:08:06 -04:00
Alexander Skvortsov
5bca4fda9d Return the proper error code when wrong password when changing email (#2171) 2020-06-17 20:43:04 -04:00
Clark Winkelmann
b87c7189cc Remove BioChanged event which is no longer used since beta 8 (#2196) 2020-06-15 00:21:06 -04:00
Clark Winkelmann
17c239388a Fix AvatarChanged event (#2197)
* Fix AvatarChanged event not being dispatched when changing avatar
Also fix the uploader to trigger the event only once
2020-06-15 00:20:24 -04:00
Alexander Skvortsov
4da2994d1f Group Gambit Improvements (#2192)
* - Add ID to fields searched in group gambit
- Use joins instead of looping in group gambit
* Add visibility scoping to group gambit
* call IDs userIds
* If group identifier is numerical, treat it as an ID
2020-06-08 17:35:24 -04:00
Matt Kilgore
293e2251ca Fixes #2157, Explicitly set SameSite value for cookies (#2159)
* Fixes #2157, Explicitly set SameSite value for cookies by making samesite a config option in config.php. Also contains an update for the cookie library dependency
2020-06-03 22:53:30 -04:00
flarum-bot
3b1f5ca07b Bundled output for commit d1750fecc0 [skip ci] 2020-05-31 02:50:39 +00:00
Alexander Skvortsov
d1750fecc0 Send Test Mail Feature (#2023)
- Add UI, backend for sending test emails
- Change mail settings endpoint to /api/mail/settings
2020-05-30 22:49:36 -04:00
flarum-bot
63242edeb3 Bundled output for commit 0aed3764c4 [skip ci] 2020-05-31 02:29:29 +00:00
Hasan Özbey
0aed3764c4 Scroll to edited post or inform the user (#2108)
* scroll to edit or inform the user
2020-05-30 22:28:08 -04:00
Alexander Skvortsov
7b1269207e Get rid of Laravel Gate contract (#2181)
* Get rid of unnecessary uses of gate

* Move gate off of Laravel's gate contract
2020-05-28 18:00:44 -04:00
Sami Mazouz
bab084a75f Fix Paths test failing on Windows (#2187)
* Fix directory separator for windows os

* Change Paths to use a forward slash instead
2020-05-28 12:42:54 -04:00
Alexander Skvortsov
3c87f800dd Instances of models should not matter when checking permissions (#2186) 2020-05-27 12:22:08 -04:00
Matt Kilgore
26256c436f Fix installer removing URL port (#2182)
* Fix installer removing URL port
2020-05-25 14:35:22 +02:00
Franz Liedke
63397bb466 Allow manipulating error handler through extender
By giving each middleware a name, they can now be replaced or moved
around using the Middleware extender.

Fixes #2115.
2020-05-24 08:47:26 +02:00
w-4
4b6864534b Fix header contents moving when opening modal (#2131)
* add navbar-fixed-top css class

* App-header position:fixed
2020-05-23 14:41:54 -04:00
Franz Liedke
c4f4f218bf Tests: Actually accept multiple extenders
We did pass multiple extenders to this method in the tests for the
`Model` extender - now this actually has the desired effect.
2020-05-23 02:00:25 +02:00
Franz Liedke
4866e7d9ba Stop using app() helper in tests 2020-05-23 01:56:21 +02:00
Sami Mazouz
d6acf28fcb Add z-index rule as part of fixing replies dropdown menu width (#2178) 2020-05-22 18:50:39 -04:00
Alexander Skvortsov
e627616750 Inject Url Generator and Translator Interface into notification mailer (#2169) 2020-05-22 18:10:31 -04:00
flarum-bot
bbd815a9ab Bundled output for commit acf4e9c80d [skip ci] 2020-05-20 00:53:05 +00:00
Alexander Skvortsov
acf4e9c80d Removed excess Widget class in favor of DashboardWidget (#2164) 2020-05-19 20:52:07 -04:00
flarum-bot
1bb5f99a27 Bundled output for commit b0822df759 [skip ci] 2020-05-19 22:46:59 +00:00
Alexander Skvortsov
b0822df759 Use drivers for display names, add display name extender (#2174)
* Deprecate GetDisplayName event

* Add interface for display name driver

* Add username driver as default

* Add code to register supported drivers / used driver as singletons

* Configured User class to use new driver-based system for display names

* Add extender for adding display name driver

* Add integration test for user display name driver

* Add frontend UI for selecting display name driver
2020-05-19 18:45:56 -04:00
flarum-bot
998e32c208 Bundled output for commit f89f114fad [skip ci] 2020-05-16 00:11:53 +00:00
julakali
f89f114fad Don't use body as tooltip container, allow notification area overflow (#2166)
* Don't use body as tooltip container, allow notification area overflow

Badge tooltips are using container: 'body', so they can overflow the
notification area. When the user navigates back while a badge tooltip is
showing, the tooltip remains visible.
This commit removes the body container attribute and instead allows the
notificationDropDown to overflow, so badge tooltips aren't cut off.
Instead, this adds overflow: hidden to NotificationList.
Fixes #2118.

* Remove newline
2020-05-15 20:10:40 -04:00
flarum-bot
9b936d4baa Bundled output for commit 7e661df15d [skip ci] 2020-05-12 16:24:38 +00:00
David Sevilla Martín
7e661df15d Some improvements to request error handling and modal error formatting (#1929)
* Use decodeURI instead of unescape & don't close modals

* Add comment

* Don't use a try/catch, clean up the group log code

* Remove double negative

* Format; fix issues from rebasing
2020-05-12 12:23:13 -04:00
Franz Liedke
b7355db2b7 Merge pull request #2154 from flarum/fl/2055-l58
Upgrade to Laravel 5.8
2020-05-12 15:20:01 +02:00
Franz Liedke
5dc9451c21 Fix notification query with DB prefix
This was fixed in https://github.com/laravel/framework/pull/28400.
See commit 7f1048352d.
2020-05-09 14:45:57 +02:00
Franz Liedke
220c8c66b0 Fix signature of HandleErrors middleware
In Laravel 5.8, the `Container::tagged()` method was changed to return
an iterator [1].

We only use the result for iteration, or, in this case, to pass a bunch
of "reporters" to the error handler middleware, therefore we need to
accept an iterable here.

[1]: https://laravel.com/docs/5.8/upgrade#container-generators
2020-05-08 23:30:17 +02:00
Franz Liedke
484933db7d Test setup: Do not use env() helper
Not needed, and not working without a full Laravel installation.
2020-05-08 23:30:17 +02:00
Franz Liedke
f6347dcc46 Update Laravel components to v5.8
First part of #2055.
2020-05-08 21:46:13 +02:00
Franz Liedke
107b4be726 Remove empty comment 2020-05-08 16:05:25 +02:00
Franz Liedke
93d4192b54 Apply fixes from StyleCI
[ci skip] [skip ci]
2020-05-08 14:03:48 +00:00
Franz Liedke
ecdce44d55 Fix container configuration when not installed 2020-05-08 16:03:20 +02:00
Franz Liedke
a5e286e662 Drop MigrationServiceProvider 2020-05-08 12:04:24 +02:00
Franz Liedke
443949f7b9 Fix generate:migration command for extensions
Apparently, this code was from back when we had a special "extensions"
directory for Composer packages marked as Flarum extensions.

While we're at it, we now inject the Paths instance instead of using one
of the global helpers (which I am trying to get rid of).

Refs #2055.
2020-05-08 12:01:11 +02:00
Franz Liedke
4884aad2f0 Update beta.13 changelog 2020-05-08 11:35:46 +02:00
Franz Liedke
365eb15d29 Merge pull request #2142 from flarum/fl/2055-prepare-for-laravel-58
Split up Application and Container
2020-05-07 22:49:36 +02:00
flarum-bot
85e2623622 Bundled output for commit 7d99727168 [skip ci] 2020-05-07 07:20:06 +00:00
Daniël Klabbers
7d99727168 commit version constant 2020-05-07 09:17:26 +02:00
Daniël Klabbers
84784c9839 Release v0.1.0-beta.13 2020-05-07 09:18:04 +02:00
Franz Liedke
a9470b463f Make two more tests compatible with PHPUnit 8 2020-05-07 09:18:04 +02:00
Franz Liedke
deb48bd173 Remove obsolete method 2020-05-07 09:18:04 +02:00
Alexander Skvortsov
b38bd60362 Added simply confirmation popup for hiding / deleting posts (#2135) 2020-05-07 09:18:04 +02:00
Franz Liedke
260e7cd48f Inject new Paths class instead of Application
This (and similar work in other areas) will allow us to further
reduce the API surface of the Application class.

Separation of concerns etc.
2020-05-01 15:47:35 +02:00
Franz Liedke
41a56c4ad1 Split up Application and Container
- Stop trying to implement Laravel's Application contract, which
  has no value for us.
- Stop inheriting from the Container, injecting one works equally
  well and does not clutter up the interfaces.
- Inject the Paths collection instead of unwrapping it again, for
  better encapsulation.

This brings us one step closer toward upgrading our Laravel
components (#2055), because we no longer need to adopt the changes
to the Application contract.
2020-05-01 15:47:35 +02:00
Franz Liedke
d0ae2839f0 Extract a class to hold / determine paths 2020-05-01 15:24:20 +02:00
flarum-bot
d31a747631 Bundled output for commit 526081bd06 [skip ci] 2020-05-01 09:53:55 +00:00
Franz Liedke
526081bd06 Update Webpack 2020-05-01 11:52:26 +02:00
Franz Liedke
cbdd3c5cc7 Apply fixes from StyleCI
[ci skip] [skip ci]
2020-04-27 20:04:41 +00:00
Franz Liedke
7d1ef9d891 Remove a bunch of deprecated events
Use extenders instead!

Refs #1891.
2020-04-27 22:04:08 +02:00
137 changed files with 2178 additions and 2788 deletions

View File

@@ -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

View File

@@ -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

8
js/dist/forum.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

827
js/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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": {

View File

@@ -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,

View File

@@ -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';

View File

@@ -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',

View File

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

View File

@@ -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>;
}
/**

View File

@@ -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() {

View File

@@ -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);

View File

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

View File

@@ -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';

View File

@@ -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 [];
}
}

View File

@@ -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 }));
}
/**

View File

@@ -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,

View File

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

View File

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

View File

@@ -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();

View File

@@ -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();

View File

@@ -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 (

View File

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

View 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;
}
}

View 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);
}

View File

@@ -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 });
}
/**

View File

@@ -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,

View File

@@ -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

View File

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

View File

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

View File

@@ -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);
}
/**

View File

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

View File

@@ -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')) {

View File

@@ -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.
*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();

View File

@@ -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

View File

@@ -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();
}
/**

View File

@@ -2,8 +2,8 @@
* The `SearchSource` interface defines a section of search results in the
* search dropdown.
*
* Search sources should be registered with the `Search` component 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.

View File

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

View File

@@ -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();

View 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();
}
}

View 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));
}
}

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

View 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;
}
}

View File

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

View File

@@ -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();

View File

@@ -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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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));

View File

@@ -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;
}

View File

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

View 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();
}
}

View File

@@ -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;
}
}

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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)) {

View File

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

View File

@@ -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 Illuminate\Console\Command;
use Illuminate\Contracts\Container\Container;
use Symfony\Component\Console\Application;
/**
* @deprecated
*/
class Configuring
{
/**
* @var Container
*/
public $app;
/**
* @var Application
*/
public $console;
/**
* @param Container $container
* @param Application $console
*/
public function __construct(Container $container, Application $console)
{
$this->app = $container;
$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);
}
}

View File

@@ -9,12 +9,10 @@
namespace Flarum\Console;
use Flarum\Console\Event\Configuring;
use Flarum\Foundation\ErrorHandling\Registry;
use Flarum\Foundation\ErrorHandling\Reporter;
use Flarum\Foundation\SiteInterface;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Container\Container;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\ConsoleEvents;
use Symfony\Component\Console\Event\ConsoleErrorEvent;
@@ -39,32 +37,20 @@ class Server
$console->add($command);
}
$this->extend($console); // deprecated
$this->handleErrors($console);
exit($console->run());
}
/**
* @deprecated
*/
private function extend(Application $console)
{
$container = \Illuminate\Container\Container::getInstance();
$this->handleErrors($container, $console);
$events = $container->make(Dispatcher::class);
$events->dispatch(new Configuring($container, $console));
}
private function handleErrors(Container $container, Application $console)
private function handleErrors(Application $console)
{
$dispatcher = new EventDispatcher();
$dispatcher->addListener(ConsoleEvents::ERROR, function (ConsoleErrorEvent $event) use ($container) {
$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 */

View File

@@ -9,9 +9,6 @@
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;
@@ -84,11 +81,6 @@ abstract class AbstractModel extends Eloquent
$this->attributes = array_merge($this->attributes, Arr::get(static::$defaults, $class, []));
}
// Deprecated in beta 13, remove in beta 14.
static::$dispatcher->dispatch(
new ConfigureModelDefaultAttributes($this, $this->attributes)
);
$this->attributes = array_map(function ($item) {
return is_callable($item) ? $item() : $item;
}, $this->attributes);
@@ -103,10 +95,6 @@ abstract class AbstractModel extends Eloquent
*/
public function getDates()
{
static::$dispatcher->dispatch(
new ConfigureModelDates($this, $this->dates)
);
$dates = $this->dates;
foreach (array_merge(array_reverse(class_parents($this)), [static::class]) as $class) {
@@ -157,11 +145,6 @@ abstract class AbstractModel extends Eloquent
return $relation($this);
}
}
// Deprecated, remove in beta 14
return static::$dispatcher->until(
new GetModelRelationship($this, $name)
);
}
/**

View File

@@ -13,6 +13,7 @@ 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;
@@ -26,18 +27,18 @@ class MigrateCommand extends AbstractCommand
protected $container;
/**
* @var Application
* @var Paths
*/
protected $app;
protected $paths;
/**
* @param Container $container
* @param Application $application
* @param Paths $paths
*/
public function __construct(Container $container, Application $application)
public function __construct(Container $container, Paths $paths)
{
$this->container = $container;
$this->app = $application;
$this->paths = $paths;
parent::__construct();
}
@@ -91,8 +92,8 @@ class MigrateCommand extends AbstractCommand
$this->info('Publishing assets...');
$this->container->make('files')->copyDirectory(
$this->app->vendorPath().'/components/font-awesome/webfonts',
$this->app->publicPath().'/assets/fonts'
$this->paths->vendor.'/components/font-awesome/webfonts',
$this->paths->public.'/assets/fonts'
);
}
}

View File

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

View File

@@ -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';
}
}
/**

View File

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

View File

@@ -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;
}

View File

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

View File

@@ -1,17 +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;
/**
* @deprecated Will be removed in Beta.14. Use Flarum\Extend\Routes instead.
*/
class ConfigureApiRoutes extends AbstractConfigureRoutes
{
}

View File

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

View File

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

View File

@@ -1,50 +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;
/**
* @deprecated in beta 13, removed in beta 14
*
* 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;
}
}

View File

@@ -1,47 +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;
/**
* @deprecated in beta 13, removed in beta 14
*/
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;
}
}

View File

@@ -1,51 +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;
/**
* @deprecated beta 13, use the Model extender instead.
*
* 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;
}
}

38
src/Extend/User.php Normal file
View 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);
});
}
}

View File

@@ -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,10 @@ class ExtensionManager
{
protected $config;
protected $app;
/**
* @var Paths
*/
protected $paths;
protected $container;
@@ -52,14 +55,14 @@ 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;
@@ -71,11 +74,11 @@ 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;
@@ -86,8 +89,8 @@ class ExtensionManager
}
$path = isset($package['install-path'])
? $this->getExtensionsDir().'/composer/'.$package['install-path']
: $this->getExtensionsDir().'/'.Arr::get($package, 'name');
? $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($path, $package);
@@ -203,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()
);
}
}
@@ -215,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());
}
/**
@@ -227,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;
}
/**
@@ -332,14 +335,4 @@ class ExtensionManager
return isset($enabled[$extension]);
}
/**
* The extensions path.
*
* @return string
*/
protected function getExtensionsDir()
{
return $this->app->vendorPath();
}
}

View File

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

View File

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

View File

@@ -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)
);
}
/**

View File

@@ -9,21 +9,22 @@
namespace Flarum\Foundation;
use Illuminate\Contracts\Container\Container;
use Illuminate\Support\ServiceProvider;
abstract class AbstractServiceProvider extends ServiceProvider
{
/**
* @var Application
* @var Container
*/
protected $app;
/**
* @param Application $app
* @param Container $container
*/
public function __construct(Application $app)
public function __construct(Container $container)
{
parent::__construct($app);
$this->app = $container;
}
/**

View File

@@ -9,49 +9,33 @@
namespace Flarum\Foundation;
use Illuminate\Container\Container;
use Illuminate\Contracts\Foundation\Application as ApplicationContract;
use Illuminate\Contracts\Container\Container;
use Illuminate\Events\EventServiceProvider;
use Illuminate\Support\Arr;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
class Application extends Container implements ApplicationContract
class Application
{
/**
* The Flarum version.
*
* @var string
*/
const VERSION = '0.1.0-beta.13-dev';
const VERSION = '0.1.0-beta.14-dev';
/**
* The base path for the Flarum installation.
* The IoC container for the Flarum application.
*
* @var string
* @var Container
*/
protected $basePath;
private $container;
/**
* The public path for the Flarum installation.
* The paths for the Flarum installation.
*
* @var string
* @var Paths
*/
protected $publicPath;
/**
* The custom storage path defined by the developer.
*
* @var string
*/
protected $storagePath;
/**
* A custom vendor path to find dependencies in non-standard environments.
*
* @var string
*/
protected $vendorPath;
protected $paths;
/**
* Indicates if the application has "booted".
@@ -88,34 +72,20 @@ class Application extends Container implements ApplicationContract
*/
protected $loadedProviders = [];
/**
* The deferred services and their providers.
*
* @var array
*/
protected $deferredServices = [];
/**
* Create a new Flarum application instance.
*
* @param string|null $basePath
* @param string|null $publicPath
* @param Container $container
* @param Paths $paths
*/
public function __construct($basePath = null, $publicPath = null)
public function __construct(Container $container, Paths $paths)
{
$this->container = $container;
$this->paths = $paths;
$this->registerBaseBindings();
$this->registerBaseServiceProviders();
$this->registerCoreContainerAliases();
if ($basePath) {
$this->setBasePath($basePath);
}
if ($publicPath) {
$this->setPublicPath($publicPath);
}
}
/**
@@ -125,7 +95,7 @@ class Application extends Container implements ApplicationContract
*/
public function config($key, $default = null)
{
return Arr::get($this->make('flarum.config'), $key, $default);
return Arr::get($this->container->make('flarum.config'), $key, $default);
}
/**
@@ -146,7 +116,7 @@ class Application extends Container implements ApplicationContract
*/
public function url($path = null)
{
$config = $this->make('flarum.config');
$config = $this->container->make('flarum.config');
$url = Arr::get($config, 'url', Arr::get($_SERVER, 'REQUEST_URI'));
if (is_array($url)) {
@@ -164,26 +134,21 @@ class Application extends Container implements ApplicationContract
return $url;
}
/**
* Get the version number of the application.
*
* @return string
*/
public function version()
{
return static::VERSION;
}
/**
* Register the basic bindings into the container.
*/
protected function registerBaseBindings()
{
static::setInstance($this);
\Illuminate\Container\Container::setInstance($this->container);
$this->instance('app', $this);
$this->container->instance('app', $this->container);
$this->container->alias('app', \Illluminate\Container\Container::class);
$this->instance(Container::class, $this);
$this->container->instance('flarum', $this);
$this->container->alias('flarum', self::class);
$this->container->instance('flarum.paths', $this->paths);
$this->container->alias('flarum.paths', Paths::class);
}
/**
@@ -191,171 +156,51 @@ class Application extends Container implements ApplicationContract
*/
protected function registerBaseServiceProviders()
{
$this->register(new EventServiceProvider($this));
}
/**
* Set the base path for the application.
*
* @param string $basePath
* @return $this
*/
public function setBasePath($basePath)
{
$this->basePath = rtrim($basePath, '\/');
$this->bindPathsInContainer();
return $this;
}
/**
* Set the public path for the application.
*
* @param string $publicPath
* @return $this
*/
public function setPublicPath($publicPath)
{
$this->publicPath = rtrim($publicPath, '\/');
$this->bindPathsInContainer();
return $this;
}
/**
* Bind all of the application paths in the container.
*
* @return void
*/
protected function bindPathsInContainer()
{
foreach (['base', 'public', 'storage', 'vendor'] as $path) {
$this->instance('path.'.$path, $this->{$path.'Path'}());
}
$this->register(new EventServiceProvider($this->container));
}
/**
* Get the base path of the Laravel installation.
*
* @return string
* @deprecated Will be removed in Beta.15.
*/
public function basePath()
{
return $this->basePath;
return $this->paths->base;
}
/**
* Get the path to the public / web directory.
*
* @return string
* @deprecated Will be removed in Beta.15.
*/
public function publicPath()
{
return $this->publicPath;
return $this->paths->public;
}
/**
* Get the path to the storage directory.
*
* @return string
* @deprecated Will be removed in Beta.15.
*/
public function storagePath()
{
return $this->storagePath ?: $this->basePath.DIRECTORY_SEPARATOR.'storage';
return $this->paths->storage;
}
/**
* Get the path to the vendor directory where dependencies are installed.
*
* @return string
* @deprecated Will be removed in Beta.15.
*/
public function vendorPath()
{
return $this->vendorPath ?: $this->basePath.DIRECTORY_SEPARATOR.'vendor';
}
/**
* Set the storage directory.
*
* @param string $path
* @return $this
*/
public function useStoragePath($path)
{
$this->storagePath = $path;
$this->instance('path.storage', $path);
return $this;
}
/**
* Set the vendor directory.
*
* @param string $path
* @return $this
*/
public function useVendorPath($path)
{
$this->vendorPath = $path;
$this->instance('path.vendor', $path);
return $this;
}
/**
* Get or check the current application environment.
*
* @param mixed
* @return string
*/
public function environment()
{
if (func_num_args() > 0) {
$patterns = is_array(func_get_arg(0)) ? func_get_arg(0) : func_get_args();
foreach ($patterns as $pattern) {
if (Str::is($pattern, $this['env'])) {
return true;
}
}
return false;
}
return $this['env'];
}
/**
* Determine if we are running in the console.
*
* @return bool
*/
public function runningInConsole()
{
return php_sapi_name() == 'cli';
}
/**
* Determine if we are running unit tests.
*
* @return bool
*/
public function runningUnitTests()
{
return $this['env'] == 'testing';
}
/**
* Register all of the configured providers.
*
* @return void
*/
public function registerConfiguredProviders()
{
return $this->paths->vendor;
}
/**
@@ -423,7 +268,7 @@ class Application extends Container implements ApplicationContract
*/
public function resolveProviderClass($provider)
{
return new $provider($this);
return new $provider($this->container);
}
/**
@@ -434,106 +279,13 @@ class Application extends Container implements ApplicationContract
*/
protected function markAsRegistered($provider)
{
$this['events']->dispatch($class = get_class($provider), [$provider]);
$this->container['events']->dispatch($class = get_class($provider), [$provider]);
$this->serviceProviders[] = $provider;
$this->loadedProviders[$class] = true;
}
/**
* Load and boot all of the remaining deferred providers.
*/
public function loadDeferredProviders()
{
// We will simply spin through each of the deferred providers and register each
// one and boot them if the application has booted. This should make each of
// the remaining services available to this application for immediate use.
foreach ($this->deferredServices as $service => $provider) {
$this->loadDeferredProvider($service);
}
$this->deferredServices = [];
}
/**
* Load the provider for a deferred service.
*
* @param string $service
*/
public function loadDeferredProvider($service)
{
if (! isset($this->deferredServices[$service])) {
return;
}
$provider = $this->deferredServices[$service];
// If the service provider has not already been loaded and registered we can
// register it with the application and remove the service from this list
// of deferred services, since it will already be loaded on subsequent.
if (! isset($this->loadedProviders[$provider])) {
$this->registerDeferredProvider($provider, $service);
}
}
/**
* Register a deferred provider and service.
*
* @param string $provider
* @param string $service
*/
public function registerDeferredProvider($provider, $service = null)
{
// Once the provider that provides the deferred service has been registered we
// will remove it from our local list of the deferred services with related
// providers so that this container does not try to resolve it out again.
if ($service) {
unset($this->deferredServices[$service]);
}
$this->register($instance = new $provider($this));
if (! $this->booted) {
$this->booting(function () use ($instance) {
$this->bootProvider($instance);
});
}
}
/**
* Resolve the given type from the container.
*
* (Overriding Container::make)
*
* @param string $abstract
* @param array $parameters
* @return mixed
*/
public function make($abstract, array $parameters = [])
{
$abstract = $this->getAlias($abstract);
if (isset($this->deferredServices[$abstract])) {
$this->loadDeferredProvider($abstract);
}
return parent::make($abstract, $parameters);
}
/**
* Determine if the given abstract type has been bound.
*
* (Overriding Container::bound)
*
* @param string $abstract
* @return bool
*/
public function bound($abstract)
{
return isset($this->deferredServices[$abstract]) || parent::bound($abstract);
}
/**
* Determine if the application has booted.
*
@@ -578,7 +330,7 @@ class Application extends Container implements ApplicationContract
protected function bootProvider(ServiceProvider $provider)
{
if (method_exists($provider, 'boot')) {
return $this->call([$provider, 'boot']);
return $this->container->call([$provider, 'boot']);
}
}
@@ -621,96 +373,13 @@ class Application extends Container implements ApplicationContract
}
}
/**
* Get the path to the cached "compiled.php" file.
*
* @return string
*/
public function getCachedCompilePath()
{
return $this->basePath().'/bootstrap/cache/compiled.php';
}
/**
* Get the path to the cached services.json file.
*
* @return string
*/
public function getCachedServicesPath()
{
return $this->basePath().'/bootstrap/cache/services.json';
}
/**
* Determine if the application is currently down for maintenance.
*
* @return bool
*/
public function isDownForMaintenance()
{
return $this->config('offline');
}
/**
* Get the service providers that have been loaded.
*
* @return array
*/
public function getLoadedProviders()
{
return $this->loadedProviders;
}
/**
* Get the application's deferred services.
*
* @return array
*/
public function getDeferredServices()
{
return $this->deferredServices;
}
/**
* Set the application's deferred services.
*
* @param array $services
* @return void
*/
public function setDeferredServices(array $services)
{
$this->deferredServices = $services;
}
/**
* Add an array of services to the application's deferred services.
*
* @param array $services
* @return void
*/
public function addDeferredServices(array $services)
{
$this->deferredServices = array_merge($this->deferredServices, $services);
}
/**
* Determine if the given service is a deferred service.
*
* @param string $service
* @return bool
*/
public function isDeferredService($service)
{
return isset($this->deferredServices[$service]);
}
/**
* Register the core class aliases in the container.
*/
public function registerCoreContainerAliases()
{
$aliases = [
'app' => [self::class, \Illuminate\Contracts\Container\Container::class, \Illuminate\Contracts\Foundation\Application::class, \Psr\Container\ContainerInterface::class],
'app' => [\Illuminate\Contracts\Container\Container::class, \Illuminate\Contracts\Foundation\Application::class, \Psr\Container\ContainerInterface::class],
'blade.compiler' => [\Illuminate\View\Compilers\BladeCompiler::class],
'cache' => [\Illuminate\Cache\CacheManager::class, \Illuminate\Contracts\Cache\Factory::class],
'cache.store' => [\Illuminate\Cache\Repository::class, \Illuminate\Contracts\Cache\Repository::class],
@@ -730,36 +399,8 @@ class Application extends Container implements ApplicationContract
foreach ($aliases as $key => $aliases) {
foreach ((array) $aliases as $alias) {
$this->alias($key, $alias);
$this->container->alias($key, $alias);
}
}
}
/**
* Flush the container of all bindings and resolved instances.
*/
public function flush()
{
parent::flush();
$this->loadedProviders = [];
}
/**
* Get the path to the cached packages.php file.
*
* @return string
*/
public function getCachedPackagesPath()
{
return storage_path('app/cache/packages.php');
}
/**
* @return string
*/
public function resourcePath()
{
return storage_path('resources');
}
}

View File

@@ -10,8 +10,8 @@
namespace Flarum\Foundation\Console;
use Flarum\Console\AbstractCommand;
use Flarum\Foundation\Application;
use Flarum\Foundation\Event\ClearingCache;
use Flarum\Foundation\Paths;
use Illuminate\Contracts\Cache\Store;
class CacheClearCommand extends AbstractCommand
@@ -22,18 +22,18 @@ class CacheClearCommand extends AbstractCommand
protected $cache;
/**
* @var Application
* @var Paths
*/
protected $app;
protected $paths;
/**
* @param Store $cache
* @param Application $app
* @param Paths $paths
*/
public function __construct(Store $cache, Application $app)
public function __construct(Store $cache, Paths $paths)
{
$this->cache = $cache;
$this->app = $app;
$this->paths = $paths;
parent::__construct();
}
@@ -57,7 +57,7 @@ class CacheClearCommand extends AbstractCommand
$this->cache->flush();
$storagePath = $this->app->storagePath();
$storagePath = $this->paths->storage;
array_map('unlink', glob($storagePath.'/formatter/*'));
array_map('unlink', glob($storagePath.'/locale/*'));

View File

@@ -14,7 +14,6 @@ use Flarum\Api\ApiServiceProvider;
use Flarum\Bus\BusServiceProvider;
use Flarum\Console\ConsoleServiceProvider;
use Flarum\Database\DatabaseServiceProvider;
use Flarum\Database\MigrationServiceProvider;
use Flarum\Discussion\DiscussionServiceProvider;
use Flarum\Extension\ExtensionServiceProvider;
use Flarum\Formatter\FormatterServiceProvider;
@@ -51,7 +50,7 @@ use Psr\Log\LoggerInterface;
class InstalledSite implements SiteInterface
{
/**
* @var array
* @var Paths
*/
private $paths;
@@ -65,7 +64,7 @@ class InstalledSite implements SiteInterface
*/
private $extenders = [];
public function __construct(array $paths, array $config)
public function __construct(Paths $paths, array $config)
{
$this->paths = $paths;
$this->config = $config;
@@ -95,22 +94,18 @@ class InstalledSite implements SiteInterface
return $this;
}
private function bootLaravel(): Application
private function bootLaravel(): Container
{
$laravel = new Application($this->paths['base'], $this->paths['public']);
$container = new \Illuminate\Container\Container;
$laravel = new Application($container, $this->paths);
$laravel->useStoragePath($this->paths['storage']);
$container->instance('env', 'production');
$container->instance('flarum.config', $this->config);
$container->instance('flarum.debug', $laravel->inDebugMode());
$container->instance('config', $config = $this->getIlluminateConfig($laravel));
if (isset($this->paths['vendor'])) {
$laravel->useVendorPath($this->paths['vendor']);
}
$laravel->instance('env', 'production');
$laravel->instance('flarum.config', $this->config);
$laravel->instance('config', $config = $this->getIlluminateConfig($laravel));
$this->registerLogger($laravel);
$this->registerCache($laravel);
$this->registerLogger($container);
$this->registerCache($container);
$laravel->register(AdminServiceProvider::class);
$laravel->register(ApiServiceProvider::class);
@@ -129,7 +124,6 @@ class InstalledSite implements SiteInterface
$laravel->register(HttpServiceProvider::class);
$laravel->register(LocaleServiceProvider::class);
$laravel->register(MailServiceProvider::class);
$laravel->register(MigrationServiceProvider::class);
$laravel->register(NotificationServiceProvider::class);
$laravel->register(PostServiceProvider::class);
$laravel->register(QueueServiceProvider::class);
@@ -141,18 +135,18 @@ class InstalledSite implements SiteInterface
$laravel->register(ValidationServiceProvider::class);
$laravel->register(ViewServiceProvider::class);
$laravel->booting(function (Container $app) {
$laravel->booting(function () use ($container) {
// Run all local-site extenders before booting service providers
// (but after those from "real" extensions, which have been set up
// in a service provider above).
foreach ($this->extenders as $extension) {
$extension->extend($app);
$extension->extend($container);
}
});
$laravel->boot();
return $laravel;
return $container;
}
/**
@@ -164,7 +158,7 @@ class InstalledSite implements SiteInterface
return new ConfigRepository([
'view' => [
'paths' => [],
'compiled' => $this->paths['storage'].'/views',
'compiled' => $this->paths->storage.'/views',
],
'mail' => [
'driver' => 'mail',
@@ -175,43 +169,43 @@ class InstalledSite implements SiteInterface
'disks' => [
'flarum-assets' => [
'driver' => 'local',
'root' => $this->paths['public'].'/assets',
'root' => $this->paths->public.'/assets',
'url' => $app->url('assets')
],
'flarum-avatars' => [
'driver' => 'local',
'root' => $this->paths['public'].'/assets/avatars'
'root' => $this->paths->public.'/assets/avatars'
]
]
],
'session' => [
'lifetime' => 120,
'files' => $this->paths['storage'].'/sessions',
'files' => $this->paths->storage.'/sessions',
'cookie' => 'session'
]
]);
}
private function registerLogger(Application $app)
private function registerLogger(Container $container)
{
$logPath = $this->paths['storage'].'/logs/flarum.log';
$logPath = $this->paths->storage.'/logs/flarum.log';
$handler = new RotatingFileHandler($logPath, Logger::INFO);
$handler->setFormatter(new LineFormatter(null, null, true, true));
$app->instance('log', new Logger($app->environment(), [$handler]));
$app->alias('log', LoggerInterface::class);
$container->instance('log', new Logger('flarum', [$handler]));
$container->alias('log', LoggerInterface::class);
}
private function registerCache(Application $app)
private function registerCache(Container $container)
{
$app->singleton('cache.store', function ($app) {
return new CacheRepository($app->make('cache.filestore'));
$container->singleton('cache.store', function ($container) {
return new CacheRepository($container->make('cache.filestore'));
});
$app->alias('cache.store', Repository::class);
$container->alias('cache.store', Repository::class);
$app->singleton('cache.filestore', function () {
return new FileStore(new Filesystem, $this->paths['storage'].'/cache');
$container->singleton('cache.filestore', function () {
return new FileStore(new Filesystem, $this->paths->storage.'/cache');
});
$app->alias('cache.filestore', Store::class);
$container->alias('cache.filestore', Store::class);
}
}

44
src/Foundation/Paths.php Normal file
View File

@@ -0,0 +1,44 @@
<?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\Foundation;
use InvalidArgumentException;
/**
* @property-read string base
* @property-read string public
* @property-read string storage
* @property-read string vendor
*/
class Paths
{
private $paths;
public function __construct(array $paths)
{
if (! isset($paths['base'], $paths['public'], $paths['storage'])) {
throw new InvalidArgumentException(
'Paths array requires keys base, public and storage'
);
}
$this->paths = array_map(function ($path) {
return rtrim($path, '\/');
}, $paths);
// Assume a standard Composer directory structure unless specified
$this->paths['vendor'] = $this->vendor ?? $this->base.'/vendor';
}
public function __get($name): ?string
{
return $this->paths[$name] ?? null;
}
}

View File

@@ -9,7 +9,6 @@
namespace Flarum\Foundation;
use InvalidArgumentException;
use RuntimeException;
class Site
@@ -20,18 +19,14 @@ class Site
*/
public static function fromPaths(array $paths)
{
if (! isset($paths['base'], $paths['public'], $paths['storage'])) {
throw new InvalidArgumentException(
'Paths array requires keys base, public and storage'
);
}
$paths = new Paths($paths);
date_default_timezone_set('UTC');
if (static::hasConfigFile($paths['base'])) {
if (static::hasConfigFile($paths->base)) {
return (
new InstalledSite($paths, static::loadConfig($paths['base']))
)->extendWith(static::loadExtenders($paths['base']));
new InstalledSite($paths, static::loadConfig($paths->base))
)->extendWith(static::loadExtenders($paths->base));
} else {
return new UninstalledSite($paths);
}

View File

@@ -16,6 +16,7 @@ use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\Settings\UninstalledSettingsRepository;
use Flarum\User\SessionServiceProvider;
use Illuminate\Config\Repository as ConfigRepository;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Filesystem\FilesystemServiceProvider;
use Illuminate\Validation\ValidationServiceProvider;
@@ -30,11 +31,11 @@ use Psr\Log\LoggerInterface;
class UninstalledSite implements SiteInterface
{
/**
* @var array
* @var Paths
*/
private $paths;
public function __construct(array $paths)
public function __construct(Paths $paths)
{
$this->paths = $paths;
}
@@ -51,21 +52,17 @@ class UninstalledSite implements SiteInterface
);
}
private function bootLaravel(): Application
private function bootLaravel(): Container
{
$laravel = new Application($this->paths['base'], $this->paths['public']);
$container = new \Illuminate\Container\Container;
$laravel = new Application($container, $this->paths);
$laravel->useStoragePath($this->paths['storage']);
$container->instance('env', 'production');
$container->instance('flarum.config', []);
$container->instance('flarum.debug', $laravel->inDebugMode());
$container->instance('config', $config = $this->getIlluminateConfig());
if (isset($this->paths['vendor'])) {
$laravel->useVendorPath($this->paths['vendor']);
}
$laravel->instance('env', 'production');
$laravel->instance('flarum.config', []);
$laravel->instance('config', $config = $this->getIlluminateConfig());
$this->registerLogger($laravel);
$this->registerLogger($container);
$laravel->register(ErrorServiceProvider::class);
$laravel->register(LocaleServiceProvider::class);
@@ -75,12 +72,12 @@ class UninstalledSite implements SiteInterface
$laravel->register(InstallServiceProvider::class);
$laravel->singleton(
$container->singleton(
SettingsRepositoryInterface::class,
UninstalledSettingsRepository::class
);
$laravel->singleton('view', function ($app) {
$container->singleton('view', function ($app) {
$engines = new EngineResolver();
$engines->register('php', function () {
return new PhpEngine();
@@ -97,7 +94,7 @@ class UninstalledSite implements SiteInterface
$laravel->boot();
return $laravel;
return $container;
}
/**
@@ -108,7 +105,7 @@ class UninstalledSite implements SiteInterface
return new ConfigRepository([
'session' => [
'lifetime' => 120,
'files' => $this->paths['storage'].'/sessions',
'files' => $this->paths->storage.'/sessions',
'cookie' => 'session'
],
'view' => [
@@ -117,13 +114,13 @@ class UninstalledSite implements SiteInterface
]);
}
private function registerLogger(Application $app)
private function registerLogger(Container $container)
{
$logPath = $this->paths['storage'].'/logs/flarum-installer.log';
$logPath = $this->paths->storage.'/logs/flarum-installer.log';
$handler = new StreamHandler($logPath, Logger::DEBUG);
$handler->setFormatter(new LineFormatter(null, null, true, true));
$app->instance('log', new Logger('Flarum Installer', [$handler]));
$app->alias('log', LoggerInterface::class);
$container->instance('log', new Logger('Flarum Installer', [$handler]));
$container->alias('log', LoggerInterface::class);
}
}

View File

@@ -50,16 +50,12 @@ class JsCompiler extends RevisionCompiler
}
// Add a comment to the end of our file to point to the sourcemap
// we just constructed. We will then write the JS file, save the
// map to a temporary location, and then move it to the asset dir.
// we just constructed. We will then store the JS file and the map
// in our asset directory.
$output[] = '//# sourceMappingURL='.$this->assetsDir->url($mapFile);
$this->assetsDir->put($file, implode("\n", $output));
$mapTemp = @tempnam(storage_path('tmp'), $mapFile);
$map->save($mapTemp);
$this->assetsDir->put($mapFile, file_get_contents($mapTemp));
@unlink($mapTemp);
$this->assetsDir->put($mapFile, json_encode($map, JSON_UNESCAPED_SLASHES));
return true;
}

View File

@@ -10,6 +10,7 @@
namespace Flarum\Frontend;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\Paths;
use Flarum\Frontend\Compiler\Source\SourceCollector;
use Flarum\Http\UrlGenerator;
use Flarum\Settings\SettingsRepositoryInterface;
@@ -21,14 +22,16 @@ class FrontendServiceProvider extends AbstractServiceProvider
{
$this->app->singleton('flarum.assets.factory', function () {
return function (string $name) {
$paths = $this->app[Paths::class];
$assets = new Assets(
$name,
$this->app->make('filesystem')->disk('flarum-assets'),
$this->app->storagePath()
$paths->storage
);
$assets->setLessImportDirs([
$this->app->vendorPath().'/components/font-awesome/less' => ''
$paths->vendor.'/components/font-awesome/less' => ''
]);
$assets->css([$this, 'addBaseCss']);

View File

@@ -41,19 +41,6 @@ class GroupRepository
return $this->scopeVisibleTo($query, $actor)->firstOrFail();
}
/**
* Find a group by name.
*
* @param string $name
* @return User|null
*/
public function findByName($name, User $actor = null)
{
$query = Group::where('name_singular', $name)->orWhere('name_plural', $name);
return $this->scopeVisibleTo($query, $actor)->first();
}
/**
* Scope a query to only include records that are visible to a user.
*

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