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

Compare commits

...

56 Commits

Author SHA1 Message Date
Alexander Skvortsov
3ef541a152 Webpack 5, use v1 of flarum webpack config 2021-05-09 14:02:34 -04:00
Alexander Skvortsov
81894d7cc2 Implement registry, move patchMithril call to Application boot. 2021-05-09 14:01:10 -04:00
Alexander Skvortsov
97aa569bfa Don't export objects directly 2021-05-09 13:59:34 -04:00
Alexander Skvortsov
7d80b88d5c Add semicolons in flarum.extension assignments
Without these, content generated by webpack 5 breaks
2021-05-09 01:30:13 -04:00
flarum-bot
2cd1c2964a Bundled output for commit 8a451e0bfc [skip ci] 2021-05-07 16:31:01 +00:00
Alexander Skvortsov
8a451e0bfc Fix exception in bootExtensions
Frontend extenders exist in a weird state of limbo, where they are technically defined, but aren't used or tested at all. In da5db714c2, we shifted from passing `extension.extend` to `flattenDeep` to calling `flat` on `extension.extend`. If an extension doesn't define extenders (as is the case for most extensions), the change breaks the forum. All we do here is add a null check.
2021-05-07 12:29:10 -04:00
flarum-bot
0b9ad5425c Bundled output for commit da5db714c2 [skip ci] 2021-05-05 23:29:41 +00:00
David Wheatley
da5db714c2 Remove lodash from core (#2827)
* Remove `lodash-es` dependency

* Replace `escapeRegExp` with home-made util

* Replace `throttle` with `throttle-debounce` library

* Use native browser methods for `deepFlatten`

We need a polyfill for iOS 11 and below. I think using a native method with this polyfill is better than having our own function instead, even if the bundle size is ~150B more.

* Save a few bytes in `escapeRegExp`

* Fix typo in comment

* Undo import re-organisation

* Use spread instead of slice

* Use smaller Array.flat polyfill from MDN

* Export new utils in `compat.js`
2021-05-06 00:28:22 +01:00
flarum-bot
d4a2357a32 Bundled output for commit 588a9f952f [skip ci] 2021-05-05 14:47:48 +00:00
David Wheatley
588a9f952f Remove unneeded delete (#2835) 2021-05-05 15:46:23 +01:00
Alexander Skvortsov
66233ce818 Remove unused variable 2021-05-04 18:15:05 -04:00
Alexander Skvortsov
7d4bd8a845 Centralize permission caching (#2832) 2021-05-04 13:56:14 -04:00
David Wheatley
3a6b8847f1 Mark JS dist folder as generated code (#2828)
This excludes it from the repo's language stats and are suppressed in Linguist diffs.

See: https://github.com/github/linguist/blob/master/docs/overrides.md#generated-code
2021-05-04 18:13:42 +01:00
Robert Korulczyk
2ffec2ee71 Update validation.yml. (#2829)
source: https://github.com/laravel/laravel/blob/v8.5.16/resources/lang/en/validation.php
2021-05-03 19:47:18 -04:00
Matt Kilgore
7eea2476ca Harden Headers (#2721)
* Basic security headers

* Remove XSS Header (not relevent)

* Fix config name

* Use Arr::get()

* Add tests

* Re-fix the StoreConfig step for fresh installs

Co-authored-by: luceos <luceos@users.noreply.github.com>
Co-authored-by: Alexander Skvortsov <askvortsov1@users.noreply.github.com>
2021-05-03 12:42:06 -04:00
Alexander Skvortsov
9711af42ae Apply fixes from StyleCI
[ci skip] [skip ci]
2021-05-03 05:36:06 +00:00
Alexander Skvortsov
d12d52918b Use latest version of settings package
This allows us to get rid of hacks for configuring settings and config
2021-05-03 01:35:46 -04:00
flarum-bot
ad92d11cf9 Bundled output for commit 3ca035f9aa [skip ci] 2021-05-02 16:14:09 +00:00
David Wheatley
3ca035f9aa Revamp notifications stylesheet (grid and flex) (#2822) 2021-05-02 17:13:04 +01:00
flarum-bot
bbff3a2748 Bundled output for commit f5cd5f202f [skip ci] 2021-05-02 10:38:26 +00:00
David Wheatley
f5cd5f202f Allow multiple methods to be provided to extend and override 2021-05-02 11:37:19 +01:00
flarum-bot
a78cbf644c Bundled output for commit 2de47a8656 [skip ci] 2021-04-30 17:00:49 +00:00
Alexander Skvortsov
2de47a8656 Fix package-lock
b45519974a accidentially commited a package-lock with symlinks, breaking the JS build process
2021-04-30 12:59:33 -04:00
Alexander Skvortsov
b45519974a Switch to ICU MessageFormat (#2759) 2021-04-30 12:44:39 -04:00
Alexander Skvortsov
edaf45d133 Remove unnecessary laravel config (#2796) 2021-04-30 00:31:19 +02:00
Matt Kilgore
6b9e991082 Move Powered By Header to headers config (#2777)
* Move Powered By Header to headers config
* Use Arr::get()
2021-04-30 00:30:01 +02:00
David Wheatley
8a431dc3cc [A11Y] Add focus ring mixin to restore ring to elements which no longer have it (#2814)
* Add focus ring mixin

These mixins allow us to restore default browser focus rings on elements which no longer have them.

* Add info about custom outline styles; use `#private` namespace and fix mixin name

I just learned that Less has namespaces! https://lesscss.org/features/#mixins-feature-namespaces
2021-04-29 22:10:17 +01:00
David Wheatley
91b1d9029e LESS should be capitalised as Less
See http://lesscss.org/
2021-04-29 22:07:46 +01:00
Daniël Klabbers
e337c10bb8 Revision compiler revised (#2805)
- revisions now use <asset>.<type>?v=<revision> instead of <asset>-<revision>.<type>- remove deprecated filename for revision method
- reconsider use of cache differentiator and implement something that
prevents recompiling css every single time
- allow force recompilation
2021-04-29 16:49:36 -04:00
Daniël Klabbers
e0258d2708 error handling when extending flarum from extensions fails (#2740) 2021-04-29 16:17:41 -04:00
Daniël Klabbers
fcb5778705 fixed container bindings use of container (#2807) 2021-04-29 15:33:51 -04:00
Sami Mazouz
40b47de9e1 Remove ExtensionPage CSS over-specification (#2792) 2021-04-29 16:31:37 +01:00
Daniël Klabbers
deadd67691 clarify callable arguments for password checker (#2812) 2021-04-29 10:19:06 -04:00
flarum-bot
c119731e65 Bundled output for commit 2b7e7f3ff4 [skip ci] 2021-04-26 16:15:37 +00:00
Sami Mazouz
2b7e7f3ff4 Fix class naming (#2811) 2021-04-26 17:14:22 +01:00
flarum-bot
f4acb2c5db Bundled output for commit f9779284e4 [skip ci] 2021-04-22 22:37:03 +00:00
David Wheatley
f9779284e4 Add users list to admin dashboard (#2626)
* Commit initial WIP code

* Fix squashed grid on mobile

* Add pagination support; rename to userList

* Improve grid sizing

* Improve grid row shading

* Move EditUserModal to common

* Add link to profile page in grid

* Use Less styling vars

* Move EditUserModal translations to lib

* Add edit user button to grid

* Fix incorrect profile link priority

* Update profile link translation key

* Add priorities to other columns

* Add group badges to grid

* Add username to profile link tooltip

* Organise imports

* Use variable for header border bottom color

* Fix broken export

* Add total user count to API payload's metadata

* Add new metadata to ApiPayload type

* Implement correct page number

* Remove debug code

* Use function to get the total pages

This allows us to use the raw count elsewhere in the component (pssst... check the next commit!)

* Center profile link in column

* Add profile link header

* Show total users above table

* Use ItemList's itemName property for column data attributes

* Add user email column, hidden by default

This column is hidden by default using a placeholder email and blur filter. These are then removed when the visibility toggle is pressed.

This prevents any over-the-shoulder accidental data leakage, as emails are classed as PII under GDPR.

* Fix incorrect tooltip translation keys

* Add extra padding between email and visibility toggle button

* Prevent selection of blurred email

* Fix incorrect icon state for email toggle

* Update API response type to include metadata (for now)

* Increase number of users per page to 50

* Update compat files with new locations

* Format

* Add @deprecated notices for forum compat export

* Use AdminPayload for user count instead of supplying as REST API metadata

* Make nav look less squashed using bottom margin

* Suppress TS warning

* StyleCI fixes

* Fix TS error

* Update based on review comments

* Rename user list -> users

* Rename internal instances of user_list to users

* Fix formatting

* Use CSS custom properties for the table column count

* Use .Button--icon instead of custom style

* Make fake email more realistic length

* Add a11y attributes

* Use padding bottom instead of margin bottom for page spacing

* Make compatible with new CSS LoadingIndicator

I won't let it break here! :P

* Integrate profile link into username column

* Don't force columns to be 300px

This made the grid look very bloated and intimidating -- lets instead increase the padding between items and make it only the width it needs to be.

* Center edit user button in column

* Increase spacing between email and visibility toggle button

* Rename `statistics` to `modelStatistics` in Admin payload

This prevents any possible conflicts with core and `flarum/statistics`. We might want to consider migrating the stats extension to extend this object in the future.

* Update comments, fix TS error

* Various translation key changes

* Change gmail.com -> example.com

* Stretch 'edit user' button to entire cell size

* Update translations

* Is the YAML formatted right this time? 🙈

* Remove email placeholder

Fixes an issue where the table would jump if an email was unhidden that was longer than the placeholder.

* Re-order lib translations

* Clicking blurred email now unblurs

* Correct header class

* Improve edit user button centring

* Improve vertical row item centering

* Fix incorrect column length in aria attribute

* Use .Button--text!
2021-04-22 23:35:42 +01:00
flarum-bot
43d6b3104d Bundled output for commit 33bd99d376 [skip ci] 2021-04-21 11:27:23 +00:00
David Wheatley
33bd99d376 Fix uses of loading spinner (#2797)
* Update Loading Indicator

- Fix mistake in LoadingIndicator Less
- Middle align the loading indicator when inline
- Fix Loading Indicator not correctly accepting container class names
- Add inline and block attributes

* Fix loading indicator in composer

* Fix loading indicator on notification list

* Fix loading indicator on discussion page

* Fix loading indicator on button

* Update more uses of loading indicator

* Fix loading indicator in Search box

* Fix AvatarEditor loading spinner

* Set default spinner props

* Replace "tiny" with "small" in Less

* Improve spinner vertical centring in buttons

* Reduce size specificity

* Use single attribute for block/inline

* Use new display attribute

* Use classes for different sizes

* Use `display=block` by default
2021-04-21 12:26:09 +01:00
Alexander Skvortsov
eb4b18a979 Combine search tests
#b62debf031f1d3aec9cb5e92d9df54cb8ab3a3b1 and #b6f0b01307884b11388eff1ae2d814b7f57715aa
 both added/improved searching tests, but did so in separate files. As a result, the tests did not consider each other, and when both were merged, started failing. This commit combines the tests into one file that tests both order and search in titles.
2021-04-20 19:16:59 -04:00
Sami Mazouz
b62debf031 Add user id slug driver (#2787) 2021-04-20 23:52:53 +01:00
Alexander Skvortsov
1f2411e15e Fix searching titles in discussions (#2698)
* Fix searching titles in discussions

* Apply fixes from StyleCI

* Fix tests

* Distinct by discussion ID

* Replace distinct with groupBy

Co-authored-by: Alexander Skvortsov <askvortsov1@users.noreply.github.com>
2021-04-20 18:52:14 -04:00
flarum-bot
d99df936b1 Bundled output for commit 9716a15c31 [skip ci] 2021-04-20 16:26:39 +00:00
David Wheatley
9716a15c31 Add accessibility attributes to loading spinner (#2799) 2021-04-20 17:25:23 +01:00
Alexander Skvortsov
5e2340bf10 Fix registering custom searchers, allow searchers without fulltext (#2755) 2021-04-19 16:59:53 -04:00
Alexander Skvortsov
c84939b19c Filesystem Extender and Tests (#2732) 2021-04-19 16:25:08 -04:00
Alexander Skvortsov
4974c91481 Asset Publish Command (#2731) 2021-04-19 15:51:28 -04:00
Alexander Skvortsov
f67149bb06 Use Laravel filesystem interface for assets and avatars (#2729)
* WIP: Use Laravel filesystem interface where possible
* Drop vendorFilesystem
* Support getting URL of cloud-based logo and favicon
* FilesystemAdapter should always be cloud
* Get base avatar URL from filesystem adapter
* Restore deleted getAsset method

Co-authored-by: Alexander Skvortsov <askvortsov1@users.noreply.github.com>
2021-04-19 21:11:03 +02:00
Alexander Skvortsov
a2d77d7b81 Rename relevant migration so it runs again (#2793) 2021-04-19 14:14:07 -04:00
flarum-bot
da4264c8a3 Bundled output for commit 0f9526ba9f [skip ci] 2021-04-19 14:37:25 +00:00
Alexander Skvortsov
0f9526ba9f Adjust search height on resize (#2775)
Identified as a potential issue in https://github.com/flarum/core/pull/2650

When typing, the keyboard generally obstructs half the screen. However, when the keyboard is closed, search results don't expand to take up full space.
2021-04-19 10:36:04 -04:00
Alexander Skvortsov
e77365f32f Add id to migrations table (#2794) 2021-04-19 10:35:21 -04:00
Alexander Skvortsov
c7c456cb3e Remove unused container argument 2021-04-18 17:20:14 -04:00
Alexander Skvortsov
fb51fb4e6d Drop session from user class (#2790)
This was originally introduced in 3612ca7aca, but has not seen usage, since usually when the session needs to be modified, the request is available.

It causes issues with certain queue drivers, as it can't be serialized.

It's also not entirely accurate, as a user can have multiple sessions at once. Therefore, a given session is a property of the request, not of the user.

The reason this causes issues in the Queue is that when a Job has payload that consists User(s), the Queue will try to serialize that. Serializing the User object will require serializing the session too; this causes a Serialization of Closure is not allowed error, see image.

One can circumvent that in many ways, the most obvious one is adding a __sleep and __wakeup implementation in the User class (or the session handler). But as we aren't really using the session on the User model anywhere in core, bundled or most community extensions it is best to simply detach this from the user.
2021-04-16 15:53:05 -04:00
Sami Mazouz
5b7d364b87 Update laravel docs references to 8.x (#2788) 2021-04-16 13:26:15 +01:00
Sami Mazouz
39a6106854 Add unparse to Formatter extender (#2780) 2021-04-14 11:34:49 +01:00
154 changed files with 3233 additions and 1576 deletions

1
.gitattributes vendored
View File

@@ -11,5 +11,6 @@ phpunit.xml export-ignore
tests export-ignore
js/dist/* -diff
js/dist/* linguist-generated
* text=auto eol=lf

View File

@@ -63,13 +63,14 @@
"symfony/console": "^5.2.2",
"symfony/event-dispatcher": "^5.2.2",
"symfony/mime": "^5.2.0",
"symfony/polyfill-intl-messageformatter": "^1.22.0",
"symfony/translation": "^5.1.5",
"symfony/yaml": "^5.2.2",
"tobscure/json-api": "^0.3.0",
"wikimedia/less.php": "^3.0"
},
"require-dev": {
"flarum/testing": "^0.1.0-beta.16"
"flarum/testing": "dev-main#81e25f034e2b6dceaea753ad7579b5c61d641993"
},
"autoload": {
"psr-4": {

6
js/dist/admin.js generated vendored

File diff suppressed because one or more lines are too long

2
js/dist/admin.js.map generated vendored

File diff suppressed because one or more lines are too long

8
js/dist/forum.js generated vendored

File diff suppressed because one or more lines are too long

2
js/dist/forum.js.map generated vendored

File diff suppressed because one or more lines are too long

816
js/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,35 +2,36 @@
"private": true,
"name": "@flarum/core",
"dependencies": {
"@askvortsov/rich-icu-message-formatter": "^0.1.0",
"@ultraq/icu-message-formatter": "^0.10.0",
"bootstrap": "^3.4.1",
"clsx": "^1.1.1",
"color-thief-browser": "^2.0.2",
"dayjs": "^1.10.4",
"expose-loader": "^1.0.3",
"expose-loader": "^2.0.0",
"jquery": "^3.6.0",
"jquery.hotkeys": "^0.1.0",
"lodash-es": "^4.17.21",
"mithril": "^2.0.4",
"punycode": "^2.1.1",
"spin.js": "^3.1.0",
"textarea-caret": "^3.1.0"
"textarea-caret": "^3.1.0",
"throttle-debounce": "^3.0.1"
},
"devDependencies": {
"@babel/preset-typescript": "^7.13.0",
"@types/jquery": "^3.5.5",
"@types/lodash-es": "^4.17.4",
"@types/mithril": "^2.0.7",
"@types/punycode": "^2.1.0",
"@types/textarea-caret": "^3.0.0",
"bundlewatch": "^0.3.2",
"cross-env": "^7.0.3",
"flarum-webpack-config": "0.1.0-beta.10",
"flarum-webpack-config": "^1.0.0",
"husky": "^4.3.8",
"prettier": "^2.2.1",
"webpack": "^4.46.0",
"webpack-bundle-analyzer": "^4.4.0",
"webpack-cli": "^3.3.12",
"webpack-merge": "^4.2.2"
"webpack": "^5.0.0",
"webpack-bundle-analyzer": "^4.4.1",
"webpack-cli": "^4.0.0",
"webpack-merge": "^4.0.0"
},
"scripts": {
"dev": "webpack --mode development --watch",

View File

@@ -24,6 +24,7 @@ import UploadImageButton from './components/UploadImageButton';
import LoadingModal from './components/LoadingModal';
import DashboardPage from './components/DashboardPage';
import BasicsPage from './components/BasicsPage';
import UserListPage from './components/UserListPage';
import EditCustomHeaderModal from './components/EditCustomHeaderModal';
import PermissionsPage from './components/PermissionsPage';
import PermissionDropdown from './components/PermissionDropdown';
@@ -59,6 +60,7 @@ export default Object.assign(compat, {
'components/LoadingModal': LoadingModal,
'components/DashboardPage': DashboardPage,
'components/BasicsPage': BasicsPage,
'components/UserListPage': UserListPage,
'components/EditCustomHeaderModal': EditCustomHeaderModal,
'components/PermissionsPage': PermissionsPage,
'components/PermissionDropdown': PermissionDropdown,

View File

@@ -94,6 +94,13 @@ export default class AdminNav extends Component {
</LinkButton>
);
items.add(
'userList',
<LinkButton href={app.route('users')} icon="fas fa-users" title={app.translator.trans('core.admin.nav.userlist_title')}>
{app.translator.trans('core.admin.nav.userlist_button')}
</LinkButton>
);
items.add(
'search',
<div className="Search-input">

View File

@@ -100,8 +100,6 @@ export default class AdminPage extends Page {
const { setting, help, ...componentAttrs } = entry;
delete componentAttrs.help;
const value = this.setting([setting])();
if (['bool', 'checkbox', 'switch', 'boolean'].includes(componentAttrs.type)) {
return (

View File

@@ -182,7 +182,7 @@ export default class PermissionGrid extends Component {
return SettingDropdown.component({
defaultLabel: minutes
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, { count: minutes })
? app.translator.trans('core.admin.permissions_controls.allow_some_minutes_button', { count: minutes })
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
key: 'allow_renaming',
options: [
@@ -224,7 +224,7 @@ export default class PermissionGrid extends Component {
return SettingDropdown.component({
defaultLabel: minutes
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, { count: minutes })
? app.translator.trans('core.admin.permissions_controls.allow_some_minutes_button', { count: minutes })
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
key: 'allow_post_editing',
options: [

View File

@@ -0,0 +1,384 @@
import EditUserModal from '../../common/components/EditUserModal';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import Button from '../../common/components/Button';
import icon from '../../common/helpers/icon';
import listItems from '../../common/helpers/listItems';
import type User from '../../common/models/User';
import ItemList from '../../common/utils/ItemList';
import classList from '../../common/utils/classList';
import extractText from '../../common/utils/extractText';
import AdminPage from './AdminPage';
type ColumnData = {
/**
* Column title
*/
name: String;
/**
* Component(s) to show for this column.
*/
content: (user: User) => JSX.Element;
};
type ApiPayload = {
data: Record<string, unknown>[];
included: Record<string, unknown>[];
links: {
first: string;
next?: string;
};
};
type UsersApiResponse = User[] & { payload: ApiPayload };
/**
* Admin page which displays a paginated list of all users on the forum.
*/
export default class UserListPage extends AdminPage {
/**
* Number of users to load per page.
*/
private numPerPage: number = 50;
/**
* Current page number. Zero-indexed.
*/
private pageNumber: number = 0;
/**
* Total number of forum users.
*
* Fetched from the active `AdminApplication` (`app`), with
* data provided by `AdminPayload.php`, or `flarum/statistics`
* if installed.
*/
readonly userCount: number = app.data.modelStatistics.users.total;
/**
* Get total number of user pages.
*/
private getTotalPageCount(): number {
if (this.userCount === -1) return 0;
return Math.ceil(this.userCount / this.numPerPage);
}
/**
* This page's array of users.
*
* `undefined` when page loads as no data has been fetched.
*/
private pageData: User[] | undefined = undefined;
/**
* Are there more users available?
*/
private moreData: boolean = false;
private isLoadingPage: boolean = false;
/**
* Component to render.
*/
content() {
if (typeof this.pageData === 'undefined') {
this.loadPage(0);
return [
<section class="UserListPage-grid UserListPage-grid--loading">
<LoadingIndicator containerClassName="LoadingIndicator--block" size="large" />
</section>,
];
}
const columns: (ColumnData & { itemName: string })[] = this.columns().toArray();
return [
<p class="UserListPage-totalUsers">{app.translator.trans('core.admin.users.total_users', { count: this.userCount })}</p>,
<section
class={classList(['UserListPage-grid', this.isLoadingPage ? 'UserListPage-grid--loadingPage' : 'UserListPage-grid--loaded'])}
style={{ '--columns': columns.length }}
role="table"
// +1 to account for header
aria-rowcount={this.pageData.length + 1}
aria-colcount={columns.length}
aria-live="polite"
aria-busy={this.isLoadingPage ? 'true' : 'false'}
>
{/* Render columns */}
{columns.map((column, colIndex) => (
<div class="UserListPage-grid-header" role="columnheader" aria-colindex={colIndex + 1} aria-rowindex={1}>
{column.name}
</div>
))}
{/* Render user data */}
{this.pageData.map((user, rowIndex) =>
columns.map((col, colIndex) => {
const columnContent = col.content && col.content(user);
return (
<div
class={classList(['UserListPage-grid-rowItem', rowIndex % 2 > 0 && 'UserListPage-grid-rowItem--shaded'])}
data-user-id={user.id()}
data-column-name={col.itemName}
aria-colindex={colIndex + 1}
// +2 to account for 0-based index, and for the header row
aria-rowindex={rowIndex + 2}
role="cell"
>
{columnContent || app.translator.trans('core.admin.users.grid.invalid_column_content')}
</div>
);
})
)}
{/* Loading spinner that shows when a new page is being loaded */}
{this.isLoadingPage && <LoadingIndicator size="large" />}
</section>,
<nav class="UserListPage-gridPagination">
<Button
disabled={this.pageNumber === 0}
title={app.translator.trans('core.admin.users.pagination.back_button')}
onclick={this.previousPage.bind(this)}
icon="fas fa-chevron-left"
className="Button Button--icon UserListPage-backBtn"
/>
<span class="UserListPage-pageNumber">
{app.translator.trans('core.admin.users.pagination.page_counter', {
current: this.pageNumber + 1,
total: this.getTotalPageCount(),
})}
</span>
<Button
disabled={!this.moreData}
title={app.translator.trans('core.admin.users.pagination.next_button')}
onclick={this.nextPage.bind(this)}
icon="fas fa-chevron-right"
className="Button Button--icon UserListPage-nextBtn"
/>
</nav>,
];
}
/**
* Build an item list of columns to show for each user.
*
* Each column in the list should be an object with keys `name` and `content`.
*
* `name` is a string that will be used as the column name.
* `content` is a function with the User model passed as the first and only argument.
*
* See `UserListPage.tsx` for examples.
*/
columns(): ItemList {
const columns = new ItemList();
columns.add(
'id',
{
name: app.translator.trans('core.admin.users.grid.columns.user_id.title'),
content: (user: User) => user.id(),
},
100
);
columns.add(
'username',
{
name: app.translator.trans('core.admin.users.grid.columns.username.title'),
content: (user: User) => {
const profileUrl = `${app.forum.attribute('baseUrl')}/u/${user.slug()}`;
return (
<a
target="_blank"
href={profileUrl}
title={extractText(app.translator.trans('core.admin.users.grid.columns.username.profile_link_tooltip', { username: user.username() }))}
>
{user.username()}
</a>
);
},
},
90
);
columns.add(
'joinDate',
{
name: app.translator.trans('core.admin.users.grid.columns.join_time.title'),
content: (user: User) => (
<span class="UserList-joinDate" title={user.joinTime()}>
{dayjs(user.joinTime()).format('LLL')}
</span>
),
},
80
);
columns.add(
'groupBadges',
{
name: app.translator.trans('core.admin.users.grid.columns.group_badges.title'),
content: (user: User) => {
const badges = user.badges().toArray();
if (badges.length) {
return <ul className="DiscussionHero-badges badges">{listItems(badges)}</ul>;
} else {
return app.translator.trans('core.admin.users.grid.columns.group_badges.no_badges');
}
},
},
70
);
columns.add(
'emailAddress',
{
name: app.translator.trans('core.admin.users.grid.columns.email.title'),
content: (user: User) => {
function setEmailVisibility(visible: boolean) {
// Get needed jQuery element refs
const emailContainer = $(`[data-column-name=emailAddress][data-user-id=${user.id()}] .UserList-email`);
const emailAddress = emailContainer.find('.UserList-emailAddress');
const emailToggleButton = emailContainer.find('.UserList-emailIconBtn');
const emailToggleButtonIcon = emailToggleButton.find('.icon');
emailToggleButton.attr(
'title',
extractText(
visible
? app.translator.trans('core.admin.users.grid.columns.email.visibility_hide')
: app.translator.trans('core.admin.users.grid.columns.email.visibility_show')
)
);
emailAddress.attr('aria-hidden', visible ? 'false' : 'true');
if (visible) {
emailToggleButtonIcon.addClass('fa-eye');
emailToggleButtonIcon.removeClass('fa-eye-slash');
} else {
emailToggleButtonIcon.removeClass('fa-eye');
emailToggleButtonIcon.addClass('fa-eye-slash');
}
// Need the string interpolation to prevent TS error.
emailContainer.attr('data-email-shown', `${visible}`);
}
function toggleEmailVisibility() {
const emailContainer = $(`[data-column-name=emailAddress][data-user-id=${user.id()}] .UserList-email`);
const emailShown = emailContainer.attr('data-email-shown') === 'true';
if (emailShown) {
setEmailVisibility(false);
} else {
setEmailVisibility(true);
}
}
return (
<div class="UserList-email" key={user.id()} data-email-shown="false">
<span class="UserList-emailAddress" aria-hidden onclick={() => setEmailVisibility(true)}>
{user.email()}
</span>
<button
onclick={toggleEmailVisibility}
class="Button Button--text UserList-emailIconBtn"
title={app.translator.trans('core.admin.users.grid.columns.email.visibility_show')}
>
{icon('far fa-eye-slash fa-fw', { className: 'icon' })}
</button>
</div>
);
},
},
70
);
columns.add(
'editUser',
{
name: app.translator.trans('core.admin.users.grid.columns.edit_user.title'),
content: (user: User) => (
<Button
className="Button UserList-editModalBtn"
title={app.translator.trans('core.admin.users.grid.columns.edit_user.tooltip', { username: user.username() })}
onclick={() => app.modal.show(EditUserModal, { user })}
>
{app.translator.trans('core.admin.users.grid.columns.edit_user.button')}
</Button>
),
},
-90
);
return columns;
}
headerInfo() {
return {
className: 'UserListPage',
icon: 'fas fa-users',
title: app.translator.trans('core.admin.users.title'),
description: app.translator.trans('core.admin.users.description'),
};
}
/**
* Asynchronously fetch the next set of users to be rendered.
*
* Returns an array of Users, plus the raw API payload.
*
* Uses the `this.numPerPage` as the response limit, and automatically calculates the offset required from `pageNumber`.
*
* @param pageNumber The page number to load and display
*/
async loadPage(pageNumber: number) {
if (pageNumber < 0) pageNumber = 0;
app.store
.find('users', {
page: {
limit: this.numPerPage,
offset: pageNumber * this.numPerPage,
},
})
.then((apiData: UsersApiResponse) => {
// Next link won't be present if there's no more data
this.moreData = !!apiData.payload.links.next;
let data = apiData;
// @ts-ignore
delete data.payload;
this.pageData = data;
this.pageNumber = pageNumber;
this.isLoadingPage = false;
m.redraw();
})
.catch((err: Error) => {
console.error(err);
this.pageData = [];
});
}
nextPage() {
this.isLoadingPage = true;
this.loadPage(this.pageNumber + 1);
}
previousPage() {
this.isLoadingPage = true;
this.loadPage(this.pageNumber - 1);
}
}

View File

@@ -3,6 +3,7 @@ import BasicsPage from './components/BasicsPage';
import PermissionsPage from './components/PermissionsPage';
import AppearancePage from './components/AppearancePage';
import MailPage from './components/MailPage';
import UserListPage from './components/UserListPage';
import ExtensionPage from './components/ExtensionPage';
import ExtensionPageResolver from './resolvers/ExtensionPageResolver';
@@ -18,6 +19,7 @@ export default function (app) {
permissions: { path: '/permissions', component: PermissionsPage },
appearance: { path: '/appearance', component: AppearancePage },
mail: { path: '/mail', component: MailPage },
users: { path: '/users', component: UserListPage },
extension: { path: '/extension/:id', component: ExtensionPage, resolverClass: ExtensionPageResolver },
};
}

View File

@@ -12,6 +12,7 @@ import mapRoutes from './utils/mapRoutes';
import RequestError from './utils/RequestError';
import ScrollListener from './utils/ScrollListener';
import liveHumanTimes from './utils/liveHumanTimes';
import patchMithril from './utils/patchMithril';
import { extend } from './extend';
import Forum from './models/Forum';
@@ -20,7 +21,6 @@ import Discussion from './models/Discussion';
import Post from './models/Post';
import Group from './models/Group';
import Notification from './models/Notification';
import { flattenDeep } from 'lodash-es';
import PageState from './states/PageState';
import ModalManagerState from './states/ModalManagerState';
import AlertManagerState from './states/AlertManagerState';
@@ -163,10 +163,12 @@ export default class Application {
load(payload) {
this.data = payload;
this.translator.locale = payload.locale;
this.translator.setLocale(payload.locale);
}
boot() {
patchMithril(window);
this.initializers.toArray().forEach((initializer) => initializer(this));
this.store.pushPayload({ data: this.data.resources });
@@ -180,11 +182,15 @@ export default class Application {
this.initialRoute = window.location.href;
}
// TODO: This entire system needs a do-over for v2
bootExtensions(extensions) {
Object.keys(extensions).forEach((name) => {
const extension = extensions[name];
const extenders = flattenDeep(extension.extend);
// If an extension doesn't define extenders, there's nothing more to do here.
if (!extension.extend) return;
const extenders = extension.extend.flat(Infinity);
for (const extender of extenders) {
extender.extend(this, { name, exports: extension });

View File

@@ -0,0 +1,60 @@
interface ExportRegistry {
moduleExports: object;
onLoads: object;
/**
* Add an instance to the registry.
* This serves as the equivalent of `flarum.core.compat[id] = object`
*/
add(namespace: string, id: string, object: any): void;
/**
* Add a function to run when object of id "id" is added (or overriden).
* If such an object is already registered, the handler will be applied immediately.
*/
onLoad(namespace: string, id: string, handler: Function): void;
/**
* Retrieve an object of type `id` from the registry.
*/
get(namespace: string, id: string): any;
}
export default class FlarumRegistry implements ExportRegistry {
moduleExports = new Map<string, any>();
onLoads = new Map<string, Function[]>();
protected genKey(namespace: string, id: string): string {
return `${namespace};${id}`;
}
add(namespace: string, id: string, object: any) {
const key = this.genKey(namespace, id);
const onLoads = this.onLoads.get(key);
if (onLoads) {
onLoads.reduce((acc, handler) => handler(acc), object);
}
this.moduleExports.set(key, object);
}
onLoad(namespace: string, id: string, handler: Function) {
const key = this.genKey(namespace, id);
const loadedObject = this.moduleExports.get(key);
if (loadedObject) {
this.moduleExports[id] = handler(loadedObject);
} else {
const currOnLoads = this.onLoads.get(key);
this.onLoads.set(key, [...(currOnLoads || []), handler]);
}
}
get(namespace: string, id: string): any {
const key = this.genKey(namespace, id);
return this.moduleExports.get(key);
}
}

View File

@@ -1,13 +1,8 @@
import { RichMessageFormatter, mithrilRichHandler } from '@askvortsov/rich-icu-message-formatter';
import { pluralTypeHandler, selectTypeHandler } from '@ultraq/icu-message-formatter';
import username from './helpers/username';
import extract from './utils/extract';
/**
* Translator with the same API as Symfony's.
*
* Derived from https://github.com/willdurand/BazingaJsTranslationBundle
* which is available under the MIT License.
* Copyright (c) William Durand <william.durand1@gmail.com>
*/
export default class Translator {
constructor() {
/**
@@ -18,288 +13,53 @@ export default class Translator {
*/
this.translations = {};
this.locale = null;
this.formatter = new RichMessageFormatter(null, this.formatterTypeHandlers(), mithrilRichHandler);
}
formatterTypeHandlers() {
return {
plural: pluralTypeHandler,
select: selectTypeHandler,
};
}
setLocale(locale) {
this.formatter.locale = locale;
}
addTranslations(translations) {
Object.assign(this.translations, translations);
}
trans(id, parameters) {
const translation = this.translations[id];
if (translation) {
return this.apply(translation, parameters || {});
}
return id;
}
transChoice(id, number, parameters) {
let translation = this.translations[id];
if (translation) {
number = parseInt(number, 10);
translation = this.pluralize(translation, number);
return this.apply(translation, parameters || {});
}
return id;
}
apply(translation, input) {
preprocessParameters(parameters) {
// If we've been given a user model as one of the input parameters, then
// we'll extract the username and use that for the translation. In the
// future there should be a hook here to inspect the user and change the
// translation key. This will allow a gender property to determine which
// translation key is used.
if ('user' in input) {
const user = extract(input, 'user');
if ('user' in parameters) {
const user = extract(parameters, 'user');
if (!input.username) input.username = username(user);
if (!parameters.username) parameters.username = username(user);
}
translation = translation.split(new RegExp('({[a-z0-9_]+}|</?[a-z0-9_]+>)', 'gi'));
const hydrated = [];
const open = [hydrated];
translation.forEach((part) => {
const match = part.match(new RegExp('{([a-z0-9_]+)}|<(/?)([a-z0-9_]+)>', 'i'));
if (match) {
// Either an opening or closing tag.
if (match[1]) {
open[0].push(input[match[1]]);
} else if (match[3]) {
if (match[2]) {
// Closing tag. We start by removing all raw children (generally in the form of strings) from the temporary
// holding array, then run them through m.fragment to convert them to vnodes. Usually this will just give us a
// text vnode, but using m.fragment as opposed to an explicit conversion should be more flexible. This is necessary because
// otherwise, our generated vnode will have raw strings as its children, and mithril expects vnodes.
// Finally, we add the now-processed vnodes back onto the holding array (which is the same object in memory as the
// children array of the vnode we are currently processing), and remove the reference to the holding array so that
// further text will be added to the full set of returned elements.
const rawChildren = open[0].splice(0, open[0].length);
open[0].push(...m.fragment(rawChildren).children);
open.shift();
} else {
// If a vnode with a matching tag was provided in the translator input, we use that. Otherwise, we create a new vnode
// with this tag, and an empty children array (since we're expecting to insert children, as that's the point of having this in translator)
let tag = input[match[3]] || { tag: match[3], children: [] };
open[0].push(tag);
// Insert the tag's children array as the first element of open, so that text in between the opening
// and closing tags will be added to the tag's children, not to the full set of returned elements.
open.unshift(tag.children || tag);
}
}
} else {
// Not an html tag, we add it to open[0], which is either the full set of returned elements (vnodes and text),
// or if an html tag is currently being processed, the children attribute of that html tag's vnode.
open[0].push(part);
}
});
return hydrated.filter((part) => part);
return parameters;
}
pluralize(translation, number) {
const sPluralRegex = new RegExp(/^\w+\: +(.+)$/),
cPluralRegex = new RegExp(/^\s*((\{\s*(\-?\d+[\s*,\s*\-?\d+]*)\s*\})|([\[\]])\s*(-Inf|\-?\d+)\s*,\s*(\+?Inf|\-?\d+)\s*([\[\]]))\s?(.+?)$/),
iPluralRegex = new RegExp(/^\s*(\{\s*(\-?\d+[\s*,\s*\-?\d+]*)\s*\})|([\[\]])\s*(-Inf|\-?\d+)\s*,\s*(\+?Inf|\-?\d+)\s*([\[\]])/),
standardRules = [],
explicitRules = [];
trans(id, parameters) {
const translation = this.translations[id];
translation.split('|').forEach((part) => {
if (cPluralRegex.test(part)) {
const matches = part.match(cPluralRegex);
explicitRules[matches[0]] = matches[matches.length - 1];
} else if (sPluralRegex.test(part)) {
const matches = part.match(sPluralRegex);
standardRules.push(matches[1]);
} else {
standardRules.push(part);
}
});
if (translation) {
parameters = this.preprocessParameters(parameters || {});
return this.formatter.rich(translation, parameters);
}
explicitRules.forEach((rule, e) => {
if (iPluralRegex.test(e)) {
const matches = e.match(iPluralRegex);
if (matches[1]) {
const ns = matches[2].split(',');
for (let n in ns) {
if (number == ns[n]) {
return explicitRules[e];
}
}
} else {
var leftNumber = this.convertNumber(matches[4]);
var rightNumber = this.convertNumber(matches[5]);
if (
('[' === matches[3] ? number >= leftNumber : number > leftNumber) &&
(']' === matches[6] ? number <= rightNumber : number < rightNumber)
) {
return explicitRules[e];
}
}
}
});
return standardRules[this.pluralPosition(number, this.locale)] || standardRules[0] || undefined;
return id;
}
convertNumber(number) {
if ('-Inf' === number) {
return Number.NEGATIVE_INFINITY;
} else if ('+Inf' === number || 'Inf' === number) {
return Number.POSITIVE_INFINITY;
}
return parseInt(number, 10);
}
pluralPosition(number, locale) {
if ('pt_BR' === locale) {
locale = 'xbr';
}
if (locale.length > 3) {
locale = locale.split('_')[0];
}
switch (locale) {
case 'bo':
case 'dz':
case 'id':
case 'ja':
case 'jv':
case 'ka':
case 'km':
case 'kn':
case 'ko':
case 'ms':
case 'th':
case 'vi':
case 'zh':
return 0;
case 'af':
case 'az':
case 'bn':
case 'bg':
case 'ca':
case 'da':
case 'de':
case 'el':
case 'en':
case 'eo':
case 'es':
case 'et':
case 'eu':
case 'fa':
case 'fi':
case 'fo':
case 'fur':
case 'fy':
case 'gl':
case 'gu':
case 'ha':
case 'he':
case 'hu':
case 'is':
case 'it':
case 'ku':
case 'lb':
case 'ml':
case 'mn':
case 'mr':
case 'nah':
case 'nb':
case 'ne':
case 'nl':
case 'nn':
case 'no':
case 'om':
case 'or':
case 'pa':
case 'pap':
case 'ps':
case 'pt':
case 'so':
case 'sq':
case 'sv':
case 'sw':
case 'ta':
case 'te':
case 'tk':
case 'tr':
case 'ur':
case 'zu':
return number == 1 ? 0 : 1;
case 'am':
case 'bh':
case 'fil':
case 'fr':
case 'gun':
case 'hi':
case 'ln':
case 'mg':
case 'nso':
case 'xbr':
case 'ti':
case 'wa':
return number === 0 || number == 1 ? 0 : 1;
case 'be':
case 'bs':
case 'hr':
case 'ru':
case 'sr':
case 'uk':
return number % 10 == 1 && number % 100 != 11 ? 0 : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 10 || number % 100 >= 20) ? 1 : 2;
case 'cs':
case 'sk':
return number == 1 ? 0 : number >= 2 && number <= 4 ? 1 : 2;
case 'ga':
return number == 1 ? 0 : number == 2 ? 1 : 2;
case 'lt':
return number % 10 == 1 && number % 100 != 11 ? 0 : number % 10 >= 2 && (number % 100 < 10 || number % 100 >= 20) ? 1 : 2;
case 'sl':
return number % 100 == 1 ? 0 : number % 100 == 2 ? 1 : number % 100 == 3 || number % 100 == 4 ? 2 : 3;
case 'mk':
return number % 10 == 1 ? 0 : 1;
case 'mt':
return number == 1 ? 0 : number === 0 || (number % 100 > 1 && number % 100 < 11) ? 1 : number % 100 > 10 && number % 100 < 20 ? 2 : 3;
case 'lv':
return number === 0 ? 0 : number % 10 == 1 && number % 100 != 11 ? 1 : 2;
case 'pl':
return number == 1 ? 0 : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 12 || number % 100 > 14) ? 1 : 2;
case 'cy':
return number == 1 ? 0 : number == 2 ? 1 : number == 8 || number == 11 ? 2 : 3;
case 'ro':
return number == 1 ? 0 : number === 0 || (number % 100 > 0 && number % 100 < 20) ? 1 : 2;
case 'ar':
return number === 0 ? 0 : number == 1 ? 1 : number == 2 ? 2 : number >= 3 && number <= 10 ? 3 : number >= 11 && number <= 99 ? 4 : 5;
default:
return 0;
}
/**
* @deprecated, remove before stable
*/
transChoice(id, number, parameters) {
return this.trans(id, parameters);
}
}

View File

@@ -12,7 +12,9 @@ import Drawer from './utils/Drawer';
import anchorScroll from './utils/anchorScroll';
import RequestError from './utils/RequestError';
import abbreviateNumber from './utils/abbreviateNumber';
import escapeRegExp from './utils/escapeRegExp';
import * as string from './utils/string';
import * as ThrottleDebounce from './utils/throttleDebounce';
import Stream from './utils/Stream';
import SubtreeRetainer from './utils/SubtreeRetainer';
import setRouteWithForcedRefresh from './utils/setRouteWithForcedRefresh';
@@ -59,6 +61,7 @@ import Modal from './components/Modal';
import GroupBadge from './components/GroupBadge';
import TextEditor from './components/TextEditor';
import TextEditorButton from './components/TextEditorButton';
import EditUserModal from './components/EditUserModal';
import Model from './Model';
import Application from './Application';
import fullTime from './helpers/fullTime';
@@ -90,6 +93,7 @@ export default {
'utils/abbreviateNumber': abbreviateNumber,
'utils/string': string,
'utils/SubtreeRetainer': SubtreeRetainer,
'utils/escapeRegExp': escapeRegExp,
'utils/extract': extract,
'utils/ScrollListener': ScrollListener,
'utils/stringToColor': stringToColor,
@@ -103,6 +107,7 @@ export default {
'utils/formatNumber': formatNumber,
'utils/mapRoutes': mapRoutes,
'utils/withAttr': withAttr,
'utils/throttleDebounce': ThrottleDebounce,
'models/Notification': Notification,
'models/User': User,
'models/Post': Post,
@@ -136,6 +141,7 @@ export default {
'components/GroupBadge': GroupBadge,
'components/TextEditor': TextEditor,
'components/TextEditorButton': TextEditorButton,
'components/EditUserModal': EditUserModal,
Model: Model,
Application: Application,
'helpers/fullTime': fullTime,

View File

@@ -69,7 +69,7 @@ export default class Button extends Component {
return [
iconName && iconName !== true ? icon(iconName, { className: 'Button-icon' }) : '',
children ? <span className="Button-label">{children}</span> : '',
this.attrs.loading ? <LoadingIndicator size="tiny" className="LoadingIndicator--inline" /> : '',
this.attrs.loading ? <LoadingIndicator size="small" display="inline" /> : '',
];
}
}

View File

@@ -46,7 +46,7 @@ export default class Checkbox extends Component {
* @protected
*/
getDisplay() {
return this.attrs.loading ? <LoadingIndicator size="tiny" /> : icon(this.attrs.state ? 'fas fa-check' : 'fas fa-times');
return this.attrs.loading ? <LoadingIndicator display="unset" size="small" /> : icon(this.attrs.state ? 'fas fa-check' : 'fas fa-times');
}
/**

View File

@@ -1,10 +1,10 @@
import Modal from '../../common/components/Modal';
import Button from '../../common/components/Button';
import GroupBadge from '../../common/components/GroupBadge';
import Group from '../../common/models/Group';
import extractText from '../../common/utils/extractText';
import ItemList from '../../common/utils/ItemList';
import Stream from '../../common/utils/Stream';
import Modal from './Modal';
import Button from './Button';
import GroupBadge from './GroupBadge';
import Group from '../models/Group';
import extractText from '../utils/extractText';
import ItemList from '../utils/ItemList';
import Stream from '../utils/Stream';
/**
* The `EditUserModal` component displays a modal dialog with a login form.
@@ -33,14 +33,14 @@ export default class EditUserModal extends Modal {
}
title() {
return app.translator.trans('core.forum.edit_user.title');
return app.translator.trans('core.lib.edit_user.title');
}
content() {
const fields = this.fields().toArray();
return (
<div className="Modal-body">
{fields.length > 1 ? <div className="Form">{this.fields().toArray()}</div> : app.translator.trans('core.forum.edit_user.nothing_available')}
{fields.length > 1 ? <div className="Form">{this.fields().toArray()}</div> : app.translator.trans('core.lib.edit_user.nothing_available')}
</div>
);
}
@@ -52,10 +52,10 @@ export default class EditUserModal extends Modal {
items.add(
'username',
<div className="Form-group">
<label>{app.translator.trans('core.forum.edit_user.username_heading')}</label>
<label>{app.translator.trans('core.lib.edit_user.username_heading')}</label>
<input
className="FormControl"
placeholder={extractText(app.translator.trans('core.forum.edit_user.username_label'))}
placeholder={extractText(app.translator.trans('core.lib.edit_user.username_label'))}
bidi={this.username}
disabled={this.nonAdminEditingAdmin()}
/>
@@ -67,11 +67,11 @@ export default class EditUserModal extends Modal {
items.add(
'email',
<div className="Form-group">
<label>{app.translator.trans('core.forum.edit_user.email_heading')}</label>
<label>{app.translator.trans('core.lib.edit_user.email_heading')}</label>
<div>
<input
className="FormControl"
placeholder={extractText(app.translator.trans('core.forum.edit_user.email_label'))}
placeholder={extractText(app.translator.trans('core.lib.edit_user.email_label'))}
bidi={this.email}
disabled={this.nonAdminEditingAdmin()}
/>
@@ -84,7 +84,7 @@ export default class EditUserModal extends Modal {
loading: this.loading,
onclick: this.activate.bind(this),
},
app.translator.trans('core.forum.edit_user.activate_button')
app.translator.trans('core.lib.edit_user.activate_button')
)}
</div>
) : (
@@ -97,7 +97,7 @@ export default class EditUserModal extends Modal {
items.add(
'password',
<div className="Form-group">
<label>{app.translator.trans('core.forum.edit_user.password_heading')}</label>
<label>{app.translator.trans('core.lib.edit_user.password_heading')}</label>
<div>
<label className="checkbox">
<input
@@ -110,14 +110,14 @@ export default class EditUserModal extends Modal {
}}
disabled={this.nonAdminEditingAdmin()}
/>
{app.translator.trans('core.forum.edit_user.set_password_label')}
{app.translator.trans('core.lib.edit_user.set_password_label')}
</label>
{this.setPassword() ? (
<input
className="FormControl"
type="password"
name="password"
placeholder={extractText(app.translator.trans('core.forum.edit_user.password_label'))}
placeholder={extractText(app.translator.trans('core.lib.edit_user.password_label'))}
bidi={this.password}
disabled={this.nonAdminEditingAdmin()}
/>
@@ -135,7 +135,7 @@ export default class EditUserModal extends Modal {
items.add(
'groups',
<div className="Form-group EditUserModal-groups">
<label>{app.translator.trans('core.forum.edit_user.groups_heading')}</label>
<label>{app.translator.trans('core.lib.edit_user.groups_heading')}</label>
<div>
{Object.keys(this.groups)
.map((id) => app.store.getById('groups', id))
@@ -164,7 +164,7 @@ export default class EditUserModal extends Modal {
type: 'submit',
loading: this.loading,
},
app.translator.trans('core.forum.edit_user.submit_button')
app.translator.trans('core.lib.edit_user.submit_button')
)}
</div>,
-10

View File

@@ -18,6 +18,12 @@ export interface LoadingIndicatorAttrs extends ComponentAttrs {
* Optional attributes to apply to the loading indicator's container.
*/
containerAttrs?: Partial<ComponentAttrs>;
/**
* Display type of the spinner.
*
* @default 'block'
*/
display?: 'block' | 'inline' | 'unset';
}
/**
@@ -26,12 +32,13 @@ export interface LoadingIndicatorAttrs extends ComponentAttrs {
* To set a custom color, use the CSS `color` property.
*
* To increase spacing around the spinner, use the CSS `height` property on the
* spinner's **container**.
* spinner's **container**. Setting the `display` attribute to `block` will set
* a height of `100px` by default.
*
* To apply a custom size to the loading indicator, set the `--size` and
* `--thickness` custom properties on the loading indicator itself.
* `--thickness` CSS custom properties on the loading indicator container.
*
* If you really want to change how this looks as part of your custom theme,
* If you *really* want to change how this looks as part of your custom theme,
* you can override the `border-radius` and `border` then set either a
* background image, or use `content: "\<glyph>"` (e.g. `content: "\f1ce"`)
* and `font-family: 'Font Awesome 5 Free'` to set an FA icon if you'd rather.
@@ -40,21 +47,33 @@ export interface LoadingIndicatorAttrs extends ComponentAttrs {
*
* - `containerClassName` Class name(s) to apply to the indicator's parent
* - `className` Class name(s) to apply to the indicator itself
* - `size` Size of the loading indicator
* - `display` Determines how the spinner should be displayed (`inline`, `block` (default) or `unset`)
* - `size` Size of the loading indicator (`small`, `medium` or `large`)
* - `containerAttrs` Optional attrs to be applied to the container DOM element
*
* All other attrs will be assigned as attributes on the DOM element.
*/
export default class LoadingIndicator extends Component<LoadingIndicatorAttrs> {
view() {
const { size, ...attrs } = this.attrs;
const { display = 'block', size = 'medium', containerClassName, className, ...attrs } = this.attrs;
attrs.className = classList({ LoadingIndicator: true, [attrs.className || '']: true });
attrs.containerClassName = classList({ 'LoadingIndicator-container': true, [attrs.containerClassName || '']: true });
const completeClassName = classList('LoadingIndicator', className);
const completeContainerClassName = classList(
'LoadingIndicator-container',
display !== 'unset' && `LoadingIndicator-container--${display}`,
size && `LoadingIndicator-container--${size}`,
containerClassName
);
return (
<div {...attrs.containerAttrs} data-size={size} className={attrs.containerClassName}>
<div {...attrs}></div>
<div
aria-label={app.translator.trans('core.lib.loading_indicator.accessible_label')}
role="status"
{...attrs.containerAttrs}
data-size={size}
className={completeContainerClassName}
>
<div aria-hidden className={completeClassName} {...attrs} />
</div>
);
}

View File

@@ -9,27 +9,36 @@
* Care should be taken to extend the correct object in most cases, a class'
* prototype will be the desired target of extension, not the class itself.
*
* @example
* @example <caption>Example usage of extending one method.</caption>
* extend(Discussion.prototype, 'badges', function(badges) {
* // do something with `badges`
* });
*
* @param {Object} object The object that owns the method
* @param {String} method The name of the method to extend
* @example <caption>Example usage of extending multiple methods.</caption>
* extend(IndexPage.prototype, ['oncreate', 'onupdate'], function(vnode) {
* // something that needs to be run on creation and update
* });
*
* @param {object} object The object that owns the method
* @param {string|string[]} methods The name or names of the method(s) to extend
* @param {function} callback A callback which mutates the method's output
*/
export function extend(object, method, callback) {
const original = object[method];
export function extend(object, methods, callback) {
const allMethods = Array.isArray(methods) ? methods : [methods];
object[method] = function (...args) {
const value = original ? original.apply(this, args) : undefined;
allMethods.forEach((method) => {
const original = object[method];
callback.apply(this, [value].concat(args));
object[method] = function (...args) {
const value = original ? original.apply(this, args) : undefined;
return value;
};
callback.apply(this, [value].concat(args));
Object.assign(object[method], original);
return value;
};
Object.assign(object[method], original);
});
}
/**
@@ -37,29 +46,38 @@ export function extend(object, method, callback) {
* new function will be run every time the object's method is called.
*
* The replacement function accepts the original method as its first argument,
* which is like a call to 'super'. Any arguments passed to the original method
* which is like a call to `super`. Any arguments passed to the original method
* are also passed to the replacement.
*
* Care should be taken to extend the correct object in most cases, a class'
* prototype will be the desired target of extension, not the class itself.
*
* @example
* @example <caption>Example usage of overriding one method.</caption>
* override(Discussion.prototype, 'badges', function(original) {
* const badges = original();
* // do something with badges
* return badges;
* });
*
* @param {Object} object The object that owns the method
* @param {String} method The name of the method to override
* @example <caption>Example usage of overriding multiple methods.</caption>
* extend(Discussion.prototype, ['oncreate', 'onupdate'], function(original, vnode) {
* // something that needs to be run on creation and update
* });
*
* @param {object} object The object that owns the method
* @param {string|string[]} method The name or names of the method(s) to override
* @param {function} newMethod The method to replace it with
*/
export function override(object, method, newMethod) {
const original = object[method];
export function override(object, methods, newMethod) {
const allMethods = Array.isArray(methods) ? methods : [methods];
object[method] = function (...args) {
return newMethod.apply(this, [original.bind(this)].concat(args));
};
allMethods.forEach((method) => {
const original = object[method];
Object.assign(object[method], original);
object[method] = function (...args) {
return newMethod.apply(this, [original.bind(this)].concat(args));
};
Object.assign(object[method], original);
});
}

View File

@@ -1,5 +1,5 @@
// Expose jQuery, mithril and dayjs to the window browser object
import 'expose-loader?exposes[]=$&exposes[]=jQuery!jquery';
import 'expose-loader?exposes=$,jQuery!jquery';
import 'expose-loader?exposes=m!mithril';
import 'expose-loader?exposes=dayjs!dayjs';
@@ -16,10 +16,12 @@ import localizedFormat from 'dayjs/plugin/localizedFormat';
dayjs.extend(relativeTime);
dayjs.extend(localizedFormat);
import patchMithril from './utils/patchMithril';
import FlarumRegistry from './FlarumRegistry';
patchMithril(window);
window.flreg = new FlarumRegistry();
import * as Extend from './extend/index';
export { Extend };
import './utils/arrayFlatPolyfill';

View File

@@ -0,0 +1,14 @@
// Based off of the polyfill on MDN
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/flat#reduce_concat_isarray_recursivity
//
// Needed to provide support for Safari on iOS < 12
if (!Array.prototype['flat']) {
Array.prototype['flat'] = function flat(this: any[], depth: number = 1): any[] {
return depth > 0
? Array.prototype.reduce.call(this, (acc, val): any[] => acc.concat(Array.isArray(val) ? flat.call(val, depth - 1) : val), [])
: // If no depth is provided, or depth is 0, just return a copy of
// the array. Spread is supported in all major browsers (iOS 8+)
[...this];
};
}

View File

@@ -0,0 +1,10 @@
const specialChars = /[.*+?^${}()|[\]\\]/g;
/**
* Escapes the `RegExp` special characters in `input`.
*
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
*/
export default function escapeRegExp(input: string): string {
return input.replace(specialChars, '\\$&');
}

View File

@@ -2,7 +2,7 @@
* The `evented` mixin provides methods allowing an object to trigger events,
* running externally registered event handlers.
*/
export default {
const evented = {
/**
* Arrays of registered event handlers, grouped by the event name.
*
@@ -79,3 +79,5 @@ export default {
}
},
};
export default evented;

View File

@@ -0,0 +1,3 @@
// Re-exports `throttle-debounce` to be used in `compat.js`.
export { throttle, debounce } from 'throttle-debounce';

View File

@@ -51,7 +51,6 @@ import PostPreview from './components/PostPreview';
import EventPost from './components/EventPost';
import DiscussionHero from './components/DiscussionHero';
import PostMeta from './components/PostMeta';
import EditUserModal from './components/EditUserModal';
import SearchSource from './components/SearchSource';
import DiscussionRenamedPost from './components/DiscussionRenamedPost';
import DiscussionComposer from './components/DiscussionComposer';
@@ -70,6 +69,10 @@ import Search from './components/Search';
import DiscussionListItem from './components/DiscussionListItem';
import LoadingPost from './components/LoadingPost';
import PostsUserPage from './components/PostsUserPage';
/**
* @deprecated
*/
import EditUserModal from '../common/components/EditUserModal';
import DiscussionPageResolver from './resolvers/DiscussionPageResolver';
import BasicEditorDriver from '../common/utils/BasicEditorDriver';
import routes from './routes';
@@ -128,6 +131,9 @@ export default Object.assign(compat, {
'components/EventPost': EventPost,
'components/DiscussionHero': DiscussionHero,
'components/PostMeta': PostMeta,
/**
* @deprecated Used for backwards compatibility now that the EditUserModal has moved to common. Remove in beta 17.
*/
'components/EditUserModal': EditUserModal,
'components/SearchSource': SearchSource,
'components/DiscussionRenamedPost': DiscussionRenamedPost,

View File

@@ -52,7 +52,13 @@ export default class AvatarEditor extends Component {
ondragend={this.disableDragover.bind(this)}
ondrop={this.dropUpload.bind(this)}
>
{this.loading ? <LoadingIndicator /> : user.avatarUrl() ? icon('fas fa-pencil-alt') : icon('fas fa-plus-circle')}
{this.loading ? (
<LoadingIndicator display="unset" size="large" />
) : user.avatarUrl() ? (
icon('fas fa-pencil-alt')
) : (
icon('fas fa-plus-circle')
)}
</a>
<ul className="Dropdown-menu Menu">{listItems(this.controlItems().toArray())}</ul>
</div>

View File

@@ -5,6 +5,7 @@ import TextEditor from '../../common/components/TextEditor';
import avatar from '../../common/helpers/avatar';
import listItems from '../../common/helpers/listItems';
import ItemList from '../../common/utils/ItemList';
import classList from '../../common/utils/classList';
/**
* The `ComposerBody` component handles the body, or the content, of the
@@ -66,7 +67,7 @@ export default class ComposerBody extends Component {
})}
</div>
</div>
{LoadingIndicator.component({ className: 'ComposerBody-loading' + (this.loading ? ' active' : '') })}
<LoadingIndicator display="unset" containerClassName={classList('ComposerBody-loading', this.loading && 'active')} size="large" />
</div>
</ConfirmDocumentUnload>
);

View File

@@ -19,7 +19,7 @@ export default class DiscussionList extends Component {
let loading;
if (state.isLoading()) {
loading = LoadingIndicator.component();
loading = <LoadingIndicator />;
} else if (state.moreResults) {
loading = Button.component(
{

View File

@@ -15,8 +15,8 @@ import slidable from '../utils/slidable';
import extractText from '../../common/utils/extractText';
import classList from '../../common/utils/classList';
import DiscussionPage from './DiscussionPage';
import escapeRegExp from '../../common/utils/escapeRegExp';
import { escapeRegExp } from 'lodash-es';
/**
* The `DiscussionListItem` component shows a single discussion in the
* discussion list.

View File

@@ -73,23 +73,25 @@ export default class DiscussionPage extends Page {
<div className="DiscussionPage">
<DiscussionListPane state={app.discussions} />
<div className="DiscussionPage-discussion">
{discussion
? [
DiscussionHero.component({ discussion }),
<div className="container">
<nav className="DiscussionPage-nav">
<ul>{listItems(this.sidebarItems().toArray())}</ul>
</nav>
<div className="DiscussionPage-stream">
{PostStream.component({
discussion,
stream: this.stream,
onPositionChange: this.positionChanged.bind(this),
})}
</div>
</div>,
]
: LoadingIndicator.component({ className: 'LoadingIndicator--block' })}
{discussion ? (
[
DiscussionHero.component({ discussion }),
<div className="container">
<nav className="DiscussionPage-nav">
<ul>{listItems(this.sidebarItems().toArray())}</ul>
</nav>
<div className="DiscussionPage-stream">
{PostStream.component({
discussion,
stream: this.stream,
onPositionChange: this.positionChanged.bind(this),
})}
</div>
</div>,
]
) : (
<LoadingIndicator />
)}
</div>
</div>
);

View File

@@ -57,7 +57,7 @@ export default class EventPost extends Post {
* @return {String|Object} The description to render in the DOM
*/
description(data) {
return app.translator.transChoice(this.descriptionKey(), data.count, data);
return app.translator.trans(this.descriptionKey(), data);
}
/**

View File

@@ -4,6 +4,7 @@ import icon from '../../common/helpers/icon';
import humanTime from '../../common/helpers/humanTime';
import Button from '../../common/components/Button';
import Link from '../../common/components/Link';
import classList from '../../common/utils/classList';
/**
* The `Notification` component abstract displays a single notification.
@@ -22,27 +23,31 @@ export default class Notification extends Component {
return (
<Link
className={'Notification Notification--' + notification.contentType() + ' ' + (!notification.isRead() ? 'unread' : '')}
className={classList('Notification', `Notification--${notification.contentType()}`, [!notification.isRead() && 'unread'])}
href={href}
external={href.includes('://')}
onclick={this.markAsRead.bind(this)}
>
{!notification.isRead() &&
Button.component({
className: 'Notification-action Button Button--icon Button--link',
icon: 'fas fa-check',
title: app.translator.trans('core.forum.notifications.mark_as_read_tooltip'),
onclick: (e) => {
{avatar(notification.fromUser())}
{icon(this.icon(), { className: 'Notification-icon' })}
<span className="Notification-title">
<span className="Notification-content">{this.content()}</span>
<span className="Notification-title-spring" />
{humanTime(notification.createdAt())}
</span>
{!notification.isRead() && (
<Button
className="Notification-action Button Button--link"
icon="fas fa-check"
title={app.translator.trans('core.forum.notifications.mark_as_read_tooltip')}
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
this.markAsRead();
},
})}
{avatar(notification.fromUser())}
{icon(this.icon(), { className: 'Notification-icon' })}
<span className="Notification-content">{this.content()}</span>
{humanTime(notification.createdAt())}
}}
/>
)}
<div className="Notification-excerpt">{this.excerpt()}</div>
</Link>
);

View File

@@ -17,16 +17,16 @@ export default class NotificationList extends Component {
return (
<div className="NotificationList">
<div className="NotificationList-header">
<div className="App-primaryControl">
{Button.component({
className: 'Button Button--icon Button--link',
icon: 'fas fa-check',
title: app.translator.trans('core.forum.notifications.mark_all_as_read_tooltip'),
onclick: state.markAllAsRead.bind(state),
})}
</div>
<h4 className="App-titleControl App-titleControl--text">{app.translator.trans('core.forum.notifications.title')}</h4>
<div className="App-primaryControl">
<Button
className="Button Button--link"
icon="fas fa-check"
title={app.translator.trans('core.forum.notifications.mark_all_as_read_tooltip')}
onclick={state.markAllAsRead.bind(state)}
/>
</div>
</div>
<div className="NotificationList-content">
@@ -43,7 +43,7 @@ export default class NotificationList extends Component {
// Get the discussion that this notification is related to. If it's not
// directly related to a discussion, it may be related to a post or
// other entity which is related to a discussion.
let discussion = false;
let discussion = null;
if (subject instanceof Discussion) discussion = subject;
else if (subject && subject.discussion) discussion = subject.discussion();
@@ -65,8 +65,8 @@ export default class NotificationList extends Component {
<div className="NotificationGroup">
{group.discussion ? (
<Link className="NotificationGroup-header" href={app.route.discussion(group.discussion)}>
{badges && badges.length ? <ul className="NotificationGroup-badges badges">{listItems(badges)}</ul> : ''}
{group.discussion.title()}
{badges && badges.length && <ul className="NotificationGroup-badges badges">{listItems(badges)}</ul>}
<span>{group.discussion.title()}</span>
</Link>
) : (
<div className="NotificationGroup-header">{app.forum.attribute('title')}</div>
@@ -84,7 +84,7 @@ export default class NotificationList extends Component {
})
: ''}
{state.isLoading() ? (
<LoadingIndicator className="LoadingIndicator--block" />
<LoadingIndicator />
) : pages.length ? (
''
) : (

View File

@@ -1,5 +1,6 @@
import Dropdown from '../../common/components/Dropdown';
import icon from '../../common/helpers/icon';
import classList from '../../common/utils/classList';
import NotificationList from './NotificationList';
export default class NotificationsDropdown extends Dropdown {
@@ -9,6 +10,7 @@ export default class NotificationsDropdown extends Dropdown {
attrs.menuClassName = attrs.menuClassName || 'Dropdown-menu--right';
attrs.label = attrs.label || app.translator.trans('core.forum.notifications.tooltip');
attrs.icon = attrs.icon || 'fas fa-bell';
// For best a11y support, both `title` and `aria-label` should be used
attrs.accessibleToggleLabel = attrs.accessibleToggleLabel || app.translator.trans('core.forum.notifications.toggle_dropdown_accessible_label');
@@ -21,7 +23,7 @@ export default class NotificationsDropdown extends Dropdown {
vdom.attrs.title = this.attrs.label;
vdom.attrs.className += newNotifications ? ' new' : '';
vdom.attrs.className = classList(vdom.attrs.className, [newNotifications && 'new']);
vdom.attrs.onclick = this.onclick.bind(this);
return vdom;
@@ -32,15 +34,15 @@ export default class NotificationsDropdown extends Dropdown {
return [
icon(this.attrs.icon, { className: 'Button-icon' }),
unread ? <span className="NotificationsDropdown-unread">{unread}</span> : '',
unread !== 0 && <span className="NotificationsDropdown-unread">{unread}</span>,
<span className="Button-label">{this.attrs.label}</span>,
];
}
getMenu() {
return (
<div className={'Dropdown-menu ' + this.attrs.menuClassName} onclick={this.menuClick.bind(this)}>
{this.showing ? NotificationList.component({ state: this.attrs.state }) : ''}
<div className={classList('Dropdown-menu', this.attrs.menuClassName)} onclick={this.menuClick.bind(this)}>
{this.showing && NotificationList.component({ state: this.attrs.state })}
</div>
);
}

View File

@@ -26,9 +26,10 @@ export default class PostStreamScrubber extends Component {
const count = this.stream.count();
// Index is left blank for performance reasons, it is filled in in updateScubberValues
const viewing = app.translator.transChoice('core.forum.post_scrubber.viewing_text', count, {
const viewing = app.translator.trans('core.forum.post_scrubber.viewing_text', {
count,
index: <span className="Scrubber-index"></span>,
count: <span className="Scrubber-count">{formatNumber(count)}</span>,
formattedCount: <span className="Scrubber-count">{formatNumber(count)}</span>,
});
const unreadCount = this.stream.discussion.unreadCount();

View File

@@ -98,7 +98,7 @@ export default class Search extends Component {
onblur={() => (this.hasFocus = false)}
/>
{this.loadingSources ? (
LoadingIndicator.component({ size: 'tiny', className: 'Button Button--icon Button--link' })
<LoadingIndicator size="small" display="inline" containerClassName="Button Button--icon Button--link" />
) : currentSearch ? (
<button className="Search-clear Button Button--icon Button--link" onclick={this.clear.bind(this)}>
{icon('fas fa-times-circle')}
@@ -114,6 +114,15 @@ export default class Search extends Component {
);
}
updateMaxHeight() {
// Since extensions might add elements above the search box on mobile,
// we need to calculate and set the max height dynamically.
const resultsElementMargin = 14;
const maxHeight =
window.innerHeight - this.element.querySelector('.Search-input>.FormControl').getBoundingClientRect().bottom - resultsElementMargin;
this.element.querySelector('.Search-results').style['max-height'] = `${maxHeight}px`;
}
onupdate() {
// Highlight the item that is currently selected.
this.setIndex(this.getCurrentNumericIndex());
@@ -121,12 +130,7 @@ export default class Search extends Component {
// If there are no sources, the search view is not shown.
if (!this.sources.length) return;
// Since extensions might add elements above the search box on mobile,
// we need to calculate and set the max height dynamically.
const resultsElementMargin = 14;
const maxHeight =
window.innerHeight - this.element.querySelector('.Search-input>.FormControl').getBoundingClientRect().bottom - resultsElementMargin;
this.element.querySelector('.Search-results').style['max-height'] = `${maxHeight}px`;
this.updateMaxHeight();
}
oncreate(vnode) {
@@ -191,6 +195,13 @@ export default class Search extends Component {
.one('mouseup', (e) => e.preventDefault())
.select();
});
this.updateMaxHeightHandler = this.updateMaxHeight.bind(this);
window.addEventListener('resize', this.updateMaxHeightHandler);
}
onremove() {
window.removeEventListener('resize', this.updateMaxHeightHandler);
}
/**

View File

@@ -51,7 +51,7 @@ export default class UserPage extends Page {
</div>
</div>,
]
: [<LoadingIndicator className="LoadingIndicator--block" />]}
: [<LoadingIndicator display="block" />]}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { throttle } from 'lodash-es';
import { throttle } from 'throttle-debounce';
import anchorScroll from '../../common/utils/anchorScroll';
class PostStreamState {
@@ -50,8 +50,8 @@ class PostStreamState {
*/
this.forceUpdateScrubber = false;
this.loadNext = throttle(this._loadNext, 300);
this.loadPrevious = throttle(this._loadPrevious, 300);
this.loadNext = throttle(300, this._loadNext);
this.loadPrevious = throttle(300, this._loadPrevious);
this.show(includedPosts);
}

View File

@@ -11,7 +11,7 @@ import extractText from '../../common/utils/extractText';
* The `DiscussionControls` utility constructs a list of buttons for a
* discussion which perform actions on it.
*/
export default {
const DiscussionControls = {
/**
* Get a list of controls for a discussion.
*
@@ -259,3 +259,5 @@ export default {
});
},
};
export default DiscussionControls;

View File

@@ -8,7 +8,7 @@ import extractText from '../../common/utils/extractText';
* The `PostControls` utility constructs a list of buttons for a post which
* perform actions on it.
*/
export default {
const PostControls = {
/**
* Get a list of controls for a post.
*
@@ -199,3 +199,5 @@ export default {
});
},
};
export default PostControls;

View File

@@ -1,6 +1,6 @@
import Button from '../../common/components/Button';
import Separator from '../../common/components/Separator';
import EditUserModal from '../components/EditUserModal';
import EditUserModal from '../../common/components/EditUserModal';
import UserPage from '../components/UserPage';
import ItemList from '../../common/utils/ItemList';
@@ -8,7 +8,7 @@ import ItemList from '../../common/utils/ItemList';
* The `UserControls` utility constructs a list of buttons for a user which
* perform actions on it.
*/
export default {
const UserControls = {
/**
* Get a list of controls for a user.
*
@@ -141,3 +141,5 @@ export default {
app.modal.show(EditUserModal, { user });
},
};
export default UserControls;

View File

@@ -24,4 +24,4 @@ module.exports = merge(config(), {
});
module.exports['module'].rules[0].test = /\.(tsx?|js)$/;
module.exports['module'].rules[0].use.options.presets.push('@babel/preset-typescript');
module.exports['module'].rules[0].use[1].options.presets.push('@babel/preset-typescript');

View File

@@ -10,3 +10,4 @@
@import "admin/ExtensionWidget";
@import "admin/AppearancePage";
@import "admin/MailPage";
@import "admin/UsersListPage.less";

View File

@@ -9,7 +9,7 @@
color: @muted-color;
}
.AdminHeader-description {
&-description {
margin: 0;
color: @control-color;
}

View File

@@ -1,6 +1,6 @@
.ExtensionPage {
.ExtensionPage-header {
&-header {
.ExtensionTitle {
display: flex;
align-items: center;
@@ -17,10 +17,28 @@
margin: 0;
vertical-align: middle;
}
&Items {
padding: 15px 0;
display: flex;
align-items: center;
flex-wrap: wrap;
.Checkbox {
margin: 5px 0 0 0;
display: inline-block;
}
.Checkbox.off {
.Checkbox-display {
background: @muted-more-color;
}
}
}
}
.ExtensionPage-header,
.ExtensionPage-permissions-header {
&-header,
&-permissions-header {
background: @control-bg;
h2 {
@@ -60,40 +78,6 @@
}
}
.ExtensionPage-headerItems {
padding: 15px 0;
display: flex;
align-items: center;
flex-wrap: wrap;
.Checkbox {
margin: 5px 0 0 0;
display: inline-block;
}
}
.Checkbox.off {
.Checkbox-display {
background: @muted-more-color;
}
}
.ExtensionInfo {
margin-left: auto;
.item-authors {
a {
color: @muted-color;
}
}
}
.ExtensionName {
display: inline-block;
margin-left: 8px;
}
.ExtensionIcon {
width: 30px;
height: 30px;
@@ -102,12 +86,12 @@
vertical-align: middle;
}
.ExtensionPage-headerTopItems {
&TopItems {
margin-left: auto;
}
@media (max-width: @screen-phone-max) {
.ExtensionPage-headerTopItems {
&TopItems {
float: right;
position: relative;
}
@@ -118,13 +102,13 @@
}
}
.ExtensionPage-settings, .ExtensionPage-permissions {
&-settings, &-permissions {
.ExtensionPage-subHeader {
margin: 5px 0px;
}
}
.ExtensionPage-settings {
&-settings {
margin-top: 20px;
padding: 10px 0;
@@ -133,13 +117,12 @@
}
}
.ExtensionPage-subHeader {
&-subHeader {
color: @muted-color;
font-weight: normal;
}
.ExtensionPage-permissions {
&-permissions {
.PermissionGrid-removeScope {
display: none;
@@ -150,9 +133,24 @@
padding-bottom: 25vh;
}
.ExtensionPage-permissions-header {
&-header {
margin: 20px 0 20px;
padding: 5px 0;
}
}
}
.ExtensionInfo {
margin-left: auto;
.item-authors {
a {
color: @muted-color;
}
}
}
.ExtensionName {
display: inline-block;
margin-left: 8px;
}

View File

@@ -0,0 +1,125 @@
.UserListPage {
// Pad bottom of page to make nav area look less squashed
padding-bottom: 24px;
&-grid {
width: 100%;
position: relative;
border-radius: @border-radius;
// Use CSS custom properties to define the number of columns in the grid
grid-template-columns: repeat(var(--columns), max-content);
// Ensure mobile scrollbar isn't on top of content
padding-bottom: 4px;
// Table refreshing overlay
&--loadingPage {
&::after {
content: "";
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: rgba(128, 128, 128, 0.2);
}
.LoadingIndicator-container {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
}
&--loaded,
&--loadingPage {
display: grid;
overflow-x: auto;
}
&-header {
font-weight: bold;
border-bottom: 1px solid @muted-more-color;
padding: 8px 16px;
background: @control-bg;
}
&-rowItem {
padding: 4px 16px;
display: flex;
align-items: center;
&[data-column-name="editUser"] {
padding: 0;
position: relative;
}
&--shaded {
background: darken(@body-bg, 3%);
& when (@config-dark-mode = true) {
background: lighten(@body-bg, 5%);
}
}
}
}
&-gridPagination {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
}
}
// Handles styling of default UserList columns
.UserList {
&-joinDate {
cursor: help;
text-decoration: underline;
text-decoration-style: dotted;
}
&-editModalBtn {
width: 100%;
height: 100%;
border-radius: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
&-email {
display: flex;
flex-grow: 1;
&[data-email-shown="false"] {
.UserList-emailAddress {
user-select: none;
filter: blur(4px);
cursor: pointer;
}
}
&Address {
flex-grow: 1;
margin-right: 4px;
transition: filter 0.2s ease-out;
}
&IconBtn {
margin-left: 12px;
&:hover {
text-decoration: none;
}
}
}
}

View File

@@ -90,14 +90,11 @@
.Button-label {
.transition(margin-right 0.1s);
}
.LoadingIndicator {
.LoadingIndicator-container {
color: inherit;
margin: 0 -5px 0 -15px;
}
&.loading {
.Button-label {
margin-right: 20px;
}
margin-top: -0.175em;
margin-left: 4px;
}
}

View File

@@ -3,17 +3,6 @@
.LoadingIndicator {
@spin-time: 750ms;
--size: 24px;
--thickness: 2px;
&-container[data-size="large"] & {
--size: 32px;
--thickness: 3px;
}
&-container[data-size="tiny"] & {
--size: 18px;
}
// Use the value of `color` to maintain backwards compatibility
border-color: currentColor;
@@ -30,6 +19,9 @@
// <div> container around the spinner
// Used for positioning
&-container {
--size: 24px;
--thickness: 2px;
color: @muted-color;
// Center vertically and horizontally
@@ -38,12 +30,26 @@
align-items: center;
justify-content: center;
// Size
&--large {
--size: 32px;
--thickness: 3px;
}
&--small {
--size: 18px;
}
// Display types
&--block {
height: 100px;
}
&--inline {
display: inline-block;
vertical-align: middle;
}
}
}

View File

@@ -8,7 +8,8 @@
&.focused {
margin-left: -400px;
input, .Search-results {
input,
.Search-results {
width: 400px;
}
}
@@ -61,11 +62,21 @@
.transition(all 0.4s);
box-sizing: inherit !important;
}
.LoadingIndicator-container {
height: 36px;
}
.Button {
float: left;
margin-left: -36px;
width: 36px !important;
outline: none;
width: 36px !important;
&.LoadingIndicator {
width: var(--size) !important;
padding: 0;
}
}
}

View File

@@ -17,6 +17,7 @@
@import "Button";
@import "Checkbox";
@import "Dropdown";
@import "EditUserModal";
@import "Form";
@import "FormControl";
@import "LoadingIndicator";

View File

@@ -1,3 +1,4 @@
@import "mixins/accessibility.less";
@import "mixins/border-radius.less";
@import "mixins/clearfix.less";
@import "mixins/light-contents.less";

View File

@@ -0,0 +1,100 @@
// This mixin should **only** be used in this file. If you want to define your own
// custom outline style(s), override this mixin in your own theme extension, or in
// the Custom Less section of the Admin dashboard.
#private {
.__focus-ring-styles() {
// This uses the browser's default outline styles, rather than
// using custom ones, which could introduce more issues
// Source: https://css-tricks.com/copy-the-browsers-native-focus-styles
outline: 5px auto Highlight;
outline: 5px auto -webkit-focus-ring-color;
}
}
/**
* Adds a focus ring to an element.
*
* This is only shown when focus is provided via keyboard, using the
* `:focus-visible` selector, and `:-moz-focusring` for older Firefox.
*/
.add-keyboard-focus-ring() {
// We need to declare these separately, otherwise
// browsers will ignore `:focus-visible` as they
// don't understand `:-moz-focusring`
// These are the keyboard-only versions of :focus
&:-moz-focusring {
#private.__focus-ring-styles();
}
&:focus-visible {
#private.__focus-ring-styles();
}
}
/**
* This mixin allows support for a custom focus
* selector to be supplied.
*
* For example...
*
*? button { .addKeyboardFocusRing(":focus-within") }
* becomes
*? button:focus-within { <styles> }
*
* AND
*
*? button { .addKeyboardFocusRing(" :focus-within") }
* becomes
*? button :focus-within { <styles> }
*/
.add-keyboard-focus-ring(@customFocusSelector) {
@realFocusSelector: ~"@{customFocusSelector}";
&@{realFocusSelector} {
#private.__focus-ring-styles();
}
}
/**
* Allows an offset to be supplied for an a11y
* outline.
*
* Useful for elements whose content is right up
* against their bounds.
*
* `.addKeyboardFocusRingOffset(2px)` will add an
* offset of 2 pixels to the outline.
*/
.add-keyboard-focus-ring-offset(@offset) {
.offset() {
outline-offset: @offset;
}
&:-moz-focusring {
.offset();
}
&:focus-visible {
.offset();
}
}
/**
* Allows an offset to be supplied for an a11y
* outline.
*
* Useful for elements whose content is right up
* against their bounds.
*/
.add-keyboard-focus-ring-offset(@customSelector, @offset) {
.offset() {
outline-offset: @offset;
}
@realFocusSelector: ~"@{customFocusSelector}";
&@{realFocusSelector} {
.offset();
}
}

View File

@@ -7,7 +7,6 @@
@import "forum/DiscussionList";
@import "forum/DiscussionListItem";
@import "forum/DiscussionPage";
@import "forum/EditUserModal";
@import "forum/Hero";
@import "forum/IndexPage";
@import "forum/LogInButton";

View File

@@ -23,7 +23,7 @@
&.dragover .Dropdown-toggle {
opacity: 1;
}
.LoadingIndicator {
.LoadingIndicator-container {
color: #fff;
position: absolute;
left: 0;

View File

@@ -92,7 +92,7 @@
border-radius: @border-radius @border-radius 0 0;
&.active {
display: block;
display: flex;
}
}
.ComposerBody-editor {

View File

@@ -1,48 +1,60 @@
.NotificationList {
overflow: hidden;
& .loading-indicator {
height: 100px;
}
}
.NotificationList-header {
@media @tablet-up {
padding: 12px 15px;
border-bottom: 1px solid @control-bg;
&-header {
@media @tablet-up {
padding: 12px 15px;
border-bottom: 1px solid @control-bg;
h4 {
font-size: 12px;
text-transform: uppercase;
font-weight: bold;
margin: 0;
color: @muted-color;
display: flex;
justify-content: space-between;
align-items: center;
h4 {
font-size: 12px;
text-transform: uppercase;
font-weight: bold;
margin: 0;
color: @muted-color;
}
}
}
.Button {
float: right;
margin-top: -11px;
margin-right: -11px;
// The NotificationList may be displayed inside of the drawer as a
// dropdown menu  but the drawer may have .light-contents() applied to
// it. In this case we will need to reset the button's styles back to
// normal.
& when (@config-colored-header = true) {
.Button--color(@control-color, @control-bg);
// Mark all as read button
.Button {
padding: 0;
text-decoration: none;
&:hover {
color: @link-color;
// The NotificationList may be displayed inside of the drawer as a
// dropdown menu  but the drawer may have .light-contents() applied to
// it. In this case we will need to reset the button's styles back to
// normal.
& when (@config-colored-header = true) {
color: @control-color;
&:hover,
&:focus {
color: @link-color;
}
}
.add-keyboard-focus-ring();
.add-keyboard-focus-ring-offset(4px);
.icon {
margin-right: 0;
}
}
}
// Message displayed when notifications are empty
&-empty {
color: @muted-color;
text-align: center;
padding: 50px 0;
font-size: 16px;
}
}
.NotificationList-empty {
color: @muted-color;
text-align: center;
padding: 50px 0;
font-size: 16px;
}
.NotificationGroup {
border-top: 1px solid @control-bg;
margin-top: -1px;
@@ -50,99 +62,143 @@
&:not(:last-child) {
margin-bottom: 20px;
}
}
.NotificationGroup-header {
font-weight: bold;
color: @heading-color !important;
padding: 6px 15px;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.NotificationGroup-badges {
margin-left: -2px;
margin-right: 18px;
vertical-align: 1px;
.Badge {
margin-right: -13px;
position: relative;
.Badge--size(21px);
&-header {
font-weight: bold;
color: @heading-color !important;
padding: 8px 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
// Prevent outline overflowing parent
.add-keyboard-focus-ring-offset(-1px);
}
&-badges {
@overlap: 13px;
margin-right: 8px;
padding-right: @overlap;
.Badge {
margin-right: -@overlap;
position: relative;
.Badge--size(21px);
}
}
&-content {
list-style: none;
margin: 0;
padding: 0;
}
}
.NotificationGroup-content {
list-style: none;
margin: 0;
padding: 0;
}
.Notification {
display: block;
padding: 8px 15px 8px 70px;
padding: 8px 16px;
color: @muted-color !important; // required to override .light-contents applied to header
overflow: hidden;
.unread& {
display: grid;
grid-template-columns: auto auto 1fr auto;
grid-template-areas:
"avatar icon title button"
"x x excerpt excerpt";
align-items: baseline;
row-gap: 1px;
column-gap: 6px;
// Prevent outline overflowing parent
.add-keyboard-focus-ring-offset(-1px);
&.unread {
background: @control-bg;
}
&:hover {
&:hover,
&:focus,
&:focus-within {
text-decoration: none;
background: @control-bg;
.Notification-action {
display: block;
opacity: 1;
}
}
.Avatar {
.Avatar--size(24px);
float: left;
margin: -2px 0 -2px -55px;
grid-area: avatar;
}
&-icon {
font-size: 14px;
grid-area: icon;
}
&-title {
grid-area: title;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
}
&-content {
line-height: 19px;
margin-right: 8px;
.username {
font-weight: bold;
}
}
time {
line-height: inherit;
font-size: 11px;
line-height: 19px;
font-weight: bold;
text-transform: uppercase;
}
.Notification-action {
float: right;
display: none;
margin-top: -7px;
margin-right: -10px;
&-action {
line-height: inherit;
padding: 5px 0;
padding: 0;
opacity: 0;
& when (@config-colored-header = true) {
.Button--color(@control-color, @control-bg);
.add-keyboard-focus-ring();
.add-keyboard-focus-ring-offset(4px);
&:hover {
grid-area: button;
// Needs more specificity to fix hover/focus styles not applying in dropdown
.Notification & when (@config-colored-header = true) {
color: @control-color;
&:hover,
&:focus {
color: @link-color;
}
}
.icon {
font-size: 12px;
font-size: 13px;
margin-right: 0;
}
}
}
.Notification-icon {
float: left;
margin-left: -23px;
font-size: 14px;
margin-top: 2px;
}
.Notification-content {
margin-right: 5px;
.username {
font-weight: bold;
&-excerpt {
grid-area: excerpt;
color: @muted-more-color;
font-size: 11px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
}
.Notification-excerpt {
color: @muted-more-color;
font-size: 11px;
margin-top: 3px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}

View File

@@ -17,7 +17,7 @@ core:
custom_header_heading: Custom Header
custom_header_text: => core.ref.custom_header_text
custom_styles_heading: Custom Styles
custom_styles_text: Customize your forum's appearance by adding your own LESS/CSS code to be applied on top of Flarum's default styles.
custom_styles_text: Customize your forum's appearance by adding your own Less/CSS code to be applied on top of Flarum's default styles.
dark_mode_label: Dark Mode
description: "Customize your forum's colors, logos, and other variables."
edit_css_button: Edit Custom CSS
@@ -58,7 +58,7 @@ core:
# These translations are used in the Edit Custom CSS modal dialog.
edit_css:
customize_text: "Customize your forum's appearance by adding your own LESS/CSS code to be applied on top of Flarum's <a>default styles</a>."
customize_text: "Customize your forum's appearance by adding your own Less/CSS code to be applied on top of Flarum's <a>default styles</a>."
submit_button: => core.ref.save_changes
title: Edit Custom CSS
@@ -163,6 +163,8 @@ core:
email_title: => core.admin.email.description
permissions_button: => core.admin.permissions.title
permissions_title: => core.admin.permissions.description
userlist_button: => core.admin.users.title
userlist_title: => core.admin.users.description
search_placeholder: Search Extensions
# These translations are used in the Permissions page of the admin interface.
@@ -199,7 +201,7 @@ core:
# These translations are used in the dropdown menus on the Permissions page.
permissions_controls:
allow_indefinitely_button: Indefinitely
allow_some_minutes_button: "For {count} minute|For {count} minutes"
allow_some_minutes_button: "{count, plural, one {For # minute} other {For # minutes}}"
allow_ten_minutes_button: For 10 minutes
allow_until_reply_button: Until next reply
everyone_button: Everyone
@@ -217,6 +219,46 @@ core:
remove_button: => core.ref.remove
upload_button: Choose an Image...
# These translations are used for the users list on the admin dashboard.
users:
description: A paginated list of all users on your forum.
grid:
columns:
edit_user:
button: => core.ref.edit
title: => core.ref.edit_user
tooltip: Edit {username}
email:
title: => core.ref.email
visibility_hide: Hide email address
visibility_show: Show email address
group_badges:
no_badges: None
title: Groups
join_time:
title: Joined
user_id:
title: ID
username:
profile_link_tooltip: Visit {username}'s profile
title: => core.ref.username
invalid_column_content: Invalid
pagination:
back_button: Previous page
next_button: Next page
page_counter: Page {current} of {total}
title: => core.ref.users
total_users: "Total users: {count}"
# Translations in this namespace are used by the forum user interface.
forum:
@@ -288,21 +330,6 @@ core:
replied_text: "{username} replied {ago}"
started_text: "{username} started {ago}"
# These translations are used in the Edit User modal dialog (admin function).
edit_user:
activate_button: Activate User
email_heading: => core.ref.email
email_label: => core.ref.email
groups_heading: Groups
nothing_available: There is nothing available for you to edit at this time.
password_heading: => core.ref.password
password_label: => core.ref.password
set_password_label: Set new password
submit_button: => core.ref.save_changes
title: Edit User
username_heading: => core.ref.username
username_label: => core.ref.username
# These translations are used in the Forgot Password modal dialog.
forgot_password:
dismiss_button: => core.ref.okay
@@ -388,7 +415,7 @@ core:
now_link: Now
original_post_link: Original Post
unread_text: "{count} unread"
viewing_text: "{index} of {count} post|{index} of {count} posts"
viewing_text: "{count, plural, one {{index} of {formattedCount} post} other {{index} of {formattedCount} posts}}"
# These translations are displayed between posts in the post stream.
post_stream:
@@ -474,6 +501,20 @@ core:
dropdown:
toggle_dropdown_accessible_label: Toggle dropdown menu
# These translations are used in the Edit User modal dialog (admin function).
edit_user:
activate_button: Activate User
email_heading: => core.ref.email
email_label: => core.ref.email
groups_heading: Groups
password_heading: => core.ref.password
password_label: => core.ref.password
set_password_label: Set new password
submit_button: => core.ref.save_changes
title: => core.ref.edit_user
username_heading: => core.ref.username
username_label: => core.ref.username
# These translations are displayed as error messages.
error:
dependent_extensions_message: "Cannot disable {extension} until the following dependent extensions are disabled: {extensions}"
@@ -483,6 +524,10 @@ core:
permission_denied_message: You do not have permission to do that.
rate_limit_exceeded_message: You're going a little too quickly. Please try again in a few seconds.
# These translations are used in the loading indicator component.
loading_indicator:
accessible_label: => core.ref.loading
# These translations are used as suffixes when abbreviating numbers.
number_suffix:
kilo_text: K
@@ -505,7 +550,7 @@ core:
content:
javascript_disabled_message: This site is best viewed in a modern browser with JavaScript enabled.
load_error_message: Something went wrong while trying to load the full version of this site. Try hard-refreshing this page to fix the error.
loading_text: Loading...
loading_text: => core.ref.loading
# Translations in this namespace are displayed in the basic HTML discussion view.
discussion:
@@ -620,10 +665,12 @@ core:
delete_forever: Delete Forever
discussions: Discussions # Referenced by flarum-statistics.yml
edit: Edit
edit_user: Edit User
email: Email
icon: Icon
icon_text: "Enter the name of any <a>FontAwesome</a> icon class, <em>including</em> the <code>fas fa-</code> prefix."
load_more: Load More
loading: Loading...
log_in: Log In
log_out: Log Out
mark_all_as_read: Mark All as Read
@@ -641,7 +688,7 @@ core:
save_changes: Save Changes # Referenced by flarum-suspend.yml, flarum-tags.yml
settings: Settings
sign_up: Sign Up
some_others: "{count} other|{count} others" # Referenced by flarum-likes.yml, flarum-mentions.yml
some_others: "{count, plural, one {# other} other {# others}}" # Referenced by flarum-likes.yml, flarum-mentions.yml
start_a_discussion: Start a Discussion
username: Username
users: Users # Referenced by flarum-statistics.yml

View File

@@ -3,9 +3,9 @@ validation:
active_url: "The :attribute is not a valid URL."
after: "The :attribute must be a date after :date."
after_or_equal: "The :attribute must be a date after or equal to :date."
alpha: "The :attribute may only contain letters."
alpha_dash: "The :attribute may only contain letters, numbers, dashes and underscores."
alpha_num: "The :attribute may only contain letters and numbers."
alpha: "The :attribute must only contain letters."
alpha_dash: "The :attribute must only contain letters, numbers, dashes and underscores."
alpha_num: "The :attribute must only contain letters and numbers."
array: "The :attribute must be an array."
before: "The :attribute must be a date before :date."
before_or_equal: "The :attribute must be a date before or equal to :date."
@@ -58,10 +58,10 @@ validation:
string: "The :attribute must be less than or equal :value characters."
array: "The :attribute must not have more than :value items."
max:
numeric: "The :attribute may not be greater than :max."
file: "The :attribute may not be greater than :max kilobytes."
string: "The :attribute may not be greater than :max characters."
array: "The :attribute may not have more than :max items."
numeric: "The :attribute must not be greater than :max."
file: "The :attribute must not be greater than :max kilobytes."
string: "The :attribute must not be greater than :max characters."
array: "The :attribute must not have more than :max items."
mimes: "The :attribute must be a file of type: :values."
mimetypes: "The :attribute must be a file of type: :values."
min:
@@ -69,6 +69,7 @@ validation:
file: "The :attribute must be at least :min kilobytes."
string: "The :attribute must be at least :min characters."
array: "The :attribute must have at least :min items."
multiple_of: "The :attribute must be a multiple of :value."
not_in: "The selected :attribute is invalid."
not_regex: "The :attribute format is invalid."
numeric: "The :attribute must be a number."
@@ -82,6 +83,9 @@ validation:
required_with_all: "The :attribute field is required when :values are present."
required_without: "The :attribute field is required when :values is not present."
required_without_all: "The :attribute field is required when none of :values are present."
prohibited: "The :attribute field is prohibited."
prohibited_if: "The :attribute field is prohibited when :other is :value."
prohibited_unless: "The :attribute field is prohibited unless :other is in :values."
same: "The :attribute and :other must match."
size:
numeric: "The :attribute must be :size."

View File

@@ -0,0 +1,27 @@
<?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.
*/
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => function (Builder $schema) {
if (! $schema->hasColumn('migrations', 'id')) {
$schema->table('migrations', function (Blueprint $table) {
$table->increments('id')->first();
});
}
},
'down' => function (Builder $schema) {
$schema->table('migrations', function (Blueprint $table) {
$table->dropColumn('id');
});
}
];

View File

@@ -27,6 +27,7 @@ use Flarum\Http\RouteHandlerFactory;
use Flarum\Http\UrlGenerator;
use Flarum\Locale\LocaleManager;
use Flarum\Settings\Event\Saved;
use Illuminate\Contracts\Container\Container;
use Laminas\Stratigility\MiddlewarePipe;
class AdminServiceProvider extends AbstractServiceProvider
@@ -36,8 +37,8 @@ class AdminServiceProvider extends AbstractServiceProvider
*/
public function register()
{
$this->container->extend(UrlGenerator::class, function (UrlGenerator $url) {
return $url->addCollection('admin', $this->container->make('flarum.admin.routes'), 'admin');
$this->container->extend(UrlGenerator::class, function (UrlGenerator $url, Container $container) {
return $url->addCollection('admin', $container->make('flarum.admin.routes'), 'admin');
});
$this->container->singleton('flarum.admin.routes', function () {
@@ -58,27 +59,29 @@ class AdminServiceProvider extends AbstractServiceProvider
HttpMiddleware\SetLocale::class,
'flarum.admin.route_resolver',
HttpMiddleware\CheckCsrfToken::class,
Middleware\RequireAdministrateAbility::class
Middleware\RequireAdministrateAbility::class,
HttpMiddleware\ReferrerPolicyHeader::class,
HttpMiddleware\ContentTypeOptionsHeader::class
];
});
$this->container->bind('flarum.admin.error_handler', function () {
$this->container->bind('flarum.admin.error_handler', function (Container $container) {
return new HttpMiddleware\HandleErrors(
$this->container->make(Registry::class),
$this->container['flarum.config']->inDebugMode() ? $this->container->make(WhoopsFormatter::class) : $this->container->make(ViewFormatter::class),
$this->container->tagged(Reporter::class)
$container->make(Registry::class),
$container['flarum.config']->inDebugMode() ? $container->make(WhoopsFormatter::class) : $container->make(ViewFormatter::class),
$container->tagged(Reporter::class)
);
});
$this->container->bind('flarum.admin.route_resolver', function () {
return new HttpMiddleware\ResolveRoute($this->container->make('flarum.admin.routes'));
$this->container->bind('flarum.admin.route_resolver', function (Container $container) {
return new HttpMiddleware\ResolveRoute($container->make('flarum.admin.routes'));
});
$this->container->singleton('flarum.admin.handler', function () {
$this->container->singleton('flarum.admin.handler', function (Container $container) {
$pipe = new MiddlewarePipe;
foreach ($this->container->make('flarum.admin.middleware') as $middleware) {
$pipe->pipe($this->container->make($middleware));
foreach ($container->make('flarum.admin.middleware') as $middleware) {
$pipe->pipe($container->make($middleware));
}
$pipe->pipe(new HttpMiddleware\ExecuteRoute());
@@ -86,9 +89,9 @@ class AdminServiceProvider extends AbstractServiceProvider
return $pipe;
});
$this->container->bind('flarum.assets.admin', function () {
$this->container->bind('flarum.assets.admin', function (Container $container) {
/** @var \Flarum\Frontend\Assets $assets */
$assets = $this->container->make('flarum.assets.factory')('admin');
$assets = $container->make('flarum.assets.factory')('admin');
$assets->js(function (SourceCollector $sources) {
$sources->addFile(__DIR__.'/../../js/dist/admin.js');
@@ -98,17 +101,17 @@ class AdminServiceProvider extends AbstractServiceProvider
$sources->addFile(__DIR__.'/../../less/admin.less');
});
$this->container->make(AddTranslations::class)->forFrontend('admin')->to($assets);
$this->container->make(AddLocaleAssets::class)->to($assets);
$container->make(AddTranslations::class)->forFrontend('admin')->to($assets);
$container->make(AddLocaleAssets::class)->to($assets);
return $assets;
});
$this->container->bind('flarum.frontend.admin', function () {
$this->container->bind('flarum.frontend.admin', function (Container $container) {
/** @var \Flarum\Frontend\Frontend $frontend */
$frontend = $this->container->make('flarum.frontend.factory')('admin');
$frontend = $container->make('flarum.frontend.factory')('admin');
$frontend->content($this->container->make(Content\AdminPayload::class));
$frontend->content($container->make(Content\AdminPayload::class));
return $frontend;
});

View File

@@ -14,6 +14,7 @@ use Flarum\Frontend\Document;
use Flarum\Group\Permission;
use Flarum\Settings\Event\Deserializing;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\User;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\ConnectionInterface;
@@ -81,5 +82,18 @@ class AdminPayload
$document->payload['phpVersion'] = PHP_VERSION;
$document->payload['mysqlVersion'] = $this->db->selectOne('select version() as version')->version;
/**
* Used in the admin user list. Implemented as this as it matches the API in flarum/statistics.
* If flarum/statistics ext is enabled, it will override this data with its own stats.
*
* This allows the front-end code to be simpler and use one single source of truth to pull the
* total user count from.
*/
$document->payload['modelStatistics'] = [
'users' => [
'total' => User::count()
]
];
}
}

View File

@@ -21,6 +21,7 @@ use Flarum\Http\Middleware as HttpMiddleware;
use Flarum\Http\RouteCollection;
use Flarum\Http\RouteHandlerFactory;
use Flarum\Http\UrlGenerator;
use Illuminate\Contracts\Container\Container;
use Laminas\Stratigility\MiddlewarePipe;
class ApiServiceProvider extends AbstractServiceProvider
@@ -30,8 +31,8 @@ class ApiServiceProvider extends AbstractServiceProvider
*/
public function register()
{
$this->container->extend(UrlGenerator::class, function (UrlGenerator $url) {
return $url->addCollection('api', $this->container->make('flarum.api.routes'), 'api');
$this->container->extend(UrlGenerator::class, function (UrlGenerator $url, Container $container) {
return $url->addCollection('api', $container->make('flarum.api.routes'), 'api');
});
$this->container->singleton('flarum.api.routes', function () {
@@ -51,7 +52,7 @@ class ApiServiceProvider extends AbstractServiceProvider
];
});
$this->container->bind(Middleware\ThrottleApi::class, function ($container) {
$this->container->bind(Middleware\ThrottleApi::class, function (Container $container) {
return new Middleware\ThrottleApi($container->make('flarum.api.throttlers'));
});
@@ -72,23 +73,23 @@ class ApiServiceProvider extends AbstractServiceProvider
];
});
$this->container->bind('flarum.api.error_handler', function () {
$this->container->bind('flarum.api.error_handler', function (Container $container) {
return new HttpMiddleware\HandleErrors(
$this->container->make(Registry::class),
new JsonApiFormatter($this->container['flarum.config']->inDebugMode()),
$this->container->tagged(Reporter::class)
$container->make(Registry::class),
new JsonApiFormatter($container['flarum.config']->inDebugMode()),
$container->tagged(Reporter::class)
);
});
$this->container->bind('flarum.api.route_resolver', function () {
return new HttpMiddleware\ResolveRoute($this->container->make('flarum.api.routes'));
$this->container->bind('flarum.api.route_resolver', function (Container $container) {
return new HttpMiddleware\ResolveRoute($container->make('flarum.api.routes'));
});
$this->container->singleton('flarum.api.handler', function () {
$this->container->singleton('flarum.api.handler', function (Container $container) {
$pipe = new MiddlewarePipe;
foreach ($this->container->make('flarum.api.middleware') as $middleware) {
$pipe->pipe($this->container->make($middleware));
$pipe->pipe($container->make($middleware));
}
$pipe->pipe(new HttpMiddleware\ExecuteRoute());
@@ -106,13 +107,13 @@ class ApiServiceProvider extends AbstractServiceProvider
/**
* {@inheritdoc}
*/
public function boot()
public function boot(Container $container)
{
$this->setNotificationSerializers();
AbstractSerializeController::setContainer($this->container);
AbstractSerializeController::setContainer($container);
AbstractSerializer::setContainer($this->container);
AbstractSerializer::setContainer($container);
}
/**

View File

@@ -11,8 +11,9 @@ namespace Flarum\Api\Controller;
use Flarum\Http\RequestUtil;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Filesystem\Factory;
use Illuminate\Contracts\Filesystem\Filesystem;
use Laminas\Diactoros\Response\EmptyResponse;
use League\Flysystem\FilesystemInterface;
use Psr\Http\Message\ServerRequestInterface;
class DeleteFaviconController extends AbstractDeleteController
@@ -23,18 +24,18 @@ class DeleteFaviconController extends AbstractDeleteController
protected $settings;
/**
* @var FilesystemInterface
* @var Filesystem
*/
protected $uploadDir;
/**
* @param SettingsRepositoryInterface $settings
* @param FilesystemInterface $uploadDir
* @param Factory $filesystemFactory
*/
public function __construct(SettingsRepositoryInterface $settings, FilesystemInterface $uploadDir)
public function __construct(SettingsRepositoryInterface $settings, Factory $filesystemFactory)
{
$this->settings = $settings;
$this->uploadDir = $uploadDir;
$this->uploadDir = $filesystemFactory->disk('flarum-assets');
}
/**
@@ -48,7 +49,7 @@ class DeleteFaviconController extends AbstractDeleteController
$this->settings->set('favicon_path', null);
if ($this->uploadDir->has($path)) {
if ($this->uploadDir->exists($path)) {
$this->uploadDir->delete($path);
}

View File

@@ -11,8 +11,9 @@ namespace Flarum\Api\Controller;
use Flarum\Http\RequestUtil;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Filesystem\Factory;
use Illuminate\Contracts\Filesystem\Filesystem;
use Laminas\Diactoros\Response\EmptyResponse;
use League\Flysystem\FilesystemInterface;
use Psr\Http\Message\ServerRequestInterface;
class DeleteLogoController extends AbstractDeleteController
@@ -23,18 +24,18 @@ class DeleteLogoController extends AbstractDeleteController
protected $settings;
/**
* @var FilesystemInterface
* @var Filesystem
*/
protected $uploadDir;
/**
* @param SettingsRepositoryInterface $settings
* @param FilesystemInterface $uploadDir
* @param Factory $filesystemFactory
*/
public function __construct(SettingsRepositoryInterface $settings, FilesystemInterface $uploadDir)
public function __construct(SettingsRepositoryInterface $settings, Factory $filesystemFactory)
{
$this->settings = $settings;
$this->uploadDir = $uploadDir;
$this->uploadDir = $filesystemFactory->disk('flarum-assets');
}
/**
@@ -48,7 +49,7 @@ class DeleteLogoController extends AbstractDeleteController
$this->settings->set('logo_path', null);
if ($this->uploadDir->has($path)) {
if ($this->uploadDir->exists($path)) {
$this->uploadDir->delete($path);
}

View File

@@ -10,7 +10,6 @@
namespace Flarum\Api\Controller;
use Flarum\Http\RequestUtil;
use Illuminate\Container\Container;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Mail\Message;
use Laminas\Diactoros\Response\EmptyResponse;
@@ -21,15 +20,12 @@ use Symfony\Contracts\Translation\TranslatorInterface;
class SendTestMailController implements RequestHandlerInterface
{
protected $container;
protected $mailer;
protected $translator;
public function __construct(Container $container, Mailer $mailer, TranslatorInterface $translator)
public function __construct(Mailer $mailer, TranslatorInterface $translator)
{
$this->container = $container;
$this->mailer = $mailer;
$this->translator = $translator;
}
@@ -39,7 +35,7 @@ class SendTestMailController implements RequestHandlerInterface
$actor = RequestUtil::getActor($request);
$actor->assertAdmin();
$body = $this->translator->trans('core.email.send_test.body', ['{username}' => $actor->username]);
$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);

View File

@@ -11,10 +11,11 @@ namespace Flarum\Api\Controller;
use Flarum\Http\RequestUtil;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Filesystem\Factory;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Intervention\Image\Image;
use League\Flysystem\FilesystemInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UploadedFileInterface;
use Tobscure\JsonApi\Document;
@@ -27,7 +28,7 @@ abstract class UploadImageController extends ShowForumController
protected $settings;
/**
* @var FilesystemInterface
* @var Filesystem
*/
protected $uploadDir;
@@ -48,12 +49,12 @@ abstract class UploadImageController extends ShowForumController
/**
* @param SettingsRepositoryInterface $settings
* @param FilesystemInterface $uploadDir
* @param Factory $filesystemFactory
*/
public function __construct(SettingsRepositoryInterface $settings, FilesystemInterface $uploadDir)
public function __construct(SettingsRepositoryInterface $settings, Factory $filesystemFactory)
{
$this->settings = $settings;
$this->uploadDir = $uploadDir;
$this->uploadDir = $filesystemFactory->disk('flarum-assets');
}
/**
@@ -73,7 +74,7 @@ abstract class UploadImageController extends ShowForumController
$uploadName = $this->filenamePrefix.'-'.Str::lower(Str::random(8)).'.'.$this->fileExtension;
$this->uploadDir->write($uploadName, $encodedImage);
$this->uploadDir->put($uploadName, $encodedImage);
$this->settings->set($this->filePathSettingKey, $uploadName);

View File

@@ -13,6 +13,8 @@ use Flarum\Foundation\Application;
use Flarum\Foundation\Config;
use Flarum\Http\UrlGenerator;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Filesystem\Cloud;
use Illuminate\Contracts\Filesystem\Factory;
class ForumSerializer extends AbstractSerializer
{
@@ -36,14 +38,21 @@ class ForumSerializer extends AbstractSerializer
*/
protected $url;
/**
* @var Cloud
*/
protected $assetsFilesystem;
/**
* @param Config $config
* @param Factory $filesystemFactory
* @param SettingsRepositoryInterface $settings
* @param UrlGenerator $url
*/
public function __construct(Config $config, SettingsRepositoryInterface $settings, UrlGenerator $url)
public function __construct(Config $config, Factory $filesystemFactory, SettingsRepositoryInterface $settings, UrlGenerator $url)
{
$this->config = $config;
$this->assetsFilesystem = $filesystemFactory->disk('flarum-assets');
$this->settings = $settings;
$this->url = $url;
}
@@ -107,7 +116,7 @@ class ForumSerializer extends AbstractSerializer
{
$logoPath = $this->settings->get('logo_path');
return $logoPath ? $this->url->to('forum')->path('assets/'.$logoPath) : null;
return $logoPath ? $this->getAssetUrl($logoPath) : null;
}
/**
@@ -117,6 +126,11 @@ class ForumSerializer extends AbstractSerializer
{
$faviconPath = $this->settings->get('favicon_path');
return $faviconPath ? $this->url->to('forum')->path('assets/'.$faviconPath) : null;
return $faviconPath ? $this->getAssetUrl($faviconPath) : null;
}
public function getAssetUrl($assetPath): string
{
return $this->assetsFilesystem->url($assetPath);
}
}

View File

@@ -13,13 +13,14 @@ use Flarum\Foundation\AbstractServiceProvider;
use Illuminate\Bus\Dispatcher as BaseDispatcher;
use Illuminate\Contracts\Bus\Dispatcher as DispatcherContract;
use Illuminate\Contracts\Bus\QueueingDispatcher as QueueingDispatcherContract;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Queue\Factory as QueueFactoryContract;
class BusServiceProvider extends AbstractServiceProvider
{
public function register()
{
$this->container->bind(BaseDispatcher::class, function ($container) {
$this->container->bind(BaseDispatcher::class, function (Container $container) {
return new Dispatcher($container, function ($connection = null) use ($container) {
return $container[QueueFactoryContract::class]->connection($connection);
});

View File

@@ -12,11 +12,13 @@ namespace Flarum\Console;
use Flarum\Database\Console\MigrateCommand;
use Flarum\Database\Console\ResetCommand;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\Console\AssetsPublishCommand;
use Flarum\Foundation\Console\CacheClearCommand;
use Flarum\Foundation\Console\InfoCommand;
use Illuminate\Console\Scheduling\Schedule as LaravelSchedule;
use Illuminate\Console\Scheduling\ScheduleListCommand;
use Illuminate\Console\Scheduling\ScheduleRunCommand;
use Illuminate\Contracts\Container\Container;
class ConsoleServiceProvider extends AbstractServiceProvider
{
@@ -31,12 +33,13 @@ class ConsoleServiceProvider extends AbstractServiceProvider
define('ARTISAN_BINARY', 'flarum');
}
$this->container->singleton(LaravelSchedule::class, function () {
return $this->container->make(Schedule::class);
$this->container->singleton(LaravelSchedule::class, function (Container $container) {
return $container->make(Schedule::class);
});
$this->container->singleton('flarum.console.commands', function () {
return [
AssetsPublishCommand::class,
CacheClearCommand::class,
InfoCommand::class,
MigrateCommand::class,
@@ -54,11 +57,11 @@ class ConsoleServiceProvider extends AbstractServiceProvider
/**
* {@inheritDoc}
*/
public function boot()
public function boot(Container $container)
{
$schedule = $this->container->make(LaravelSchedule::class);
$schedule = $container->make(LaravelSchedule::class);
foreach ($this->container->make('flarum.console.scheduled') as $scheduled) {
foreach ($container->make('flarum.console.scheduled') as $scheduled) {
$event = $schedule->command($scheduled['command'], $scheduled['args']);
$scheduled['callback']($event);
}

View File

@@ -88,12 +88,5 @@ class MigrateCommand extends AbstractCommand
}
$this->container->make(SettingsRepositoryInterface::class)->set('version', Application::VERSION);
$this->info('Publishing assets...');
$this->container->make('files')->copyDirectory(
$this->paths->vendor.'/components/font-awesome/webfonts',
$this->paths->public.'/assets/fonts'
);
}
}

View File

@@ -98,6 +98,7 @@ class DatabaseMigrationRepository implements MigrationRepositoryInterface
$schema = $this->connection->getSchemaBuilder();
$schema->create($this->table, function ($table) {
$table->increments('id');
$table->string('migration');
$table->string('extension')->nullable();
});

View File

@@ -10,6 +10,7 @@
namespace Flarum\Database;
use Flarum\Foundation\AbstractServiceProvider;
use Illuminate\Contracts\Container\Container;
use Illuminate\Database\Capsule\Manager;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\ConnectionResolverInterface;
@@ -21,10 +22,10 @@ class DatabaseServiceProvider extends AbstractServiceProvider
*/
public function register()
{
$this->container->singleton(Manager::class, function ($container) {
$this->container->singleton(Manager::class, function (Container $container) {
$manager = new Manager($container);
$config = $this->container['flarum']->config('database');
$config = $container['flarum']->config('database');
$config['engine'] = 'InnoDB';
$config['prefix_indexes'] = true;
@@ -33,7 +34,7 @@ class DatabaseServiceProvider extends AbstractServiceProvider
return $manager;
});
$this->container->singleton(ConnectionResolverInterface::class, function ($container) {
$this->container->singleton(ConnectionResolverInterface::class, function (Container $container) {
$manager = $container->make(Manager::class);
$manager->setAsGlobal();
$manager->bootEloquent();
@@ -46,7 +47,7 @@ class DatabaseServiceProvider extends AbstractServiceProvider
$this->container->alias(ConnectionResolverInterface::class, 'db');
$this->container->singleton(ConnectionInterface::class, function ($container) {
$this->container->singleton(ConnectionInterface::class, function (Container $container) {
$resolver = $container->make(ConnectionResolverInterface::class);
return $resolver->connection();
@@ -55,7 +56,7 @@ class DatabaseServiceProvider extends AbstractServiceProvider
$this->container->alias(ConnectionInterface::class, 'db.connection');
$this->container->alias(ConnectionInterface::class, 'flarum.db');
$this->container->singleton(MigrationRepositoryInterface::class, function ($container) {
$this->container->singleton(MigrationRepositoryInterface::class, function (Container $container) {
return new DatabaseMigrationRepository($container['flarum.db'], 'migrations');
});
@@ -64,15 +65,12 @@ class DatabaseServiceProvider extends AbstractServiceProvider
});
}
/**
* {@inheritdoc}
*/
public function boot()
public function boot(Container $container)
{
AbstractModel::setConnectionResolver($this->container->make(ConnectionResolverInterface::class));
AbstractModel::setEventDispatcher($this->container->make('events'));
AbstractModel::setConnectionResolver($container->make(ConnectionResolverInterface::class));
AbstractModel::setEventDispatcher($container->make('events'));
foreach ($this->container->make('flarum.database.model_private_checkers') as $modelClass => $checkers) {
foreach ($container->make('flarum.database.model_private_checkers') as $modelClass => $checkers) {
$modelClass::saving(function ($instance) use ($checkers) {
foreach ($checkers as $checker) {
if ($checker($instance) === true) {

View File

@@ -12,16 +12,12 @@ namespace Flarum\Discussion;
use Flarum\Discussion\Access\ScopeDiscussionVisibility;
use Flarum\Discussion\Event\Renamed;
use Flarum\Foundation\AbstractServiceProvider;
use Illuminate\Contracts\Events\Dispatcher;
class DiscussionServiceProvider extends AbstractServiceProvider
{
/**
* {@inheritdoc}
*/
public function boot()
public function boot(Dispatcher $events)
{
$events = $this->container->make('events');
$events->subscribe(DiscussionMetadataUpdater::class);
$events->listen(

View File

@@ -9,6 +9,7 @@
namespace Flarum\Discussion\Search\Gambit;
use Flarum\Discussion\Discussion;
use Flarum\Post\Post;
use Flarum\Search\GambitInterface;
use Flarum\Search\SearchState;
@@ -29,6 +30,11 @@ class FulltextGambit implements GambitInterface
$query = $search->getQuery();
$grammar = $query->getGrammar();
$discussionSubquery = Discussion::select('id')
->selectRaw('NULL as score')
->selectRaw('first_post_id as most_relevant_post_id')
->whereRaw('MATCH('.$grammar->wrap('discussions.title').') AGAINST (? IN BOOLEAN MODE)', [$bit]);
// Construct a subquery to fetch discussions which contain relevant
// posts. Retrieve the collective relevance of each discussion's posts,
// which we will use later in the order by clause, and also retrieve
@@ -39,7 +45,8 @@ class FulltextGambit implements GambitInterface
->selectRaw('SUBSTRING_INDEX(GROUP_CONCAT('.$grammar->wrap('posts.id').' ORDER BY MATCH('.$grammar->wrap('posts.content').') AGAINST (?) DESC, '.$grammar->wrap('posts.number').'), \',\', 1) as most_relevant_post_id', [$bit])
->where('posts.type', 'comment')
->whereRaw('MATCH('.$grammar->wrap('posts.content').') AGAINST (? IN BOOLEAN MODE)', [$bit])
->groupBy('posts.discussion_id');
->groupBy('posts.discussion_id')
->union($discussionSubquery);
// Join the subquery into the main search query and scope results to
// discussions that have a relevant title or that contain relevant posts.
@@ -51,11 +58,8 @@ class FulltextGambit implements GambitInterface
'=',
'discussions.id'
)
->addBinding($subquery->getBindings(), 'join')
->where(function ($query) use ($grammar, $bit) {
$query->whereRaw('MATCH('.$grammar->wrap('discussions.title').') AGAINST (? IN BOOLEAN MODE)', [$bit])
->orWhereNotNull('posts_ft.score');
});
->groupBy('discussions.id')
->addBinding($subquery->getBindings(), 'join');
$search->setDefaultSort(function ($query) use ($grammar, $bit) {
$query->orderByRaw('MATCH('.$grammar->wrap('discussions.title').') AGAINST (?) desc', [$bit]);

View File

@@ -23,6 +23,7 @@ class Auth implements ExtenderInterface
*
* @param string $identifier: Unique identifier for password checker.
* @param callable|string $callback: A closure or invokable class that contains the logic of the password checker.
* Arguments are a User $object and string $password.
* It should return:
* - `true` if the given password is valid.
* - `null` (or not return anything) if the given password is invalid, or this checker does not apply.

View File

@@ -42,7 +42,7 @@ class Event implements ExtenderInterface
* Event subscribers are classes that may subscribe to multiple events from within the subscriber class itself,
* allowing you to define several event handlers within a single class.
*
* @see https://laravel.com/docs/6.x/events#writing-event-subscribers
* @see https://laravel.com/docs/8.x/events#writing-event-subscribers
*
* @param string $subscriber: The class attribute of the subscriber class
*/

85
src/Extend/Filesystem.php Normal file
View File

@@ -0,0 +1,85 @@
<?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 Flarum\Foundation\ContainerUtil;
use Illuminate\Contracts\Container\Container;
class Filesystem implements ExtenderInterface
{
private $disks = [];
private $drivers = [];
/**
* Declare a new filesystem disk.
* Disks represent storage locations, and are backed by storage drivers.
* Flarum core uses disks for storing assets and avatars.
*
* By default, the "local" driver will be used for disks.
* The "local" driver represents the filesystem where your Flarum installation is running.
*
* To declare a new disk, you must provide default configuration a "local" driver.
*
* @param string $name: The name of the disk
* @param string|callable $callback: A callback or invokable class name with parameters:
* - \Flarum\Foundation\Paths $paths
* - \Flarum\Http\UrlGenerator $url
* which returns a Laravel disk config array.
* The `driver` key is not necessary for this array, and will be ignored.
*
* @example
* ```
* ->disk('flarum-uploads', function (Paths $paths, UrlGenerator $url) {
* return [
* 'root' => "$paths->public/assets/uploads",
* 'url' => $url->to('forum')->path('assets/uploads')
* ];
* });
* ```
*
* @see https://laravel.com/docs/8.x/filesystem#configuration
*/
public function disk(string $name, $callback)
{
$this->disks[$name] = $callback;
return $this;
}
/**
* Register a new filesystem driver.
* Drivers must implement `\Flarum\Filesystem\DriverInterface`.
*
* @param string $name: The name of the driver
* @param string $driverClass: The ::class attribute of the driver.
*/
public function driver(string $name, string $driverClass)
{
$this->drivers[$name] = $driverClass;
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
$container->extend('flarum.filesystem.disks', function ($existingDisks) use ($container) {
foreach ($this->disks as $name => $disk) {
$existingDisks[$name] = ContainerUtil::wrapCallback($disk, $container);
}
return $existingDisks;
});
$container->extend('flarum.filesystem.drivers', function ($existingDrivers) {
return array_merge($existingDrivers, $this->drivers);
});
}
}

View File

@@ -18,6 +18,7 @@ class Formatter implements ExtenderInterface, LifecycleInterface
{
private $configurationCallbacks = [];
private $parsingCallbacks = [];
private $unparsingCallbacks = [];
private $renderingCallbacks = [];
/**
@@ -58,6 +59,28 @@ class Formatter implements ExtenderInterface, LifecycleInterface
return $this;
}
/**
* Prepare the system for unparsing. This can be used to modify the text that was parsed.
* Please note that the parsed text must be returned, regardless of whether it's changed.
*
* @param callable|string $callback
*
* The callback can be a closure or invokable class, and should accept:
* - mixed $context
* - string $xml: The parsed text.
*
* The callback should return:
* - string $xml: The text to be unparsed.
*
* @return self
*/
public function unparse($callback)
{
$this->unparsingCallbacks[] = $callback;
return $this;
}
/**
* Prepare the system for rendering. This can be used to modify the xml that will be rendered, or to modify the renderer.
* Please note that the xml to be rendered must be returned, regardless of whether it's changed.
@@ -91,6 +114,10 @@ class Formatter implements ExtenderInterface, LifecycleInterface
$formatter->addParsingCallback(ContainerUtil::wrapCallback($callback, $container));
}
foreach ($this->unparsingCallbacks as $callback) {
$formatter->addUnparsingCallback(ContainerUtil::wrapCallback($callback, $container));
}
foreach ($this->renderingCallbacks as $callback) {
$formatter->addRenderingCallback(ContainerUtil::wrapCallback($callback, $container));
}

View File

@@ -97,11 +97,11 @@ class Frontend implements ExtenderInterface
if ($this->js) {
$assets->js(function (SourceCollector $sources) use ($moduleName) {
$sources->addString(function () {
return 'var module={}';
return 'var module={};';
});
$sources->addFile($this->js);
$sources->addString(function () use ($moduleName) {
return "flarum.extensions['$moduleName']=module.exports";
return "flarum.extensions['$moduleName']=module.exports;";
});
});
}

View File

@@ -17,6 +17,7 @@ use Illuminate\Contracts\Container\Container;
use InvalidArgumentException;
use RuntimeException;
use SplFileInfo;
use Symfony\Component\Translation\MessageCatalogueInterface;
class LanguagePack implements ExtenderInterface, LifecycleInterface
{
@@ -107,6 +108,9 @@ class LanguagePack implements ExtenderInterface, LifecycleInterface
// extension) with the list of known names and all extension IDs.
$slug = $file->getBasename(".{$file->getExtension()}");
// Ignore ICU MessageFormat suffixes.
$slug = str_replace(MessageCatalogueInterface::INTL_DOMAIN_SUFFIX, '', $slug);
if (in_array($slug, self::CORE_LOCALE_FILES, true)) {
return true;
}

View File

@@ -13,6 +13,7 @@ use DirectoryIterator;
use Flarum\Extension\Extension;
use Flarum\Locale\LocaleManager;
use Illuminate\Contracts\Container\Container;
use Symfony\Component\Translation\MessageCatalogueInterface;
class Locales implements ExtenderInterface, LifecycleInterface
{
@@ -38,8 +39,13 @@ class Locales implements ExtenderInterface, LifecycleInterface
continue;
}
$locale = $file->getBasename(".$extension");
// Ignore ICU MessageFormat suffixes.
$locale = str_replace(MessageCatalogueInterface::INTL_DOMAIN_SUFFIX, '', $locale);
$locales->addTranslations(
$file->getBasename(".$extension"),
$locale,
$file->getPathname()
);
}

View File

@@ -73,7 +73,7 @@ class SimpleFlarumSearch implements ExtenderInterface
public function extend(Container $container, Extension $extension = null)
{
if (! is_null($this->fullTextGambit)) {
$container->resolving('flarum.simple_search.fulltext_gambits', function ($oldFulltextGambits) {
$container->extend('flarum.simple_search.fulltext_gambits', function ($oldFulltextGambits) {
$oldFulltextGambits[$this->searcher] = $this->fullTextGambit;
return $oldFulltextGambits;

View File

@@ -27,7 +27,7 @@ class View implements ExtenderInterface, LifecycleInterface
*
* Views can then be used in your extension by injecting an instance of `Illuminate\Contracts\View\Factory`,
* and calling its `make` method. The `make` method takes the view parameter in the format NAMESPACE::VIEW_NAME.
* You can also pass variables into a view: for more information, see https://laravel.com/api/6.x/Illuminate/View/Factory.html#method_make
* You can also pass variables into a view: for more information, see https://laravel.com/api/8.x/Illuminate/View/Factory.html#method_make
*
* @param string $namespace: The name of the namespace.
* @param string|array $hints: This is a path (or an array of paths) to the folder(s)

View File

@@ -0,0 +1,30 @@
<?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\Extension\Exception;
use Exception;
use Flarum\Extension\Extension;
use Throwable;
class ExtensionBootError extends Exception
{
public $extension;
public $extender;
public function __construct(Extension $extension, $extender, Throwable $previous = null)
{
$this->extension = $extension;
$this->extender = $extender;
$extenderClass = get_class($extender);
parent::__construct("Experienced an error while booting extension: {$extension->getTitle()}.\n\nError occurred while applying an extender of type: $extenderClass.", null, $previous);
}
}

View File

@@ -11,16 +11,15 @@ namespace Flarum\Extension;
use Flarum\Database\Migrator;
use Flarum\Extend\LifecycleInterface;
use Flarum\Extension\Exception\ExtensionBootError;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Filesystem\Filesystem as FilesystemInterface;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use League\Flysystem\Adapter\Local;
use League\Flysystem\Filesystem;
use League\Flysystem\FilesystemInterface;
use League\Flysystem\MountManager;
use League\Flysystem\Plugin\ListFiles;
use Throwable;
/**
* @property string $name
@@ -53,7 +52,7 @@ class Extension implements Arrayable
protected static function nameToId($name)
{
list($vendor, $package) = explode('/', $name);
[$vendor, $package] = explode('/', $name);
$package = str_replace(['flarum-ext-', 'flarum-'], '', $package);
return "$vendor-$package";
@@ -134,7 +133,11 @@ class Extension implements Arrayable
public function extend(Container $container)
{
foreach ($this->getExtenders() as $extender) {
$extender->extend($container, $this);
try {
$extender->extend($container, $this);
} catch (Throwable $e) {
throw new ExtensionBootError($this, $extender, $e);
}
}
}
@@ -157,7 +160,7 @@ class Extension implements Arrayable
/**
* Dot notation getter for composer.json attributes.
*
* @see https://laravel.com/docs/5.1/helpers#arrays
* @see https://laravel.com/docs/8.x/helpers#arrays
*
* @param $name
* @return mixed
@@ -428,16 +431,13 @@ class Extension implements Arrayable
return;
}
$mount = new MountManager([
'source' => $source = new Filesystem(new Local($this->getPath().'/assets')),
'target' => $target,
]);
$source = new Filesystem();
$source->addPlugin(new ListFiles);
$assetFiles = $source->listFiles('/', true);
$assetFiles = $source->allFiles("$this->path/assets");
foreach ($assetFiles as $file) {
$mount->copy("source://$file[path]", "target://extensions/$this->id/$file[path]");
foreach ($assetFiles as $fullPath) {
$relPath = substr($fullPath, strlen("$this->path/assets"));
$target->put("extensions/$this->id/$relPath", $source->get($fullPath));
}
}

View File

@@ -19,6 +19,7 @@ use Flarum\Foundation\Paths;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\Filesystem\Cloud;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Schema\Builder;
use Illuminate\Filesystem\Filesystem;
@@ -252,12 +253,7 @@ class ExtensionManager
*/
protected function publishAssets(Extension $extension)
{
if ($extension->hasAssets()) {
$this->filesystem->copyDirectory(
$extension->getPath().'/assets',
$this->paths->public.'/assets/extensions/'.$extension->getId()
);
}
$extension->copyAssetsTo($this->getAssetsFilesystem());
}
/**
@@ -267,7 +263,7 @@ class ExtensionManager
*/
protected function unpublishAssets(Extension $extension)
{
$this->filesystem->deleteDirectory($this->paths->public.'/assets/extensions/'.$extension->getId());
$this->getAssetsFilesystem()->deleteDirectory('extensions/'.$extension->getId());
}
/**
@@ -279,7 +275,17 @@ class ExtensionManager
*/
public function getAsset(Extension $extension, $path)
{
return $this->paths->public.'/assets/extensions/'.$extension->getId().$path;
return $this->getAssetsFilesystem()->url($extension->getId()."/$path");
}
/**
* Get an instance of the assets filesystem.
* This is resolved dynamically because Flarum's filesystem configuration
* might not be booted yet when the ExtensionManager singleton initializes.
*/
protected function getAssetsFilesystem(): Cloud
{
return resolve('filesystem')->disk('flarum-assets');
}
/**

View File

@@ -11,6 +11,7 @@ namespace Flarum\Extension;
use Flarum\Extension\Event\Disabling;
use Flarum\Foundation\AbstractServiceProvider;
use Illuminate\Contracts\Events\Dispatcher;
class ExtensionServiceProvider extends AbstractServiceProvider
{
@@ -34,9 +35,9 @@ class ExtensionServiceProvider extends AbstractServiceProvider
/**
* {@inheritdoc}
*/
public function boot()
public function boot(Dispatcher $events)
{
$this->container->make('events')->listen(
$events->listen(
Disabling::class,
DefaultLanguagePackGuard::class
);

View File

@@ -0,0 +1,39 @@
<?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\Filesystem;
use Flarum\Foundation\Config;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Filesystem\Cloud;
interface DriverInterface
{
/**
* Construct a Laravel Cloud filesystem for this filesystem driver.
* Settings and configuration can either be pulled from the Flarum settings repository
* or the config.php file.
*
* Typically, this is done by wrapping a Flysystem adapter in Laravel's
* `Illuminate\Filesystem\FilesystemAdapter` class.
* You should ensure that the Flysystem adapter you use has a `getUrl` method.
* If it doesn't, you should create a subclass implementing that method.
* Otherwise, this driver won't work for public-facing disks
* like `flarum-assets` or `flarum-avatars`.
*
* @param string $diskName: The name of a disk this driver is being used for.
* This is generally used to locate disk-specific settings.
* @param SettingsRepositoryInterface $settings: An instance of the Flarum settings repository.
* @param Config $config: An instance of the wrapper class around `config.php`.
* @param array $localConfig: The configuration array that would have been used
* if this disk were using the 'local' filesystem driver.
* Some of these settings might be useful (e.g. visibility, )
*/
public function build(string $diskName, SettingsRepositoryInterface $settings, Config $config, array $localConfig): Cloud;
}

View File

@@ -0,0 +1,83 @@
<?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\Filesystem;
use Flarum\Foundation\Config;
use Flarum\Foundation\Paths;
use Flarum\Http\UrlGenerator;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Filesystem\FilesystemManager as LaravelFilesystemManager;
use Illuminate\Support\Arr;
use InvalidArgumentException;
class FilesystemManager extends LaravelFilesystemManager
{
protected $diskLocalConfig = [];
protected $drivers = [];
public function __construct(Container $app, array $diskLocalConfig, array $drivers)
{
parent::__construct($app);
$this->diskLocalConfig = $diskLocalConfig;
$this->drivers = $drivers;
}
/**
* @inheritDoc
*/
protected function resolve($name): Filesystem
{
$driver = $this->getDriver($name);
$localConfig = $this->getLocalConfig($name);
if (empty($localConfig)) {
throw new InvalidArgumentException("Disk [{$name}] has not been declared. Use the Filesystem extender to do this.");
}
if ($driver === 'local') {
return $this->createLocalDriver($localConfig);
}
$settings = $this->app->make(SettingsRepositoryInterface::class);
$config = $this->app->make(Config::class);
return $driver->build($name, $settings, $config, $localConfig);
}
/**
* @return string|DriverInterface
*/
protected function getDriver(string $name)
{
$config = $this->app->make(Config::class);
$settings = $this->app->make(SettingsRepositoryInterface::class);
$key = "disk_driver.$name";
$configuredDriver = Arr::get($config, $key, $settings->get($key, 'local'));
return Arr::get($this->drivers, $configuredDriver, 'local');
}
protected function getLocalConfig(string $name): array
{
if (! array_key_exists($name, $this->diskLocalConfig)) {
return [];
}
$paths = $this->app->make(Paths::class);
$url = $this->app->make(UrlGenerator::class);
return $this->diskLocalConfig[$name]($paths, $url);
}
}

View File

@@ -0,0 +1,64 @@
<?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\Filesystem;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\Paths;
use Flarum\Http\UrlGenerator;
use Illuminate\Contracts\Container\Container;
use Illuminate\Filesystem\Filesystem;
class FilesystemServiceProvider extends AbstractServiceProvider
{
/**
* {@inheritdoc}
*/
public function register()
{
$this->container->singleton('files', function () {
return new Filesystem;
});
$this->container->singleton('flarum.filesystem.disks', function () {
return [
'flarum-assets' => function (Paths $paths, UrlGenerator $url) {
return [
'root' => "$paths->public/assets",
'url' => $url->to('forum')->path('assets')
];
},
'flarum-avatars' => function (Paths $paths, UrlGenerator $url) {
return [
'root' => "$paths->public/assets/avatars",
'url' => $url->to('forum')->path('assets/avatars')
];
},
];
});
$this->container->singleton('flarum.filesystem.drivers', function () {
return [];
});
$this->container->singleton('flarum.filesystem.resolved_drivers', function (Container $container) {
return array_map(function ($driverClass) use ($container) {
return $container->make($driverClass);
}, $container->make('flarum.filesystem.drivers'));
});
$this->container->singleton('filesystem', function (Container $container) {
return new FilesystemManager(
$container,
$container->make('flarum.filesystem.disks'),
$container->make('flarum.filesystem.resolved_drivers')
);
});
}
}

View File

@@ -17,6 +17,7 @@ use Flarum\Post\Filter as PostFilter;
use Flarum\Post\Filter\PostFilterer;
use Flarum\User\Filter\UserFilterer;
use Flarum\User\Query as UserQuery;
use Illuminate\Contracts\Container\Container;
use Illuminate\Support\Arr;
class FilterServiceProvider extends AbstractServiceProvider
@@ -55,7 +56,7 @@ class FilterServiceProvider extends AbstractServiceProvider
});
}
public function boot()
public function boot(Container $container)
{
// We can resolve the filter mutators in the when->needs->give callback,
// but we need to resolve at least one regardless so we know which
@@ -63,7 +64,7 @@ class FilterServiceProvider extends AbstractServiceProvider
$filters = $this->container->make('flarum.filter.filters');
foreach ($filters as $filterer => $filterClasses) {
$this->container
$container
->when($filterer)
->needs('$filters')
->give(function () use ($filterClasses) {
@@ -77,13 +78,13 @@ class FilterServiceProvider extends AbstractServiceProvider
return $compiled;
});
$this->container
$container
->when($filterer)
->needs('$filterMutators')
->give(function () use ($filterer) {
->give(function () use ($container, $filterer) {
return array_map(function ($filterMutatorClass) {
return ContainerUtil::wrapCallback($filterMutatorClass, $this->container);
}, Arr::get($this->container->make('flarum.filter.filter_mutators'), $filterer, []));
}, Arr::get($container->make('flarum.filter.filter_mutators'), $filterer, []));
});
}
}

View File

@@ -20,6 +20,8 @@ class Formatter
protected $parsingCallbacks = [];
protected $unparsingCallbacks = [];
protected $renderingCallbacks = [];
/**
@@ -52,6 +54,11 @@ class Formatter
$this->parsingCallbacks[] = $callback;
}
public function addUnparsingCallback($callback)
{
$this->unparsingCallbacks[] = $callback;
}
public function addRenderingCallback($callback)
{
$this->renderingCallbacks[] = $callback;
@@ -98,10 +105,15 @@ class Formatter
* Unparse XML.
*
* @param string $xml
* @param mixed $context
* @return string
*/
public function unparse($xml)
public function unparse($xml, $context = null)
{
foreach ($this->unparsingCallbacks as $callback) {
$xml = $callback($context, $xml);
}
return Unparser::unparse($xml);
}

View File

@@ -24,7 +24,7 @@ class FormatterServiceProvider extends AbstractServiceProvider
$this->container->singleton('flarum.formatter', function (Container $container) {
return new Formatter(
new Repository($container->make('cache.filestore')),
$this->container[Paths::class]->storage.'/formatter'
$container[Paths::class]->storage.'/formatter'
);
});

View File

@@ -31,6 +31,9 @@ use Flarum\Locale\LocaleManager;
use Flarum\Settings\Event\Saved;
use Flarum\Settings\Event\Saving;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\View\Factory;
use Laminas\Stratigility\MiddlewarePipe;
use Symfony\Contracts\Translation\TranslatorInterface;
@@ -41,19 +44,19 @@ class ForumServiceProvider extends AbstractServiceProvider
*/
public function register()
{
$this->container->extend(UrlGenerator::class, function (UrlGenerator $url) {
return $url->addCollection('forum', $this->container->make('flarum.forum.routes'));
$this->container->extend(UrlGenerator::class, function (UrlGenerator $url, Container $container) {
return $url->addCollection('forum', $container->make('flarum.forum.routes'));
});
$this->container->singleton('flarum.forum.routes', function () {
$this->container->singleton('flarum.forum.routes', function (Container $container) {
$routes = new RouteCollection;
$this->populateRoutes($routes);
$this->populateRoutes($routes, $container);
return $routes;
});
$this->container->afterResolving('flarum.forum.routes', function (RouteCollection $routes) {
$this->setDefaultRoute($routes);
$this->container->afterResolving('flarum.forum.routes', function (RouteCollection $routes, Container $container) {
$this->setDefaultRoute($routes, $container);
});
$this->container->singleton('flarum.forum.middleware', function () {
@@ -70,26 +73,28 @@ class ForumServiceProvider extends AbstractServiceProvider
HttpMiddleware\CheckCsrfToken::class,
HttpMiddleware\ShareErrorsFromSession::class,
HttpMiddleware\FlarumPromotionHeader::class,
HttpMiddleware\ReferrerPolicyHeader::class,
HttpMiddleware\ContentTypeOptionsHeader::class
];
});
$this->container->bind('flarum.forum.error_handler', function () {
$this->container->bind('flarum.forum.error_handler', function (Container $container) {
return new HttpMiddleware\HandleErrors(
$this->container->make(Registry::class),
$this->container['flarum.config']->inDebugMode() ? $this->container->make(WhoopsFormatter::class) : $this->container->make(ViewFormatter::class),
$this->container->tagged(Reporter::class)
$container->make(Registry::class),
$container['flarum.config']->inDebugMode() ? $container->make(WhoopsFormatter::class) : $container->make(ViewFormatter::class),
$container->tagged(Reporter::class)
);
});
$this->container->bind('flarum.forum.route_resolver', function () {
return new HttpMiddleware\ResolveRoute($this->container->make('flarum.forum.routes'));
$this->container->bind('flarum.forum.route_resolver', function (Container $container) {
return new HttpMiddleware\ResolveRoute($container->make('flarum.forum.routes'));
});
$this->container->singleton('flarum.forum.handler', function () {
$this->container->singleton('flarum.forum.handler', function (Container $container) {
$pipe = new MiddlewarePipe;
foreach ($this->container->make('flarum.forum.middleware') as $middleware) {
$pipe->pipe($this->container->make($middleware));
foreach ($container->make('flarum.forum.middleware') as $middleware) {
$pipe->pipe($container->make($middleware));
}
$pipe->pipe(new HttpMiddleware\ExecuteRoute());
@@ -97,55 +102,50 @@ class ForumServiceProvider extends AbstractServiceProvider
return $pipe;
});
$this->container->bind('flarum.assets.forum', function () {
$this->container->bind('flarum.assets.forum', function (Container $container) {
/** @var Assets $assets */
$assets = $this->container->make('flarum.assets.factory')('forum');
$assets = $container->make('flarum.assets.factory')('forum');
$assets->js(function (SourceCollector $sources) {
$assets->js(function (SourceCollector $sources) use ($container) {
$sources->addFile(__DIR__.'/../../js/dist/forum.js');
$sources->addString(function () {
return $this->container->make(Formatter::class)->getJs();
$sources->addString(function () use ($container) {
return $container->make(Formatter::class)->getJs();
});
});
$assets->css(function (SourceCollector $sources) {
$assets->css(function (SourceCollector $sources) use ($container) {
$sources->addFile(__DIR__.'/../../less/forum.less');
$sources->addString(function () {
return $this->container->make(SettingsRepositoryInterface::class)->get('custom_less', '');
$sources->addString(function () use ($container) {
return $container->make(SettingsRepositoryInterface::class)->get('custom_less', '');
});
});
$this->container->make(AddTranslations::class)->forFrontend('forum')->to($assets);
$this->container->make(AddLocaleAssets::class)->to($assets);
$container->make(AddTranslations::class)->forFrontend('forum')->to($assets);
$container->make(AddLocaleAssets::class)->to($assets);
return $assets;
});
$this->container->bind('flarum.frontend.forum', function () {
return $this->container->make('flarum.frontend.factory')('forum');
$this->container->bind('flarum.frontend.forum', function (Container $container) {
return $container->make('flarum.frontend.factory')('forum');
});
}
/**
* {@inheritdoc}
*/
public function boot()
public function boot(Container $container, Dispatcher $events, Factory $view)
{
$this->loadViewsFrom(__DIR__.'/../../views', 'flarum.forum');
$this->container->make('view')->share([
'translator' => $this->container->make(TranslatorInterface::class),
'settings' => $this->container->make(SettingsRepositoryInterface::class)
$view->share([
'translator' => $container->make(TranslatorInterface::class),
'settings' => $container->make(SettingsRepositoryInterface::class)
]);
$events = $this->container->make('events');
$events->listen(
[Enabled::class, Disabled::class, ClearingCache::class],
function () {
function () use ($container) {
$recompile = new RecompileFrontendAssets(
$this->container->make('flarum.assets.forum'),
$this->container->make(LocaleManager::class)
$container->make('flarum.assets.forum'),
$container->make(LocaleManager::class)
);
$recompile->flush();
}
@@ -153,17 +153,17 @@ class ForumServiceProvider extends AbstractServiceProvider
$events->listen(
Saved::class,
function (Saved $event) {
function (Saved $event) use ($container) {
$recompile = new RecompileFrontendAssets(
$this->container->make('flarum.assets.forum'),
$this->container->make(LocaleManager::class)
$container->make('flarum.assets.forum'),
$container->make(LocaleManager::class)
);
$recompile->whenSettingsSaved($event);
$validator = new ValidateCustomLess(
$this->container->make('flarum.assets.forum'),
$this->container->make('flarum.locales'),
$this->container
$container->make('flarum.assets.forum'),
$container->make('flarum.locales'),
$container
);
$validator->whenSettingsSaved($event);
}
@@ -171,11 +171,11 @@ class ForumServiceProvider extends AbstractServiceProvider
$events->listen(
Saving::class,
function (Saving $event) {
function (Saving $event) use ($container) {
$validator = new ValidateCustomLess(
$this->container->make('flarum.assets.forum'),
$this->container->make('flarum.locales'),
$this->container
$container->make('flarum.assets.forum'),
$container->make('flarum.locales'),
$container
);
$validator->whenSettingsSaving($event);
}
@@ -186,10 +186,11 @@ class ForumServiceProvider extends AbstractServiceProvider
* Populate the forum client routes.
*
* @param RouteCollection $routes
* @param Container $container
*/
protected function populateRoutes(RouteCollection $routes)
protected function populateRoutes(RouteCollection $routes, Container $container)
{
$factory = $this->container->make(RouteHandlerFactory::class);
$factory = $container->make(RouteHandlerFactory::class);
$callback = include __DIR__.'/routes.php';
$callback($routes, $factory);
@@ -199,11 +200,12 @@ class ForumServiceProvider extends AbstractServiceProvider
* Determine the default route.
*
* @param RouteCollection $routes
* @param Container $container
*/
protected function setDefaultRoute(RouteCollection $routes)
protected function setDefaultRoute(RouteCollection $routes, Container $container)
{
$factory = $this->container->make(RouteHandlerFactory::class);
$defaultRoute = $this->container->make('flarum.settings')->get('default_route');
$factory = $container->make(RouteHandlerFactory::class);
$defaultRoute = $container->make('flarum.settings')->get('default_route');
if (isset($routes->getRouteData()[0]['GET'][$defaultRoute]['handler'])) {
$toDefaultController = $routes->getRouteData()[0]['GET'][$defaultRoute]['handler'];

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