1
0
mirror of https://github.com/flarum/core.git synced 2025-08-13 11:54:32 +02:00

Compare commits

...

86 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
Alexander Skvortsov
7794546845 Model extender: Fix inheritance (#2132)
This ensures that default values, date attributes and relationships are properly inherited, when we have deeper model class hierarchies.

This also adds test cases to ensure that inheritance order is honored for relationship and default attribute extender. As there's no way to remove date attributes, the order of evaluation there doesn't matter.
2020-04-24 21:17:31 +02:00
Franz Liedke
c43cc874ee Model extender: Add failing test
We determined that child classes are not properly affected when
extending the parent classes.

Refs #2100.
2020-04-24 17:54:30 +02:00
Franz Liedke
33cf94c192 Fix test to match its description
Refs #2100.
2020-04-24 17:31:08 +02:00
Franz Liedke
036e519865 Apply fixes from StyleCI
[ci skip] [skip ci]
2020-04-24 14:56:37 +00:00
Franz Liedke
9386c91af9 Tweak model extender tests
- Format code
- Reorder methods
- Test a different scenario to avoid the use of sleep()

Refs #2100.
2020-04-24 16:55:04 +02:00
Franz Liedke
8306cef963 Clean up model extender
- Remove unused private attributes
- Complete docblocks
- Add scalar type hints
- Format code
- Reorder methods

Refs #2100.
2020-04-24 16:33:08 +02:00
Franz Liedke
51ea326959 Apply fixes from StyleCI
[ci skip] [skip ci]
2020-04-24 13:10:36 +00:00
Alexander Skvortsov
15bed971e6 Add model extender (#2100)
This covers default attribute values, date attributes and custom relationships.
2020-04-24 15:10:24 +02:00
Franz Liedke
c896cd8696 npm audit fix 2020-04-24 14:30:16 +02:00
flarum-bot
54ac83d0b6 Bundled output for commit 1592cd1013 [skip ci] 2020-04-22 21:38:57 +00:00
Franz Liedke
1592cd1013 CI: Shorten the lint job name 2020-04-22 23:37:37 +02:00
Alexander Skvortsov
6e8884f190 Implement hidden permission groups (#2129)
Only users that have the new `viewHiddenGroups` permissions will be able to see these groups.

You might want this when you want to give certain users special permissions, but don't want to make your authorization scheme public to regular users.

Co-authored-by: luceos <daniel+github@klabbers.email>
2020-04-21 17:49:53 +02:00
Franz Liedke
df8f73bd3d Statically access Flarum version everywhere
One less reason to inject the huge Application class.

Refs #2055.
2020-04-21 16:48:36 +02:00
Franz Liedke
3f0f89afb1 Use Container contract where easily possible
Less usages of the Application god-class simplifies splitting it up.

Refs #2055.
2020-04-21 16:48:06 +02:00
Franz Liedke
f0f301c5f4 Add compatiblity with Composer 2.0
- The structure of vendor/composer/installed.json will change.
- The same file will now contain the relative path to package locations.

References:
- https://github.com/composer/composer/blob/master/UPGRADE-2.0.md
- https://php.watch/articles/composer-2
2020-04-21 15:47:58 +02:00
Franz Liedke
3045bde167 Format code
- Early returns
- Comments
- Write variables only when needed

Refs #2020.
2020-04-19 16:53:52 +02:00
Robert Korulczyk
ee7a4627d8 Load only translations for enabled extensions from language packs (#2020)
fix #1837

Co-authored-by: Daniel Klabbers <daniel+git@klabbers.email>
2020-04-19 16:29:45 +02:00
Franz Liedke
b9fb92d49a Inline test class
Refs #1977.
2020-04-19 15:55:10 +02:00
Clark Winkelmann
b5accca957 Make AbstractPolicy compatible with both object and class as $model (#1977) 2020-04-19 15:52:59 +02:00
155 changed files with 3082 additions and 2806 deletions

View File

@@ -1,4 +1,4 @@
name: Lint code
name: Lint
on:
push:
@@ -12,7 +12,7 @@ jobs:
prettier:
runs-on: ubuntu-latest
name: Lint JS code with Prettier
name: JS / Prettier
steps:
- uses: actions/checkout@master

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

828
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

@@ -3,6 +3,7 @@ import Button from '../../common/components/Button';
import Badge from '../../common/components/Badge';
import Group from '../../common/models/Group';
import ItemList from '../../common/utils/ItemList';
import Switch from '../../common/components/Switch';
/**
* The `EditGroupModal` component shows a modal dialog which allows the user
@@ -16,6 +17,7 @@ export default class EditGroupModal extends Modal {
this.namePlural = m.prop(this.group.namePlural() || '');
this.icon = m.prop(this.group.icon() || '');
this.color = m.prop(this.group.color() || '');
this.isHidden = m.prop(this.group.isHidden() || false);
}
className() {
@@ -89,6 +91,18 @@ export default class EditGroupModal extends Modal {
10
);
items.add(
'hidden',
<div className="Form-group">
{Switch.component({
state: !!Number(this.isHidden()),
children: app.translator.trans('core.admin.edit_group.hide_label'),
onchange: this.isHidden,
})}
</div>,
10
);
items.add(
'submit',
<div className="Form-group">
@@ -118,6 +132,7 @@ export default class EditGroupModal extends Modal {
namePlural: this.namePlural(),
color: this.color(),
icon: this.icon(),
isHidden: this.isHidden(),
};
}

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

@@ -112,6 +112,16 @@ export default class PermissionGrid extends Component {
100
);
items.add(
'viewHiddenGroups',
{
icon: 'fas fa-users',
label: app.translator.trans('core.admin.permissions.view_hidden_groups_label'),
permission: 'viewHiddenGroups',
},
100
);
items.add(
'viewUserList',
{

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

@@ -7,6 +7,7 @@ Object.assign(Group.prototype, {
namePlural: Model.attribute('namePlural'),
color: Model.attribute('color'),
icon: Model.attribute('icon'),
isHidden: Model.attribute('isHidden'),
});
Group.ADMINISTRATOR_ID = '1';

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

@@ -7,11 +7,8 @@
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Event;
use Flarum\Database\Migration;
/**
* @deprecated Will be removed in Beta.14. Use Flarum\Extend\Routes instead.
*/
class ConfigureApiRoutes extends AbstractConfigureRoutes
{
}
return Migration::addColumns('groups', [
'is_hidden' => ['boolean', 'default' => false]
]);

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

@@ -26,6 +26,8 @@ class ListGroupsController extends AbstractListController
*/
protected function data(ServerRequestInterface $request, Document $document)
{
return Group::all();
$actor = $request->getAttribute('actor');
return Group::whereVisibleTo($actor)->get();
}
}

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

@@ -45,6 +45,10 @@ class BasicUserSerializer extends AbstractSerializer
*/
protected function groups($user)
{
return $this->hasMany($user, GroupSerializer::class);
if ($this->getActor()->can('viewHiddenGroups')) {
return $this->hasMany($user, GroupSerializer::class);
}
return $this->hasMany($user, GroupSerializer::class, 'visibleGroups');
}
}

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

@@ -85,7 +85,7 @@ class ForumSerializer extends AbstractSerializer
if ($this->actor->can('administrate')) {
$attributes['adminUrl'] = $this->url->to('admin')->base();
$attributes['version'] = $this->app->version();
$attributes['version'] = Application::VERSION;
}
return $attributes;

View File

@@ -52,6 +52,7 @@ class GroupSerializer extends AbstractSerializer
'namePlural' => $this->translateGroupName($group->name_plural),
'color' => $group->color,
'icon' => $group->icon,
'isHidden' => $group->is_hidden
];
}

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 Flarum\Foundation\Application;
use Illuminate\Console\Command;
use Symfony\Component\Console\Application as ConsoleApplication;
/**
* @deprecated
*/
class Configuring
{
/**
* @var Application
*/
public $app;
/**
* @var ConsoleApplication
*/
public $console;
/**
* @param Application $app
* @param ConsoleApplication $console
*/
public function __construct(Application $app, ConsoleApplication $console)
{
$this->app = $app;
$this->console = $console;
}
/**
* Add a console command to the flarum binary.
*
* @param Command|string $command
*/
public function addCommand($command)
{
if (is_string($command)) {
$command = $this->app->make($command);
}
if ($command instanceof Command) {
$command->setLaravel($this->app);
}
$this->console->add($command);
}
}

View File

@@ -9,13 +9,11 @@
namespace Flarum\Console;
use Flarum\Console\Event\Configuring;
use Flarum\Foundation\Application;
use Flarum\Foundation\ErrorHandling\Registry;
use Flarum\Foundation\ErrorHandling\Reporter;
use Flarum\Foundation\SiteInterface;
use Illuminate\Contracts\Events\Dispatcher;
use Symfony\Component\Console\Application as ConsoleApplication;
use Illuminate\Container\Container;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\ConsoleEvents;
use Symfony\Component\Console\Event\ConsoleErrorEvent;
use Symfony\Component\EventDispatcher\EventDispatcher;
@@ -33,43 +31,30 @@ class Server
{
$app = $this->site->bootApp();
$console = new ConsoleApplication('Flarum', Application::VERSION);
$console = new Application('Flarum', \Flarum\Foundation\Application::VERSION);
foreach ($app->getConsoleCommands() as $command) {
$console->add($command);
}
$this->extend($console); // deprecated
$this->handleErrors($console);
exit($console->run());
}
/**
* @deprecated
*/
private function extend(ConsoleApplication $console)
{
$app = Application::getInstance();
$this->handleErrors($app, $console);
$events = $app->make(Dispatcher::class);
$events->dispatch(new Configuring($app, $console));
}
private function handleErrors(Application $app, ConsoleApplication $console)
private function handleErrors(Application $console)
{
$dispatcher = new EventDispatcher();
$dispatcher->addListener(ConsoleEvents::ERROR, function (ConsoleErrorEvent $event) use ($app) {
/** @var Registry $registry */
$registry = $app->make(Registry::class);
$dispatcher->addListener(ConsoleEvents::ERROR, function (ConsoleErrorEvent $event) {
$container = Container::getInstance();
/** @var Registry $registry */
$registry = $container->make(Registry::class);
$error = $registry->handle($event->getError());
/** @var Reporter[] $reporters */
$reporters = $app->tagged(Reporter::class);
$reporters = $container->tagged(Reporter::class);
if ($error->shouldBeReported()) {
foreach ($reporters as $reporter) {

View File

@@ -9,11 +9,9 @@
namespace Flarum\Database;
use Flarum\Event\ConfigureModelDates;
use Flarum\Event\ConfigureModelDefaultAttributes;
use Flarum\Event\GetModelRelationship;
use Illuminate\Database\Eloquent\Model as Eloquent;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Arr;
use LogicException;
/**
@@ -46,6 +44,12 @@ abstract class AbstractModel extends Eloquent
*/
protected $afterDeleteCallbacks = [];
public static $customRelations = [];
public static $dateAttributes = [];
public static $defaults = [];
/**
* {@inheritdoc}
*/
@@ -71,13 +75,15 @@ abstract class AbstractModel extends Eloquent
*/
public function __construct(array $attributes = [])
{
$defaults = [];
$this->attributes = [];
static::$dispatcher->dispatch(
new ConfigureModelDefaultAttributes($this, $defaults)
);
foreach (array_merge(array_reverse(class_parents($this)), [static::class]) as $class) {
$this->attributes = array_merge($this->attributes, Arr::get(static::$defaults, $class, []));
}
$this->attributes = $defaults;
$this->attributes = array_map(function ($item) {
return is_callable($item) ? $item() : $item;
}, $this->attributes);
parent::__construct($attributes);
}
@@ -89,19 +95,13 @@ abstract class AbstractModel extends Eloquent
*/
public function getDates()
{
static $dates = [];
$dates = $this->dates;
$class = get_class($this);
if (! isset($dates[$class])) {
static::$dispatcher->dispatch(
new ConfigureModelDates($this, $this->dates)
);
$dates[$class] = $this->dates;
foreach (array_merge(array_reverse(class_parents($this)), [static::class]) as $class) {
$dates = array_merge($dates, Arr::get(static::$dateAttributes, $class, []));
}
return $dates[$class];
return $dates;
}
/**
@@ -139,9 +139,12 @@ abstract class AbstractModel extends Eloquent
*/
protected function getCustomRelation($name)
{
return static::$dispatcher->until(
new GetModelRelationship($this, $name)
);
foreach (array_merge([static::class], class_parents($this)) as $class) {
$relation = Arr::get(static::$customRelations, $class.".$name", null);
if (! is_null($relation)) {
return $relation($this);
}
}
}
/**

View File

@@ -13,23 +13,32 @@ use Flarum\Console\AbstractCommand;
use Flarum\Database\Migrator;
use Flarum\Extension\ExtensionManager;
use Flarum\Foundation\Application;
use Flarum\Foundation\Paths;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Container\Container;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Schema\Builder;
class MigrateCommand extends AbstractCommand
{
/**
* @var Application
* @var Container
*/
protected $app;
protected $container;
/**
* @param Application $application
* @var Paths
*/
public function __construct(Application $application)
protected $paths;
/**
* @param Container $container
* @param Paths $paths
*/
public function __construct(Container $container, Paths $paths)
{
$this->app = $application;
$this->container = $container;
$this->paths = $paths;
parent::__construct();
}
@@ -58,16 +67,16 @@ class MigrateCommand extends AbstractCommand
public function upgrade()
{
$this->app->bind(Builder::class, function ($app) {
return $app->make(ConnectionInterface::class)->getSchemaBuilder();
$this->container->bind(Builder::class, function ($container) {
return $container->make(ConnectionInterface::class)->getSchemaBuilder();
});
$migrator = $this->app->make(Migrator::class);
$migrator = $this->container->make(Migrator::class);
$migrator->setOutput($this->output);
$migrator->run(__DIR__.'/../../../migrations');
$extensions = $this->app->make(ExtensionManager::class);
$extensions = $this->container->make(ExtensionManager::class);
$extensions->getMigrator()->setOutput($this->output);
foreach ($extensions->getEnabledExtensions() as $name => $extension) {
@@ -78,13 +87,13 @@ class MigrateCommand extends AbstractCommand
}
}
$this->app->make(SettingsRepositoryInterface::class)->set('version', $this->app->version());
$this->container->make(SettingsRepositoryInterface::class)->set('version', Application::VERSION);
$this->info('Publishing assets...');
$this->app->make('files')->copyDirectory(
$this->app->vendorPath().'/components/font-awesome/webfonts',
$this->app->publicPath().'/assets/fonts'
$this->container->make('files')->copyDirectory(
$this->paths->vendor.'/components/font-awesome/webfonts',
$this->paths->public.'/assets/fonts'
);
}
}

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,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,48 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Event;
use Flarum\Database\AbstractModel;
/**
* The `ConfigureModelDates` event is called to retrieve a list of fields for a model
* that should be converted into date objects.
*/
class ConfigureModelDates
{
/**
* @var AbstractModel
*/
public $model;
/**
* @var array
*/
public $dates;
/**
* @param AbstractModel $model
* @param array $dates
*/
public function __construct(AbstractModel $model, array &$dates)
{
$this->model = $model;
$this->dates = &$dates;
}
/**
* @param string $model
* @return bool
*/
public function isModel($model)
{
return $this->model instanceof $model;
}
}

View File

@@ -1,44 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Event;
use Flarum\Database\AbstractModel;
class ConfigureModelDefaultAttributes
{
/**
* @var AbstractModel
*/
public $model;
/**
* @var array
*/
public $attributes;
/**
* @param AbstractModel $model
* @param array $attributes
*/
public function __construct(AbstractModel $model, array &$attributes)
{
$this->model = $model;
$this->attributes = &$attributes;
}
/**
* @param string $model
* @return bool
*/
public function isModel($model)
{
return $this->model instanceof $model;
}
}

View File

@@ -1,49 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Event;
use Flarum\Database\AbstractModel;
/**
* The `GetModelRelationship` event is called to retrieve Relation object for a
* model. Listeners should return an Eloquent Relation object.
*/
class GetModelRelationship
{
/**
* @var AbstractModel
*/
public $model;
/**
* @var string
*/
public $relationship;
/**
* @param AbstractModel $model
* @param string $relationship
*/
public function __construct(AbstractModel $model, $relationship)
{
$this->model = $model;
$this->relationship = $relationship;
}
/**
* @param string $model
* @param string $relationship
* @return bool
*/
public function isRelationship($model, $relationship)
{
return $this->model instanceof $model && $this->relationship === $relationship;
}
}

View File

@@ -11,13 +11,20 @@ namespace Flarum\Extend;
use DirectoryIterator;
use Flarum\Extension\Extension;
use Flarum\Extension\ExtensionManager;
use Flarum\Locale\LocaleManager;
use Illuminate\Contracts\Container\Container;
use InvalidArgumentException;
use RuntimeException;
use SplFileInfo;
class LanguagePack implements ExtenderInterface, LifecycleInterface
{
private const CORE_LOCALE_FILES = [
'core',
'validation',
];
private $path;
/**
@@ -49,13 +56,13 @@ class LanguagePack implements ExtenderInterface, LifecycleInterface
$container->resolving(
LocaleManager::class,
function (LocaleManager $locales) use ($extension, $locale, $title) {
$this->registerLocale($locales, $extension, $locale, $title);
function (LocaleManager $locales, Container $container) use ($extension, $locale, $title) {
$this->registerLocale($container, $locales, $extension, $locale, $title);
}
);
}
private function registerLocale(LocaleManager $locales, Extension $extension, $locale, $title)
private function registerLocale(Container $container, LocaleManager $locales, Extension $extension, $locale, $title)
{
$locales->addLocale($locale, $title);
@@ -76,12 +83,41 @@ class LanguagePack implements ExtenderInterface, LifecycleInterface
}
foreach (new DirectoryIterator($directory) as $file) {
if ($file->isFile() && in_array($file->getExtension(), ['yml', 'yaml'])) {
if ($this->shouldLoad($file, $container)) {
$locales->addTranslations($locale, $file->getPathname());
}
}
}
private function shouldLoad(SplFileInfo $file, Container $container)
{
if (! $file->isFile()) {
return false;
}
// We are only interested in YAML files
if (! in_array($file->getExtension(), ['yml', 'yaml'], true)) {
return false;
}
// Some language packs include translations for many extensions
// from the ecosystems. For performance reasons, we should only
// load those that belong to core, or extensions that are enabled.
// To identify them, we compare the filename (without the YAML
// extension) with the list of known names and all extension IDs.
$slug = $file->getBasename(".{$file->getExtension()}");
if (in_array($slug, self::CORE_LOCALE_FILES, true)) {
return true;
}
/** @var ExtensionManager $extensions */
static $extensions;
$extensions = $extensions ?? $container->make(ExtensionManager::class);
return $extensions->isEnabled($slug);
}
public function onEnable(Container $container, Extension $extension)
{
$container->make('flarum.locales')->clearCache();

182
src/Extend/Model.php Normal file
View File

@@ -0,0 +1,182 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Extend;
use Flarum\Database\AbstractModel;
use Flarum\Extension\Extension;
use Illuminate\Contracts\Container\Container;
use Illuminate\Support\Arr;
class Model implements ExtenderInterface
{
private $modelClass;
/**
* @param string $modelClass The ::class attribute of the model you are modifying.
* This model should extend from \Flarum\Database\AbstractModel.
*/
public function __construct(string $modelClass)
{
$this->modelClass = $modelClass;
}
/**
* Add an attribute to be treated as a date.
*
* @param string $attribute
* @return self
*/
public function dateAttribute(string $attribute)
{
Arr::set(
AbstractModel::$dateAttributes,
$this->modelClass,
array_merge(
Arr::get(AbstractModel::$dateAttributes, $this->modelClass, []),
[$attribute]
)
);
return $this;
}
/**
* Add a default value for a given attribute, which can be an explicit value, or a closure.
*
* @param string $attribute
* @param mixed $value
* @return self
*/
public function default(string $attribute, $value)
{
Arr::set(AbstractModel::$defaults, "$this->modelClass.$attribute", $value);
return $this;
}
/**
* Establish a simple belongsTo relationship from this model to another model.
* This represents an inverse one-to-one or inverse one-to-many relationship.
* For more complex relationships, use the ->relationship method.
*
* @param string $name: The name of the relation. This doesn't have to be anything in particular,
* but has to be unique from other relation names for this model, and should
* work as the name of a method.
* @param string $related: The ::class attribute of the model, which should extend \Flarum\Database\AbstractModel.
* @param string $foreignKey: The foreign key attribute of the parent model.
* @param string $ownerKey: The primary key attribute of the parent model.
* @return self
*/
public function belongsTo(string $name, string $related, string $foreignKey = null, string $ownerKey = null)
{
return $this->relationship($name, function (AbstractModel $model) use ($related, $foreignKey, $ownerKey, $name) {
return $model->belongsTo($related, $foreignKey, $ownerKey, $name);
});
}
/**
* Establish a simple belongsToMany relationship from this model to another model.
* This represents a many-to-many relationship.
* For more complex relationships, use the ->relationship method.
*
* @param string $name: The name of the relation. This doesn't have to be anything in particular,
* but has to be unique from other relation names for this model, and should
* work as the name of a method.
* @param string $related: The ::class attribute of the model, which should extend \Flarum\Database\AbstractModel.
* @param string $table: The intermediate table for this relation
* @param string $foreignPivotKey: The foreign key attribute of the parent model.
* @param string $relatedPivotKey: The associated key attribute of the relation.
* @param string $parentKey: The key name of the parent model.
* @param string $relatedKey: The key name of the related model.
* @return self
*/
public function belongsToMany(
string $name,
string $related,
string $table = null,
string $foreignPivotKey = null,
string $relatedPivotKey = null,
string $parentKey = null,
string $relatedKey = null
) {
return $this->relationship($name, function (AbstractModel $model) use ($related, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $name) {
return $model->belongsToMany($related, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $name);
});
}
/**
* Establish a simple hasOne relationship from this model to another model.
* This represents a one-to-one relationship.
* For more complex relationships, use the ->relationship method.
*
* @param string $name: The name of the relation. This doesn't have to be anything in particular,
* but has to be unique from other relation names for this model, and should
* work as the name of a method.
* @param string $related: The ::class attribute of the model, which should extend \Flarum\Database\AbstractModel.
* @param string $foreignKey: The foreign key attribute of the parent model.
* @param string $localKey: The primary key attribute of the parent model.
* @return self
*/
public function hasOne(string $name, string $related, string $foreignKey = null, string $localKey = null)
{
return $this->relationship($name, function (AbstractModel $model) use ($related, $foreignKey, $localKey) {
return $model->hasOne($related, $foreignKey, $localKey);
});
}
/**
* Establish a simple hasMany relationship from this model to another model.
* This represents a one-to-many relationship.
* For more complex relationships, use the ->relationship method.
*
* @param string $name: The name of the relation. This doesn't have to be anything in particular,
* but has to be unique from other relation names for this model, and should
* work as the name of a method.
* @param string $related: The ::class attribute of the model, which should extend \Flarum\Database\AbstractModel.
* @param string $foreignKey: The foreign key attribute of the parent model.
* @param string $localKey: The primary key attribute of the parent model.
* @return self
*/
public function hasMany(string $name, string $related, string $foreignKey = null, string $localKey = null)
{
return $this->relationship($name, function (AbstractModel $model) use ($related, $foreignKey, $localKey) {
return $model->hasMany($related, $foreignKey, $localKey);
});
}
/**
* Add a relationship from this model to another model.
*
* @param string $name: The name of the relation. This doesn't have to be anything in particular,
* but has to be unique from other relation names for this model, and should
* work as the name of a method.
* @param callable $callable
*
* The callable can be a closure or invokable class, and should accept:
* - $instance: An instance of this model.
*
* The callable should return:
* - $relationship: A Laravel Relationship object. See relevant methods of models
* like \Flarum\User\User for examples of how relationships should be returned.
*
* @return self
*/
public function relationship(string $name, callable $callable)
{
Arr::set(AbstractModel::$customRelations, "$this->modelClass.$name", $callable);
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
// Nothing needed here.
}
}

38
src/Extend/User.php Normal file
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,12 @@ class ExtensionManager
{
protected $config;
protected $app;
/**
* @var Paths
*/
protected $paths;
protected $container;
protected $migrator;
@@ -50,13 +55,15 @@ class ExtensionManager
public function __construct(
SettingsRepositoryInterface $config,
Application $app,
Paths $paths,
Container $container,
Migrator $migrator,
Dispatcher $dispatcher,
Filesystem $filesystem
) {
$this->config = $config;
$this->app = $app;
$this->paths = $paths;
$this->container = $container;
$this->migrator = $migrator;
$this->dispatcher = $dispatcher;
$this->filesystem = $filesystem;
@@ -67,18 +74,26 @@ class ExtensionManager
*/
public function getExtensions()
{
if (is_null($this->extensions) && $this->filesystem->exists($this->app->vendorPath().'/composer/installed.json')) {
if (is_null($this->extensions) && $this->filesystem->exists($this->paths->vendor.'/composer/installed.json')) {
$extensions = new Collection();
// Load all packages installed by composer.
$installed = json_decode($this->filesystem->get($this->app->vendorPath().'/composer/installed.json'), true);
$installed = json_decode($this->filesystem->get($this->paths->vendor.'/composer/installed.json'), true);
// Composer 2.0 changes the structure of the installed.json manifest
$installed = $installed['packages'] ?? $installed;
foreach ($installed as $package) {
if (Arr::get($package, 'type') != 'flarum-extension' || empty(Arr::get($package, 'name'))) {
continue;
}
$path = isset($package['install-path'])
? $this->paths->vendor.'/composer/'.$package['install-path']
: $this->paths->vendor.'/'.Arr::get($package, 'name');
// Instantiates an Extension object using the package path and composer.json file.
$extension = new Extension($this->getExtensionsDir().'/'.Arr::get($package, 'name'), $package);
$extension = new Extension($path, $package);
// Per default all extensions are installed if they are registered in composer.
$extension->setInstalled(true);
@@ -130,7 +145,7 @@ class ExtensionManager
$this->setEnabled($enabled);
$extension->enable($this->app);
$extension->enable($this->container);
$this->dispatcher->dispatch(new Enabled($extension));
}
@@ -156,7 +171,7 @@ class ExtensionManager
$this->setEnabled($enabled);
$extension->disable($this->app);
$extension->disable($this->container);
$this->dispatcher->dispatch(new Disabled($extension));
}
@@ -191,7 +206,7 @@ class ExtensionManager
if ($extension->hasAssets()) {
$this->filesystem->copyDirectory(
$extension->getPath().'/assets',
$this->app->publicPath().'/assets/extensions/'.$extension->getId()
$this->paths->public.'/assets/extensions/'.$extension->getId()
);
}
}
@@ -203,7 +218,7 @@ class ExtensionManager
*/
protected function unpublishAssets(Extension $extension)
{
$this->filesystem->deleteDirectory($this->app->publicPath().'/assets/extensions/'.$extension->getId());
$this->filesystem->deleteDirectory($this->paths->public.'/assets/extensions/'.$extension->getId());
}
/**
@@ -215,7 +230,7 @@ class ExtensionManager
*/
public function getAsset(Extension $extension, $path)
{
return $this->app->publicPath().'/assets/extensions/'.$extension->getId().$path;
return $this->paths->public.'/assets/extensions/'.$extension->getId().$path;
}
/**
@@ -227,7 +242,7 @@ class ExtensionManager
*/
public function migrate(Extension $extension, $direction = 'up')
{
$this->app->bind(Builder::class, function ($container) {
$this->container->bind(Builder::class, function ($container) {
return $container->make(ConnectionInterface::class)->getSchemaBuilder();
});
@@ -320,14 +335,4 @@ class ExtensionManager
return isset($enabled[$extension]);
}
/**
* The extensions path.
*
* @return string
*/
protected function getExtensionsDir()
{
return $this->app->vendorPath();
}
}

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

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