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

Compare commits

..

4 Commits

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

View File

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

View File

@@ -38,24 +38,24 @@
"php": ">=7.2",
"axy/sourcemap": "^0.1.4",
"components/font-awesome": "5.9.*",
"dflydev/fig-cookies": "^2.0.1",
"dflydev/fig-cookies": "^1.0.2",
"doctrine/dbal": "^2.7",
"franzl/whoops-middleware": "^0.4.0",
"illuminate/bus": "5.8.*",
"illuminate/cache": "5.8.*",
"illuminate/config": "5.8.*",
"illuminate/container": "5.8.*",
"illuminate/contracts": "5.8.*",
"illuminate/database": "5.8.*",
"illuminate/events": "5.8.*",
"illuminate/filesystem": "5.8.*",
"illuminate/hashing": "5.8.*",
"illuminate/mail": "5.8.*",
"illuminate/queue": "5.8.*",
"illuminate/session": "5.8.*",
"illuminate/support": "5.8.*",
"illuminate/validation": "5.8.*",
"illuminate/view": "5.8.*",
"illuminate/bus": "5.7.*",
"illuminate/cache": "5.7.*",
"illuminate/config": "5.7.*",
"illuminate/container": "5.7.*",
"illuminate/contracts": "5.7.*",
"illuminate/database": "5.7.*",
"illuminate/events": "5.7.*",
"illuminate/filesystem": "5.7.*",
"illuminate/hashing": "5.7.*",
"illuminate/mail": "5.7.*",
"illuminate/queue": "5.7.*",
"illuminate/session": "5.7.*",
"illuminate/support": "5.7.*",
"illuminate/validation": "5.7.*",
"illuminate/view": "5.7.*",
"intervention/image": "^2.5.0",
"laminas/laminas-diactoros": "^1.8.4",
"laminas/laminas-httphandlerrunner": "^1.0",

4
js/dist/admin.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

8
js/dist/forum.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

827
js/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,8 +15,8 @@
"moment": "^2.22.2",
"punycode": "^2.1.1",
"spin.js": "^3.1.0",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11",
"webpack": "^4.41.2",
"webpack-cli": "^3.1.2",
"webpack-merge": "^4.1.4"
},
"devDependencies": {

View File

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

View File

@@ -1,4 +1,4 @@
import Page from '../../common/components/Page';
import Page from './Page';
import Button from '../../common/components/Button';
import Switch from '../../common/components/Switch';
import EditCustomCssModal from './EditCustomCssModal';

View File

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

View File

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

View File

@@ -1,8 +1,17 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import Component from '../../common/Component';
export default class DashboardWidget extends Component {
export default class Widget extends Component {
view() {
return <div className={'DashboardWidget Widget ' + this.className()}>{this.content()}</div>;
return <div className={'Widget ' + this.className()}>{this.content()}</div>;
}
/**

View File

@@ -1,10 +1,13 @@
import Page from '../../common/components/Page';
import Page from './Page';
import LinkButton from '../../common/components/LinkButton';
import Button from '../../common/components/Button';
import Dropdown from '../../common/components/Dropdown';
import Separator from '../../common/components/Separator';
import AddExtensionModal from './AddExtensionModal';
import LoadingModal from './LoadingModal';
import ItemList from '../../common/utils/ItemList';
import icon from '../../common/helpers/icon';
import listItems from '../../common/helpers/listItems';
export default class ExtensionsPage extends Page {
view() {

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import Page from '../../common/components/Page';
import Page from './Page';
import GroupBadge from '../../common/components/GroupBadge';
import EditGroupModal from './EditGroupModal';
import Group from '../../common/models/Group';

View File

@@ -0,0 +1,34 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import Component from '../../common/Component';
export default class DashboardWidget extends Component {
view() {
return <div className={'DashboardWidget ' + this.className()}>{this.content()}</div>;
}
/**
* Get the class name to apply to the widget.
*
* @return {String}
*/
className() {
return '';
}
/**
* Get the content of the widget.
*
* @return {VirtualElement}
*/
content() {
return [];
}
}

View File

@@ -21,7 +21,6 @@ import Post from './models/Post';
import Group from './models/Group';
import Notification from './models/Notification';
import { flattenDeep } from 'lodash-es';
import PageState from './states/PageState';
/**
* The `App` class provides a container for an application, as well as various
@@ -116,28 +115,6 @@ export default class Application {
*/
requestError = null;
/**
* The page the app is currently on.
*
* This object holds information about the type of page we are currently
* visiting, and sometimes additional arbitrary page state that may be
* relevant to lower-level components.
*
* @type {PageState}
*/
current = new PageState(null);
/**
* The page the app was on before the current page.
*
* Once the application navigates to another page, the object previously
* assigned to this.current will be moved to this.previous, while this.current
* is re-initialized.
*
* @type {PageState}
*/
previous = new PageState(null);
data;
title = '';
@@ -347,15 +324,12 @@ export default class Application {
}
const isDebug = app.forum.attribute('debug');
// contains a formatted errors if possible, response must be an JSON API array of errors
// the details property is decoded to transform escaped characters such as '\n'
const formattedError = error.response && Array.isArray(error.response.errors) && error.response.errors.map((e) => decodeURI(e.detail));
error.alert = new Alert({
type: 'error',
children,
controls: isDebug && [
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error, formattedError)}>
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error)}>
Debug
</Button>,
],
@@ -364,17 +338,6 @@ export default class Application {
try {
options.errorHandler(error);
} catch (error) {
if (isDebug && error.xhr) {
const { method, url } = error.options;
const { status = '' } = error.xhr;
console.group(`${method} ${url} ${status}`);
console.error(...(formattedError || [error]));
console.groupEnd();
}
this.alerts.show(error.alert);
}
@@ -387,13 +350,12 @@ export default class Application {
/**
* @param {RequestError} error
* @param {string[]} [formattedError]
* @private
*/
showDebug(error, formattedError) {
showDebug(error) {
this.alerts.dismiss(this.requestError.alert);
this.modal.show(new RequestErrorModal({ error, formattedError }));
this.modal.show(new RequestErrorModal({ error }));
}
/**

View File

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

View File

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

View File

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

View File

@@ -43,6 +43,8 @@ export default class ModalManager extends Component {
this.showing = true;
this.component = component;
if (app.current) app.current.retain = true;
m.redraw(true);
const dismissible = !!this.component.isDismissible();

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import History from './utils/History';
import Pane from './utils/Pane';
import Search from './components/Search';
import ReplyComposer from './components/ReplyComposer';
import DiscussionPage from './components/DiscussionPage';
import SignUpModal from './components/SignUpModal';
@@ -13,9 +14,6 @@ import routes from './routes';
import alertEmailConfirmation from './utils/alertEmailConfirmation';
import Application from '../common/Application';
import Navigation from '../common/components/Navigation';
import NotificationListState from './states/NotificationListState';
import GlobalSearchState from './states/GlobalSearchState';
import DiscussionListState from './state/DiscussionListState';
export default class ForumApplication extends Application {
/**
@@ -36,6 +34,13 @@ export default class ForumApplication extends Application {
discussionRenamed: DiscussionRenamedPost,
};
/**
* The page's search component instance.
*
* @type {Search}
*/
search = new Search();
/**
* An object which controls the state of the page's side pane.
*
@@ -58,38 +63,10 @@ export default class ForumApplication extends Application {
*/
history = new History();
/**
* An object which controls the state of the user's notifications.
*
* @type {NotificationListState}
*/
notifications = new NotificationListState(this);
/*
* An object which stores previously searched queries and provides convenient
* tools for retrieving and managing search values.
*
* @type {GlobalSearchState}
*/
search = new GlobalSearchState();
constructor() {
super();
routes(this);
/**
* An object which controls the state of the cached discussion list, which
* is used in the index page and the slideout pane.
*
* @type {DiscussionListState}
*/
this.discussions = new DiscussionListState({ forumApp: this });
/**
* @deprecated beta 14, remove in beta 15.
*/
this.cache.discussionList = this.discussions;
}
/**
@@ -160,7 +137,7 @@ export default class ForumApplication extends Application {
* @return {Boolean}
*/
viewingDiscussion(discussion) {
return this.current.matches(DiscussionPage, { discussion });
return this.current instanceof DiscussionPage && this.current.discussion === discussion;
}
/**

View File

@@ -23,6 +23,7 @@ import PostEdited from './components/PostEdited';
import PostStream from './components/PostStream';
import ChangePasswordModal from './components/ChangePasswordModal';
import IndexPage from './components/IndexPage';
import Page from './components/Page';
import DiscussionRenamedNotification from './components/DiscussionRenamedNotification';
import DiscussionsSearchSource from './components/DiscussionsSearchSource';
import HeaderSecondary from './components/HeaderSecondary';
@@ -91,6 +92,7 @@ export default Object.assign(compat, {
'components/PostStream': PostStream,
'components/ChangePasswordModal': ChangePasswordModal,
'components/IndexPage': IndexPage,
'components/Page': Page,
'components/DiscussionRenamedNotification': DiscussionRenamedNotification,
'components/DiscussionsSearchSource': DiscussionsSearchSource,
'components/HeaderSecondary': HeaderSecondary,

View File

@@ -31,7 +31,13 @@ export default class CommentPost extends Post {
*/
this.revealContent = false;
this.subtree.check(() => this.isEditing());
// Create an instance of the component that displays the post's author so
// that we can force the post to rerender when the user card is shown.
this.postUser = new PostUser({ post: this.props.post });
this.subtree.check(
() => this.postUser.cardVisible,
() => this.isEditing()
);
}
content() {
@@ -123,12 +129,13 @@ export default class CommentPost extends Post {
headerItems() {
const items = new ItemList();
const post = this.props.post;
const props = { post };
items.add('user', PostUser.component({ post }), 100);
items.add('meta', PostMeta.component({ post }));
items.add('user', this.postUser.render(), 100);
items.add('meta', PostMeta.component(props));
if (post.isEdited() && !post.isHidden()) {
items.add('edited', PostEdited.component({ post }));
items.add('edited', PostEdited.component(props));
}
// If the post is hidden, add a button that allows toggling the visibility

View File

@@ -98,7 +98,7 @@ export default class DiscussionComposer extends ComposerBody {
.save(data)
.then((discussion) => {
app.composer.hide();
app.discussions.refresh();
app.cache.discussionList.refresh();
m.route(app.route.discussion(discussion));
}, this.loaded.bind(this));
}

View File

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

View File

@@ -1,4 +1,4 @@
import Page from '../../common/components/Page';
import Page from './Page';
import ItemList from '../../common/utils/ItemList';
import DiscussionHero from './DiscussionHero';
import PostStream from './PostStream';
@@ -7,7 +7,6 @@ import LoadingIndicator from '../../common/components/LoadingIndicator';
import SplitDropdown from '../../common/components/SplitDropdown';
import listItems from '../../common/helpers/listItems';
import DiscussionControls from '../utils/DiscussionControls';
import DiscussionList from './DiscussionList';
/**
* The `DiscussionPage` component displays a whole discussion page, including
@@ -36,13 +35,13 @@ export default class DiscussionPage extends Page {
// If the discussion list has been loaded, then we'll enable the pane (and
// hide it by default). Also, if we've just come from another discussion
// page, then we don't want Mithril to redraw the whole page if it did,
// then the pane would redraw which would be slow and would cause problems with
// then the pane would which would be slow and would cause problems with
// event handlers.
if (app.discussions.hasDiscussions()) {
if (app.cache.discussionList) {
app.pane.enable();
app.pane.hide();
if (app.previous.matches(DiscussionPage)) {
if (app.previous instanceof DiscussionPage) {
m.redraw.strategy('diff');
}
}
@@ -91,9 +90,9 @@ export default class DiscussionPage extends Page {
return (
<div className="DiscussionPage">
{app.discussions.hasDiscussions() ? (
{app.cache.discussionList ? (
<div className="DiscussionPage-list" config={this.configPane.bind(this)}>
{!$('.App-navigation').is(':visible') && <DiscussionList state={app.discussions} />}
{!$('.App-navigation').is(':visible') ? app.cache.discussionList.render() : ''}
</div>
) : (
''
@@ -200,9 +199,6 @@ export default class DiscussionPage extends Page {
this.stream = new PostStream({ discussion, includedPosts });
this.stream.on('positionChanged', this.positionChanged.bind(this));
this.stream.goToNumber(m.route.param('near') || (includedPosts[0] && includedPosts[0].number()), true);
app.current.set('discussion', discussion);
app.current.set('stream', this.stream);
}
/**

View File

@@ -1,6 +1,4 @@
import ComposerBody from './ComposerBody';
import Alert from '../../common/components/Alert';
import Button from '../../common/components/Button';
import icon from '../../common/helpers/icon';
function minimizeComposerIfFullScreen(e) {
@@ -77,40 +75,10 @@ export default class EditPostComposer extends ComposerBody {
}
onsubmit() {
const discussion = this.props.post.discussion();
this.loading = true;
const data = this.data();
this.props.post.save(data).then((post) => {
// If we're currently viewing the discussion which this edit was made
// in, then we can scroll to the post.
if (app.viewingDiscussion(discussion)) {
app.current.stream.goToNumber(post.number());
} else {
// Otherwise, we'll create an alert message to inform the user that
// their edit has been made, containing a button which will
// transition to their edited post when clicked.
let alert;
const viewButton = Button.component({
className: 'Button Button--link',
children: app.translator.trans('core.forum.composer_edit.view_button'),
onclick: () => {
m.route(app.route.post(post));
app.alerts.dismiss(alert);
},
});
app.alerts.show(
(alert = new Alert({
type: 'success',
children: app.translator.trans('core.forum.composer_edit.edited_message'),
controls: [viewButton],
}))
);
}
app.composer.hide();
}, this.loaded.bind(this));
this.props.post.save(data).then(() => app.composer.hide(), this.loaded.bind(this));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -89,8 +89,7 @@ export default class ReplyComposer extends ComposerBody {
// If we're currently viewing the discussion which this reply was made
// in, then we can update the post stream and scroll to the post.
if (app.viewingDiscussion(discussion)) {
const stream = app.current.get('stream');
stream.update().then(() => stream.goToNumber(post.number()));
app.current.stream.update().then(() => app.current.stream.goToNumber(post.number()));
} else {
// Otherwise, we'll create an alert message to inform the user that
// their reply has been posted, containing a button which will

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -178,7 +178,7 @@ export default {
app.composer.show();
if (goToLast && app.viewingDiscussion(this) && !app.composer.isFullScreen()) {
app.current.get('stream').goToNumber('reply');
app.current.stream.goToNumber('reply');
}
deferred.resolve(component);
@@ -229,7 +229,13 @@ export default {
app.history.back();
}
return this.delete().then(() => app.discussions.removeDiscussion(this));
return this.delete().then(() => {
// If there is a discussion list in the cache, remove this discussion.
if (app.cache.discussionList) {
app.cache.discussionList.removeDiscussion(this);
m.redraw();
}
});
}
},

View File

@@ -181,7 +181,10 @@ export default {
// If this was the last post in the discussion, then we will assume that
// the whole discussion was deleted too.
if (!discussion.postIds().length) {
app.discussions.removeDiscussion(discussion);
// If there is a discussion list in the cache, remove this discussion.
if (app.cache.discussionList) {
app.cache.discussionList.removeDiscussion(discussion);
}
if (app.viewingDiscussion(discussion)) {
app.history.back();

View File

@@ -112,7 +112,7 @@ export default {
.delete()
.then(() => {
this.showDeletionAlert(user, 'success');
if (app.current.matches(UserPage, { user })) {
if (app.current instanceof UserPage && app.current.user === user) {
app.history.back();
} else {
window.location.reload();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ namespace Flarum\Admin;
use Flarum\Extension\Event\Disabled;
use Flarum\Extension\Event\Enabled;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\Application;
use Flarum\Foundation\ErrorHandling\Registry;
use Flarum\Foundation\ErrorHandling\Reporter;
use Flarum\Foundation\ErrorHandling\ViewFormatter;
@@ -49,7 +50,6 @@ class AdminServiceProvider extends AbstractServiceProvider
$this->app->singleton('flarum.admin.middleware', function () {
return [
'flarum.admin.error_handler',
HttpMiddleware\ParseJsonBody::class,
HttpMiddleware\StartSession::class,
HttpMiddleware\RememberFromCookie::class,
@@ -60,17 +60,16 @@ class AdminServiceProvider extends AbstractServiceProvider
];
});
$this->app->bind('flarum.admin.error_handler', function () {
return new HttpMiddleware\HandleErrors(
$this->app->make(Registry::class),
$this->app['flarum']->inDebugMode() ? $this->app->make(WhoopsFormatter::class) : $this->app->make(ViewFormatter::class),
$this->app->tagged(Reporter::class)
);
});
$this->app->singleton('flarum.admin.handler', function () {
$this->app->singleton('flarum.admin.handler', function (Application $app) {
$pipe = new MiddlewarePipe;
// All requests should first be piped through our global error handler
$pipe->pipe(new HttpMiddleware\HandleErrors(
$app->make(Registry::class),
$app->inDebugMode() ? $app->make(WhoopsFormatter::class) : $app->make(ViewFormatter::class),
$app->tagged(Reporter::class)
));
foreach ($this->app->make('flarum.admin.middleware') as $middleware) {
$pipe->pipe($this->app->make($middleware));
}

View File

@@ -14,18 +14,12 @@ use Flarum\Frontend\Document;
use Flarum\Group\Permission;
use Flarum\Settings\Event\Deserializing;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\ConnectionInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
class AdminPayload
{
/**
* @var Container;
*/
protected $container;
/**
* @var SettingsRepositoryInterface
*/
@@ -42,20 +36,13 @@ class AdminPayload
protected $db;
/**
* @param Container $container
* @param SettingsRepositoryInterface $settings
* @param ExtensionManager $extensions
* @param ConnectionInterface $db
* @param Dispatcher $events
*/
public function __construct(
Container $container,
SettingsRepositoryInterface $settings,
ExtensionManager $extensions,
ConnectionInterface $db,
Dispatcher $events
) {
$this->container = $container;
public function __construct(SettingsRepositoryInterface $settings, ExtensionManager $extensions, ConnectionInterface $db, Dispatcher $events)
{
$this->settings = $settings;
$this->extensions = $extensions;
$this->db = $db;
@@ -74,8 +61,6 @@ class AdminPayload
$document->payload['permissions'] = Permission::map();
$document->payload['extensions'] = $this->extensions->getExtensions()->toArray();
$document->payload['displayNameDrivers'] = array_keys($this->container->make('flarum.user.display_name.supported_drivers'));
$document->payload['phpVersion'] = PHP_VERSION;
$document->payload['mysqlVersion'] = $this->db->selectOne('select version() as version')->version;
}

View File

@@ -13,8 +13,10 @@ use Flarum\Api\Controller\AbstractSerializeController;
use Flarum\Api\Serializer\AbstractSerializer;
use Flarum\Api\Serializer\BasicDiscussionSerializer;
use Flarum\Api\Serializer\NotificationSerializer;
use Flarum\Event\ConfigureApiRoutes;
use Flarum\Event\ConfigureNotificationTypes;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\Application;
use Flarum\Foundation\ErrorHandling\JsonApiFormatter;
use Flarum\Foundation\ErrorHandling\Registry;
use Flarum\Foundation\ErrorHandling\Reporter;
@@ -44,7 +46,6 @@ class ApiServiceProvider extends AbstractServiceProvider
$this->app->singleton('flarum.api.middleware', function () {
return [
'flarum.api.error_handler',
HttpMiddleware\ParseJsonBody::class,
Middleware\FakeHttpMethods::class,
HttpMiddleware\StartSession::class,
@@ -56,17 +57,15 @@ class ApiServiceProvider extends AbstractServiceProvider
];
});
$this->app->bind('flarum.api.error_handler', function () {
return new HttpMiddleware\HandleErrors(
$this->app->make(Registry::class),
new JsonApiFormatter($this->app['flarum']->inDebugMode()),
$this->app->tagged(Reporter::class)
);
});
$this->app->singleton('flarum.api.handler', function () {
$this->app->singleton('flarum.api.handler', function (Application $app) {
$pipe = new MiddlewarePipe;
$pipe->pipe(new HttpMiddleware\HandleErrors(
$app->make(Registry::class),
new JsonApiFormatter($app->inDebugMode()),
$app->tagged(Reporter::class)
));
foreach ($this->app->make('flarum.api.middleware') as $middleware) {
$pipe->pipe($this->app->make($middleware));
}
@@ -121,5 +120,9 @@ class ApiServiceProvider extends AbstractServiceProvider
$callback = include __DIR__.'/routes.php';
$callback($routes, $factory);
$this->app->make('events')->dispatch(
new ConfigureApiRoutes($routes, $factory)
);
}
}

View File

@@ -1,53 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Controller;
use Flarum\User\AssertPermissionTrait;
use Illuminate\Container\Container;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Mail\Message;
use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Symfony\Component\Translation\TranslatorInterface;
class SendTestMailController implements RequestHandlerInterface
{
use AssertPermissionTrait;
protected $container;
protected $mailer;
protected $translator;
public function __construct(Container $container, Mailer $mailer, TranslatorInterface $translator)
{
$this->container = $container;
$this->mailer = $mailer;
$this->translator = $translator;
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$actor = $request->getAttribute('actor');
$this->assertAdmin($actor);
$body = $this->translator->trans('core.email.send_test.body', ['{username}' => $actor->username]);
$this->mailer->raw($body, function (Message $message) use ($actor) {
$message->to($actor->email);
$message->subject($this->translator->trans('core.email.send_test.subject'));
});
return new EmptyResponse();
}
}

View File

@@ -12,7 +12,7 @@ namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\CurrentUserSerializer;
use Flarum\Api\Serializer\UserSerializer;
use Flarum\User\Command\EditUser;
use Flarum\User\Exception\NotAuthenticatedException;
use Flarum\User\Exception\PermissionDeniedException;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
@@ -62,7 +62,7 @@ class UpdateUserController extends AbstractShowController
$password = Arr::get($request->getParsedBody(), 'meta.password');
if (! $actor->checkPassword($password)) {
throw new NotAuthenticatedException;
throw new PermissionDeniedException;
}
}

View File

@@ -10,24 +10,40 @@
namespace Flarum\Api\Serializer;
use Flarum\Discussion\Discussion;
use Flarum\User\Gate;
class DiscussionSerializer extends BasicDiscussionSerializer
{
/**
* @var \Flarum\User\Gate
*/
protected $gate;
/**
* @param \Flarum\User\Gate $gate
*/
public function __construct(Gate $gate)
{
$this->gate = $gate;
}
/**
* {@inheritdoc}
*/
protected function getDefaultAttributes($discussion)
{
$gate = $this->gate->forUser($this->actor);
$attributes = parent::getDefaultAttributes($discussion) + [
'commentCount' => (int) $discussion->comment_count,
'participantCount' => (int) $discussion->participant_count,
'createdAt' => $this->formatDate($discussion->created_at),
'lastPostedAt' => $this->formatDate($discussion->last_posted_at),
'lastPostNumber' => (int) $discussion->last_post_number,
'canReply' => $this->actor->can('reply', $discussion),
'canRename' => $this->actor->can('rename', $discussion),
'canDelete' => $this->actor->can('delete', $discussion),
'canHide' => $this->actor->can('hide', $discussion)
'canReply' => $gate->allows('reply', $discussion),
'canRename' => $gate->allows('rename', $discussion),
'canDelete' => $gate->allows('delete', $discussion),
'canHide' => $gate->allows('hide', $discussion)
];
if ($discussion->hidden_at) {

View File

@@ -10,9 +10,23 @@
namespace Flarum\Api\Serializer;
use Flarum\Post\CommentPost;
use Flarum\User\Gate;
class PostSerializer extends BasicPostSerializer
{
/**
* @var \Flarum\User\Gate
*/
protected $gate;
/**
* @param \Flarum\User\Gate $gate
*/
public function __construct(Gate $gate)
{
$this->gate = $gate;
}
/**
* {@inheritdoc}
*/
@@ -22,13 +36,15 @@ class PostSerializer extends BasicPostSerializer
unset($attributes['content']);
$canEdit = $this->actor->can('edit', $post);
$gate = $this->gate->forUser($this->actor);
$canEdit = $gate->allows('edit', $post);
if ($post instanceof CommentPost) {
if ($canEdit) {
$attributes['content'] = $post->content;
}
if ($this->actor->can('viewIps', $post)) {
if ($gate->allows('viewIps', $post)) {
$attributes['ipAddress'] = $post->ip_address;
}
} else {
@@ -46,8 +62,8 @@ class PostSerializer extends BasicPostSerializer
$attributes += [
'canEdit' => $canEdit,
'canDelete' => $this->actor->can('delete', $post),
'canHide' => $this->actor->can('hide', $post)
'canDelete' => $gate->allows('delete', $post),
'canHide' => $gate->allows('hide', $post)
];
return $attributes;

View File

@@ -9,8 +9,23 @@
namespace Flarum\Api\Serializer;
use Flarum\User\Gate;
class UserSerializer extends BasicUserSerializer
{
/**
* @var \Flarum\User\Gate
*/
protected $gate;
/**
* @param Gate $gate
*/
public function __construct(Gate $gate)
{
$this->gate = $gate;
}
/**
* @param \Flarum\User\User $user
* @return array
@@ -19,14 +34,16 @@ class UserSerializer extends BasicUserSerializer
{
$attributes = parent::getDefaultAttributes($user);
$canEdit = $this->actor->can('edit', $user);
$gate = $this->gate->forUser($this->actor);
$canEdit = $gate->allows('edit', $user);
$attributes += [
'joinTime' => $this->formatDate($user->joined_at),
'discussionCount' => (int) $user->discussion_count,
'commentCount' => (int) $user->comment_count,
'canEdit' => $canEdit,
'canDelete' => $this->actor->can('delete', $user),
'canDelete' => $gate->allows('delete', $user),
];
if ($user->getPreference('discloseOnline') || $this->actor->can('viewLastSeenAt', $user)) {

View File

@@ -309,15 +309,8 @@ return function (RouteCollection $map, RouteHandlerFactory $route) {
// List available mail drivers, available fields and validation status
$map->get(
'/mail/settings',
'/mail-settings',
'mailSettings.index',
$route->toController(Controller\ShowMailSettingsController::class)
);
// Send test mail post
$map->post(
'/mail/test',
'mailTest',
$route->toController(Controller\SendTestMailController::class)
);
};

View File

@@ -0,0 +1,58 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Console\Event;
use Illuminate\Console\Command;
use Illuminate\Contracts\Container\Container;
use Symfony\Component\Console\Application;
/**
* @deprecated
*/
class Configuring
{
/**
* @var Container
*/
public $app;
/**
* @var Application
*/
public $console;
/**
* @param Container $container
* @param Application $console
*/
public function __construct(Container $container, Application $console)
{
$this->app = $container;
$this->console = $console;
}
/**
* Add a console command to the flarum binary.
*
* @param Command|string $command
*/
public function addCommand($command)
{
if (is_string($command)) {
$command = $this->app->make($command);
}
if ($command instanceof Command) {
$command->setLaravel($this->app);
}
$this->console->add($command);
}
}

View File

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

View File

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

View File

@@ -13,7 +13,6 @@ use Flarum\Console\AbstractCommand;
use Flarum\Database\Migrator;
use Flarum\Extension\ExtensionManager;
use Flarum\Foundation\Application;
use Flarum\Foundation\Paths;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Container\Container;
use Illuminate\Database\ConnectionInterface;
@@ -27,18 +26,18 @@ class MigrateCommand extends AbstractCommand
protected $container;
/**
* @var Paths
* @var Application
*/
protected $paths;
protected $app;
/**
* @param Container $container
* @param Paths $paths
* @param Application $application
*/
public function __construct(Container $container, Paths $paths)
public function __construct(Container $container, Application $application)
{
$this->container = $container;
$this->paths = $paths;
$this->app = $application;
parent::__construct();
}
@@ -92,8 +91,8 @@ class MigrateCommand extends AbstractCommand
$this->info('Publishing assets...');
$this->container->make('files')->copyDirectory(
$this->paths->vendor.'/components/font-awesome/webfonts',
$this->paths->public.'/assets/fonts'
$this->app->vendorPath().'/components/font-awesome/webfonts',
$this->app->publicPath().'/assets/fonts'
);
}
}

View File

@@ -24,7 +24,7 @@ class DatabaseServiceProvider extends AbstractServiceProvider
$this->app->singleton(Manager::class, function ($app) {
$manager = new Manager($app);
$config = $this->app['flarum']->config('database');
$config = $app->config('database');
$config['engine'] = 'InnoDB';
$config['prefix_indexes'] = true;
@@ -54,10 +54,6 @@ class DatabaseServiceProvider extends AbstractServiceProvider
$this->app->alias(ConnectionInterface::class, 'db.connection');
$this->app->alias(ConnectionInterface::class, 'flarum.db');
$this->app->singleton(MigrationRepositoryInterface::class, function ($app) {
return new DatabaseMigrationRepository($app['flarum.db'], 'migrations');
});
}
/**

View File

@@ -9,7 +9,7 @@
namespace Flarum\Database;
use Flarum\Foundation\Paths;
use Flarum\Extension\Extension;
use Illuminate\Filesystem\Filesystem;
class MigrationCreator
@@ -22,27 +22,27 @@ class MigrationCreator
protected $files;
/**
* @var Paths
* @var string
*/
protected $paths;
protected $publicPath;
/**
* Create a new migrator instance.
*
* @param Filesystem $files
* @param Paths $paths
* @param string $publicPath
*/
public function __construct(Filesystem $files, Paths $paths)
public function __construct(Filesystem $files, $publicPath)
{
$this->files = $files;
$this->paths = $paths;
$this->publicPath = $publicPath;
}
/**
* Create a new migration for the given extension.
*
* @param string $name
* @param string $extension
* @param Extension $extension
* @param string $table
* @param bool $create
* @return string
@@ -105,11 +105,9 @@ class MigrationCreator
*/
protected function getMigrationPath($extension)
{
if ($extension) {
return $this->paths->vendor.'/'.$extension.'/migrations';
} else {
return __DIR__.'/../../migrations';
}
$parent = $extension ? public_path('extensions/'.$extension) : __DIR__.'/../..';
return $parent.'/migrations';
}
/**

View File

@@ -0,0 +1,31 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Database;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\Application;
use Illuminate\Filesystem\Filesystem;
class MigrationServiceProvider extends AbstractServiceProvider
{
/**
* {@inheritdoc}
*/
public function register()
{
$this->app->singleton(MigrationRepositoryInterface::class, function ($app) {
return new DatabaseMigrationRepository($app['flarum.db'], 'migrations');
});
$this->app->bind(MigrationCreator::class, function (Application $app) {
return new MigrationCreator($app->make(Filesystem::class), $app->basePath());
});
}
}

View File

@@ -12,6 +12,7 @@ namespace Flarum\Discussion;
use Flarum\Event\ScopeModelVisibility;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\AbstractPolicy;
use Flarum\User\Gate;
use Flarum\User\User;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\Eloquent\Builder;
@@ -28,6 +29,11 @@ class DiscussionPolicy extends AbstractPolicy
*/
protected $settings;
/**
* @var Gate
*/
protected $gate;
/**
* @var Dispatcher
*/
@@ -35,11 +41,13 @@ class DiscussionPolicy extends AbstractPolicy
/**
* @param SettingsRepositoryInterface $settings
* @param Gate $gate
* @param Dispatcher $events
*/
public function __construct(SettingsRepositoryInterface $settings, Dispatcher $events)
public function __construct(SettingsRepositoryInterface $settings, Gate $gate, Dispatcher $events)
{
$this->settings = $settings;
$this->gate = $gate;
$this->events = $events;
}

View File

@@ -0,0 +1,90 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Event;
use Flarum\Http\RouteCollection;
use Flarum\Http\RouteHandlerFactory;
/**
* @deprecated Will be removed in Beta.14.
*/
abstract class AbstractConfigureRoutes
{
/**
* @var RouteCollection
*/
public $routes;
/**
* @var RouteHandlerFactory
*/
protected $route;
/**
* @param RouteCollection $routes
* @param \Flarum\Http\RouteHandlerFactory $route
*/
public function __construct(RouteCollection $routes, RouteHandlerFactory $route)
{
$this->routes = $routes;
$this->route = $route;
}
/**
* @param string $url
* @param string $name
* @param string $controller
*/
public function get($url, $name, $controller)
{
$this->route('get', $url, $name, $controller);
}
/**
* @param string $url
* @param string $name
* @param string $controller
*/
public function post($url, $name, $controller)
{
$this->route('post', $url, $name, $controller);
}
/**
* @param string $url
* @param string $name
* @param string $controller
*/
public function patch($url, $name, $controller)
{
$this->route('patch', $url, $name, $controller);
}
/**
* @param string $url
* @param string $name
* @param string $controller
*/
public function delete($url, $name, $controller)
{
$this->route('delete', $url, $name, $controller);
}
/**
* @param string $method
* @param string $url
* @param string $name
* @param string $controller
*/
protected function route($method, $url, $name, $controller)
{
$this->routes->$method($url, $name, $this->route->toController($controller));
}
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Event;
/**
* @deprecated Will be removed in Beta.14. Use Flarum\Extend\Routes instead.
*/
class ConfigureApiRoutes extends AbstractConfigureRoutes
{
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Event;
use Flarum\Forum\Controller\FrontendController;
/**
* @deprecated Will be removed in Beta.14. Use Flarum\Extend\Routes or Flarum\Extend\Frontend instead.
*/
class ConfigureForumRoutes extends AbstractConfigureRoutes
{
/**
* {@inheritdoc}
*/
public function get($url, $name, $handler = FrontendController::class)
{
parent::get($url, $name, $handler);
}
}

View File

@@ -0,0 +1,79 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Event;
use DirectoryIterator;
use Flarum\Locale\LocaleManager;
use Illuminate\Support\Arr;
use RuntimeException;
/**
* @deprecated Will be removed in Beta.14. Use Flarum\Extend\LanguagePack instead.
*/
class ConfigureLocales
{
/**
* @var LocaleManager
*/
public $locales;
/**
* @param LocaleManager $locales
*/
public function __construct(LocaleManager $locales)
{
$this->locales = $locales;
}
/**
* Load language pack resources from the given directory.
*
* @param string $directory
*/
public function loadLanguagePackFrom($directory)
{
$name = $title = basename($directory);
if (file_exists($manifest = $directory.'/composer.json')) {
$json = json_decode(file_get_contents($manifest), true);
if (empty($json)) {
throw new RuntimeException("Error parsing composer.json in $name: ".json_last_error_msg());
}
$locale = Arr::get($json, 'extra.flarum-locale.code');
$title = Arr::get($json, 'extra.flarum-locale.title', $title);
}
if (! isset($locale)) {
throw new RuntimeException("Language pack $name must define \"extra.flarum-locale.code\" in composer.json.");
}
$this->locales->addLocale($locale, $title);
if (! is_dir($localeDir = $directory.'/locale')) {
throw new RuntimeException("Language pack $name must have a \"locale\" subdirectory.");
}
if (file_exists($file = $localeDir.'/config.js')) {
$this->locales->addJsFile($locale, $file);
}
if (file_exists($file = $localeDir.'/config.css')) {
$this->locales->addCssFile($locale, $file);
}
foreach (new DirectoryIterator($localeDir) as $file) {
if ($file->isFile() && in_array($file->getExtension(), ['yml', 'yaml'])) {
$this->locales->addTranslations($locale, $file->getPathname());
}
}
}
}

View File

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

View File

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

View File

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

View File

@@ -1,38 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Extend;
use Flarum\Extension\Extension;
use Illuminate\Contracts\Container\Container;
class User implements ExtenderInterface
{
private $displayNameDrivers = [];
/**
* Add a mail driver.
*
* @param string $identifier Identifier for display name driver. E.g. 'username' for UserNameDriver
* @param string $driver ::class attribute of driver class, which must implement Flarum\User\DisplayName\DriverInterface
*/
public function displayNameDriver(string $identifier, $driver)
{
$this->drivers[$identifier] = $driver;
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
$container->extend('flarum.user.display_name.supported_drivers', function ($existingDrivers) {
return array_merge($existingDrivers, $this->drivers);
});
}
}

View File

@@ -15,7 +15,7 @@ use Flarum\Extension\Event\Disabling;
use Flarum\Extension\Event\Enabled;
use Flarum\Extension\Event\Enabling;
use Flarum\Extension\Event\Uninstalled;
use Flarum\Foundation\Paths;
use Flarum\Foundation\Application;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Events\Dispatcher;
@@ -29,10 +29,7 @@ class ExtensionManager
{
protected $config;
/**
* @var Paths
*/
protected $paths;
protected $app;
protected $container;
@@ -55,14 +52,14 @@ class ExtensionManager
public function __construct(
SettingsRepositoryInterface $config,
Paths $paths,
Application $app,
Container $container,
Migrator $migrator,
Dispatcher $dispatcher,
Filesystem $filesystem
) {
$this->config = $config;
$this->paths = $paths;
$this->app = $app;
$this->container = $container;
$this->migrator = $migrator;
$this->dispatcher = $dispatcher;
@@ -74,11 +71,11 @@ class ExtensionManager
*/
public function getExtensions()
{
if (is_null($this->extensions) && $this->filesystem->exists($this->paths->vendor.'/composer/installed.json')) {
if (is_null($this->extensions) && $this->filesystem->exists($this->app->vendorPath().'/composer/installed.json')) {
$extensions = new Collection();
// Load all packages installed by composer.
$installed = json_decode($this->filesystem->get($this->paths->vendor.'/composer/installed.json'), true);
$installed = json_decode($this->filesystem->get($this->app->vendorPath().'/composer/installed.json'), true);
// Composer 2.0 changes the structure of the installed.json manifest
$installed = $installed['packages'] ?? $installed;
@@ -89,8 +86,8 @@ class ExtensionManager
}
$path = isset($package['install-path'])
? $this->paths->vendor.'/composer/'.$package['install-path']
: $this->paths->vendor.'/'.Arr::get($package, 'name');
? $this->getExtensionsDir().'/composer/'.$package['install-path']
: $this->getExtensionsDir().'/'.Arr::get($package, 'name');
// Instantiates an Extension object using the package path and composer.json file.
$extension = new Extension($path, $package);
@@ -206,7 +203,7 @@ class ExtensionManager
if ($extension->hasAssets()) {
$this->filesystem->copyDirectory(
$extension->getPath().'/assets',
$this->paths->public.'/assets/extensions/'.$extension->getId()
$this->app->publicPath().'/assets/extensions/'.$extension->getId()
);
}
}
@@ -218,7 +215,7 @@ class ExtensionManager
*/
protected function unpublishAssets(Extension $extension)
{
$this->filesystem->deleteDirectory($this->paths->public.'/assets/extensions/'.$extension->getId());
$this->filesystem->deleteDirectory($this->app->publicPath().'/assets/extensions/'.$extension->getId());
}
/**
@@ -230,7 +227,7 @@ class ExtensionManager
*/
public function getAsset(Extension $extension, $path)
{
return $this->paths->public.'/assets/extensions/'.$extension->getId().$path;
return $this->app->publicPath().'/assets/extensions/'.$extension->getId().$path;
}
/**
@@ -335,4 +332,14 @@ class ExtensionManager
return isset($enabled[$extension]);
}
/**
* The extensions path.
*
* @return string
*/
protected function getExtensionsDir()
{
return $this->app->vendorPath();
}
}

View File

@@ -11,6 +11,7 @@ namespace Flarum\Extension;
use Flarum\Extension\Event\Disabling;
use Flarum\Foundation\AbstractServiceProvider;
use Illuminate\Contracts\Container\Container;
class ExtensionServiceProvider extends AbstractServiceProvider
{
@@ -26,8 +27,8 @@ class ExtensionServiceProvider extends AbstractServiceProvider
// listener on the app rather than in the service provider's boot method
// below, so that extensions have a chance to register things on the
// container before the core boots up (and starts resolving services).
$this->app['flarum']->booting(function () {
$this->app->make('flarum.extensions')->extend($this->app);
$this->app->booting(function (Container $app) {
$app->make('flarum.extensions')->extend($app);
});
}

View File

@@ -10,7 +10,6 @@
namespace Flarum\Formatter;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\Paths;
use Illuminate\Cache\Repository;
use Illuminate\Contracts\Container\Container;
@@ -25,7 +24,7 @@ class FormatterServiceProvider extends AbstractServiceProvider
return new Formatter(
new Repository($container->make('cache.filestore')),
$container->make('events'),
$this->app[Paths::class]->storage.'/formatter'
$this->app->storagePath().'/formatter'
);
});

View File

@@ -9,10 +9,12 @@
namespace Flarum\Forum;
use Flarum\Event\ConfigureForumRoutes;
use Flarum\Extension\Event\Disabled;
use Flarum\Extension\Event\Enabled;
use Flarum\Formatter\Formatter;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\Application;
use Flarum\Foundation\ErrorHandling\Registry;
use Flarum\Foundation\ErrorHandling\Reporter;
use Flarum\Foundation\ErrorHandling\ViewFormatter;
@@ -58,7 +60,6 @@ class ForumServiceProvider extends AbstractServiceProvider
$this->app->singleton('flarum.forum.middleware', function () {
return [
'flarum.forum.error_handler',
HttpMiddleware\ParseJsonBody::class,
HttpMiddleware\CollectGarbage::class,
HttpMiddleware\StartSession::class,
@@ -70,17 +71,16 @@ class ForumServiceProvider extends AbstractServiceProvider
];
});
$this->app->bind('flarum.forum.error_handler', function () {
return new HttpMiddleware\HandleErrors(
$this->app->make(Registry::class),
$this->app['flarum']->inDebugMode() ? $this->app->make(WhoopsFormatter::class) : $this->app->make(ViewFormatter::class),
$this->app->tagged(Reporter::class)
);
});
$this->app->singleton('flarum.forum.handler', function () {
$this->app->singleton('flarum.forum.handler', function (Application $app) {
$pipe = new MiddlewarePipe;
// All requests should first be piped through our global error handler
$pipe->pipe(new HttpMiddleware\HandleErrors(
$app->make(Registry::class),
$app->inDebugMode() ? $app->make(WhoopsFormatter::class) : $app->make(ViewFormatter::class),
$app->tagged(Reporter::class)
));
foreach ($this->app->make('flarum.forum.middleware') as $middleware) {
$pipe->pipe($this->app->make($middleware));
}
@@ -186,6 +186,10 @@ class ForumServiceProvider extends AbstractServiceProvider
$callback = include __DIR__.'/routes.php';
$callback($routes, $factory);
$this->app->make('events')->dispatch(
new ConfigureForumRoutes($routes, $factory)
);
}
/**

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,44 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Foundation;
use InvalidArgumentException;
/**
* @property-read string base
* @property-read string public
* @property-read string storage
* @property-read string vendor
*/
class Paths
{
private $paths;
public function __construct(array $paths)
{
if (! isset($paths['base'], $paths['public'], $paths['storage'])) {
throw new InvalidArgumentException(
'Paths array requires keys base, public and storage'
);
}
$this->paths = array_map(function ($path) {
return rtrim($path, '\/');
}, $paths);
// Assume a standard Composer directory structure unless specified
$this->paths['vendor'] = $this->vendor ?? $this->base.'/vendor';
}
public function __get($name): ?string
{
return $this->paths[$name] ?? null;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

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