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

Compare commits

..

22 Commits

Author SHA1 Message Date
Daniël Klabbers
d95f22424d Merge branch 'master' into dk/1236-user-preferences 2020-06-26 09:50:10 +02:00
flarum-bot
92791a253d Bundled output for commit 138c784a50 [skip ci] 2020-06-24 00:51:55 +00:00
David Sevilla Martín
138c784a50 Call liveHumanTimes() to update ago times every 10s (#2208)
This file has existed for 5 years, yet it was never used.
2020-06-23 20:50:57 -04:00
flarum-bot
bb567e5278 Bundled output for commit cf4f2f283e [skip ci] 2020-06-20 14:19:53 +00:00
w-4
cf4f2f283e Fix discussion unreadCount could be higher than commentCount (#2195)
* Fix discussion unreadCount being higher than commentCount if posts have been deleted
2020-06-20 10:18:26 -04:00
flarum-bot
ed01f389a8 Bundled output for commit 71e313e677 [skip ci] 2020-06-19 21:42:28 +00:00
Alexander Skvortsov
71e313e677 Clean up app.current, app.previous in JS (#2156)
- Encapsulate app.current, app.previous in PageState objects
- Reorganize Page classes to use one central base class in common

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

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

* Drop useless app_path() helper

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

* Deprecate global path helpers

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

* Stop storing paths as strings in container

* Avoid using path helpers from Application class

* Deprecate path helpers from Application class

* Avoid using public_path() in prerequisite check

a) The comparison was already outdated, as a different path was passed.
b) We're trying to get rid of these global helpers.
2020-06-19 16:16:03 -04:00
flarum-bot
b82504b4b1 Bundled output for commit 898d68d9f3 [skip ci] 2020-06-19 00:30:16 +00:00
Franz Liedke
898d68d9f3 Remove leftover property
Refs #2150.
2020-06-19 02:27:01 +02:00
flarum-bot
69f0172b92 Bundled output for commit 62fe9db732 [skip ci] 2020-06-19 00:11:51 +00:00
Alexander Skvortsov
62fe9db732 Don't store PostUser instance in CommentPost (#2184)
* Don't save component state in CommentPost
2020-06-18 20:10:25 -04:00
flarum-bot
ed566cd18f Bundled output for commit 5c1663d8f1 [skip ci] 2020-06-18 23:54:42 +00:00
Alexander Skvortsov
5c1663d8f1 Move Discussion List State into its own class (#2150)
Extract discussion list state
2020-06-18 19:53:40 -04:00
flarum-bot
c5d3b058ba Bundled output for commit 4a804dbbbc [skip ci] 2020-06-18 22:48:18 +00:00
Alexander Skvortsov
4a804dbbbc Remove app.search instance, cache app.cache.searched (#2151)
* Moved search state logic into search state
2020-06-18 18:47:01 -04:00
luceos
18fb20cdb7 Apply fixes from StyleCI
[ci skip] [skip ci]
2020-04-01 15:07:09 +00:00
Daniël Klabbers
ae55cd3d20 refactoring of the extenders 2020-04-01 17:06:30 +02:00
luceos
23736fcfda Apply fixes from StyleCI
[ci skip] [skip ci]
2020-03-24 10:02:08 +00:00
Daniël Klabbers
3046461c77 small clarification on the UserPreferences extender 2020-03-24 11:01:22 +01:00
Daniël Klabbers
46e049ecb0 fixes #1236
- split up deprecated and remaining user notification logic
- started building a test (needs work)
- created new Model for NotificationPreference
- created extender to register a NotificationChannel
- created extender to maintain UserPreferences

User preferences are still possible on the users table directly.
Registering a user preference allows for transformation to happen.
And provides easier accessors. Not sure we want this.

! tests need work.
2020-03-24 10:58:28 +01:00
Daniël Klabbers
49d8559599 Moved deprecated user notification preferences logic into a dedicated trait,
did the same for user preferences to one that we can retain; those for user columns
re-added migrations, fixed most of the fallback methods
2020-03-09 22:46:38 +01:00
66 changed files with 1156 additions and 603 deletions

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

View File

@@ -6,7 +6,6 @@ import EditCustomFooterModal from './components/EditCustomFooterModal';
import SessionDropdown from './components/SessionDropdown';
import HeaderPrimary from './components/HeaderPrimary';
import AppearancePage from './components/AppearancePage';
import Page from './components/Page';
import StatusWidget from './components/StatusWidget';
import HeaderSecondary from './components/HeaderSecondary';
import SettingsModal from './components/SettingsModal';
@@ -36,7 +35,6 @@ export default Object.assign(compat, {
'components/SessionDropdown': SessionDropdown,
'components/HeaderPrimary': HeaderPrimary,
'components/AppearancePage': AppearancePage,
'components/Page': Page,
'components/StatusWidget': StatusWidget,
'components/HeaderSecondary': HeaderSecondary,
'components/SettingsModal': SettingsModal,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import Drawer from './utils/Drawer';
import mapRoutes from './utils/mapRoutes';
import RequestError from './utils/RequestError';
import ScrollListener from './utils/ScrollListener';
import liveHumanTimes from './utils/liveHumanTimes';
import { extend } from './extend';
import Forum from './models/Forum';
@@ -21,6 +22,7 @@ import Post from './models/Post';
import Group from './models/Group';
import Notification from './models/Notification';
import { flattenDeep } from 'lodash-es';
import PageState from './states/PageState';
/**
* The `App` class provides a container for an application, as well as various
@@ -115,6 +117,28 @@ export default class Application {
*/
requestError = null;
/**
* The page the app is currently on.
*
* This object holds information about the type of page we are currently
* visiting, and sometimes additional arbitrary page state that may be
* relevant to lower-level components.
*
* @type {PageState}
*/
current = new PageState(null);
/**
* The page the app was on before the current page.
*
* Once the application navigates to another page, the object previously
* assigned to this.current will be moved to this.previous, while this.current
* is re-initialized.
*
* @type {PageState}
*/
previous = new PageState(null);
data;
title = '';
@@ -169,6 +193,8 @@ export default class Application {
$(() => {
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
});
liveHumanTimes();
}
/**

View File

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

View File

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

View File

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

View File

@@ -68,7 +68,10 @@ Object.assign(Discussion.prototype, {
const user = app.session.user;
if (user && user.markedAllAsReadAt() < this.lastPostedAt()) {
return Math.max(0, this.lastPostNumber() - (this.lastReadPostNumber() || 0));
const unreadCount = Math.max(0, this.lastPostNumber() - (this.lastReadPostNumber() || 0));
// If posts have been deleted, it's possible that the unread count could exceed the
// actual post count. As such, we take the min of the two to ensure this isn't an issue.
return Math.min(unreadCount, this.commentCount());
}
return 0;

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import History from './utils/History';
import Pane from './utils/Pane';
import Search from './components/Search';
import ReplyComposer from './components/ReplyComposer';
import DiscussionPage from './components/DiscussionPage';
import SignUpModal from './components/SignUpModal';
@@ -15,6 +14,8 @@ 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 {
/**
@@ -35,13 +36,6 @@ export default class ForumApplication extends Application {
discussionRenamed: DiscussionRenamedPost,
};
/**
* The page's search component instance.
*
* @type {Search}
*/
search = new Search();
/**
* An object which controls the state of the page's side pane.
*
@@ -71,10 +65,31 @@ export default class ForumApplication extends Application {
*/
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;
}
/**
@@ -145,7 +160,7 @@ export default class ForumApplication extends Application {
* @return {Boolean}
*/
viewingDiscussion(discussion) {
return this.current instanceof DiscussionPage && this.current.discussion === discussion;
return this.current.matches(DiscussionPage, { discussion });
}
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import Page from './Page';
import Page from '../../common/components/Page';
import NotificationList from './NotificationList';
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => function (Builder $schema) {
$schema->create('notification_preferences', function (Blueprint $table) {
$table->integer('user_id')->unsigned();
$table->string('type');
$table->string('channel');
$table->boolean('enabled')->default(false);
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
},
'down' => function (Builder $schema) {
$schema->drop('notification_preferences');
}
];

View File

@@ -0,0 +1,27 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => function (Builder $schema) {
$schema->table('users', function (Blueprint $table) {
$table->boolean('disclose_online')->default(false);
$table->string('locale')->nullable();
});
},
'down' => function (Builder $schema) {
$schema->table('users', function (Blueprint $table) {
$table->dropColumn('disclose_online');
$table->dropColumn('locale');
});
}
];

View File

@@ -0,0 +1,52 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Illuminate\Database\Schema\Builder;
use Illuminate\Support\Str;
return [
'up' => function (Builder $builder) {
$db = $builder->getConnection();
$db->table('users')
->select(['id', 'preferences'])
->whereNotNull('preferences')
->orderBy('id')
->each(function ($user) use ($db) {
collect(json_decode($user->preferences ?? '{}'))
->each(function ($value, $key) use ($user, $db) {
if (in_array($key, ['discloseOnline', 'followAfterReply'])) {
$db->table('users')
->where('id', $user->id)
->update([Str::snake($key) => (bool) $value]);
}
if ($key === 'locale') {
$db->table('users')
->where('id', $user->id)
->update(['locale' => $value]);
}
if (preg_match('/^notify_(?<type>[^_]+)_(?<channel>.*)$/', $key, $matches)) {
$db->table('notification_preferences')
->insert([
'user_id' => $user->id,
'type' => $matches['type'],
'channel' => $matches['channel'],
'enabled' => (bool) $value
]);
}
});
});
},
'down' => function (Builder $builder) {
$db = $builder->getConnection();
$db->table('notification_preferences')->truncate();
}
];

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => function (Builder $schema) {
$schema->table('users', function (Blueprint $table) {
$table->dropColumn('preferences');
});
},
'down' => function (Builder $schema) {
$schema->table('users', function (Blueprint $table) {
$table->binary('preferences')->nullable();
});
}
];

View File

@@ -0,0 +1,33 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Extend;
use Flarum\Extension\Extension;
use Flarum\User\NotificationPreference;
use Illuminate\Contracts\Container\Container;
class Notification implements ExtenderInterface
{
private $channels = [];
public function extend(Container $container, Extension $extension = null)
{
foreach ($this->channels as $channel => $enabled) {
NotificationPreference::addChannel($channel, $enabled ?? []);
}
}
public function addChannel(string $channel, array $enabledTypes = null)
{
$this->channels[$channel] = $enabledTypes;
return $this;
}
}

View File

@@ -10,6 +10,7 @@
namespace Flarum\Extend;
use Flarum\Extension\Extension;
use Flarum\User\User as Eloquent;
use Illuminate\Contracts\Container\Container;
class User implements ExtenderInterface
@@ -35,4 +36,11 @@ class User implements ExtenderInterface
return array_merge($existingDrivers, $this->drivers);
});
}
public function addPreference(string $key, callable $transformer = null, $default = null)
{
Eloquent::addPreference($key, $transformer, $default);
return $this;
}
}

View File

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

View File

@@ -86,8 +86,6 @@ class Application
$this->registerBaseBindings();
$this->registerBaseServiceProviders();
$this->registerCoreContainerAliases();
$this->bindPathsInContainer();
}
/**
@@ -161,22 +159,11 @@ class Application
$this->register(new EventServiceProvider($this->container));
}
/**
* Bind all of the application paths in the container.
*
* @return void
*/
protected function bindPathsInContainer()
{
foreach (['base', 'public', 'storage', 'vendor'] as $path) {
$this->container->instance('path.'.$path, $this->paths->$path);
}
}
/**
* Get the base path of the Laravel installation.
*
* @return string
* @deprecated Will be removed in Beta.15.
*/
public function basePath()
{
@@ -187,6 +174,7 @@ class Application
* Get the path to the public / web directory.
*
* @return string
* @deprecated Will be removed in Beta.15.
*/
public function publicPath()
{
@@ -197,6 +185,7 @@ class Application
* Get the path to the storage directory.
*
* @return string
* @deprecated Will be removed in Beta.15.
*/
public function storagePath()
{
@@ -207,6 +196,7 @@ class Application
* Get the path to the vendor directory where dependencies are installed.
*
* @return string
* @deprecated Will be removed in Beta.15.
*/
public function vendorPath()
{

View File

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

View File

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

View File

@@ -23,15 +23,6 @@ class InstallServiceProvider extends AbstractServiceProvider
$this->app->singleton('flarum.install.routes', function () {
return new RouteCollection;
});
$this->app->singleton(Installation::class, function () {
return new Installation(
$this->app->basePath(),
$this->app->publicPath(),
$this->app->storagePath(),
$this->app->vendorPath()
);
});
}
/**

View File

@@ -9,12 +9,14 @@
namespace Flarum\Install;
use Flarum\Foundation\Paths;
class Installation
{
private $basePath;
private $publicPath;
private $storagePath;
private $vendorPath;
/**
* @var Paths
*/
private $paths;
private $configPath;
private $debug = false;
@@ -34,12 +36,9 @@ class Installation
/** @var \Illuminate\Database\ConnectionInterface */
private $db;
public function __construct($basePath, $publicPath, $storagePath, $vendorPath)
public function __construct(Paths $paths)
{
$this->basePath = $basePath;
$this->publicPath = $publicPath;
$this->storagePath = $storagePath;
$this->vendorPath = $vendorPath;
$this->paths = $paths;
}
public function configPath($path)
@@ -98,9 +97,9 @@ class Installation
'tokenizer',
]),
new Prerequisite\WritablePaths([
$this->basePath,
$this->getAssetPath(),
$this->storagePath,
$this->paths->base,
$this->getAssetPath().'/*',
$this->paths->storage,
])
);
}
@@ -140,11 +139,11 @@ class Installation
});
$pipeline->pipe(function () {
return new Steps\PublishAssets($this->vendorPath, $this->getAssetPath());
return new Steps\PublishAssets($this->paths->vendor, $this->getAssetPath());
});
$pipeline->pipe(function () {
return new Steps\EnableBundledExtensions($this->db, $this->vendorPath, $this->getAssetPath());
return new Steps\EnableBundledExtensions($this->db, $this->paths->vendor, $this->getAssetPath());
});
return $pipeline;
@@ -152,12 +151,12 @@ class Installation
private function getConfigPath()
{
return $this->basePath.'/'.($this->configPath ?? 'config.php');
return $this->paths->base.'/'.($this->configPath ?? 'config.php');
}
private function getAssetPath()
{
return "$this->publicPath/assets";
return $this->paths->public.'/assets';
}
private function getMigrationPath()

View File

@@ -10,14 +10,20 @@
namespace Flarum\Install\Prerequisite;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class WritablePaths implements PrerequisiteInterface
{
protected $paths;
/**
* @var Collection
*/
private $paths;
private $wildcards = [];
public function __construct(array $paths)
{
$this->paths = $paths;
$this->paths = $this->normalize($paths);
}
public function problems(): Collection
@@ -28,7 +34,7 @@ class WritablePaths implements PrerequisiteInterface
private function getMissingPaths(): Collection
{
return (new Collection($this->paths))
return $this->paths
->reject(function ($path) {
return file_exists($path);
})->map(function ($path) {
@@ -41,13 +47,13 @@ class WritablePaths implements PrerequisiteInterface
private function getNonWritablePaths(): Collection
{
return (new Collection($this->paths))
return $this->paths
->filter(function ($path) {
return file_exists($path) && ! is_writable($path);
})->map(function ($path) {
})->map(function ($path, $index) {
return [
'message' => 'The '.$this->getAbsolutePath($path).' directory is not writable.',
'detail' => 'Please chmod this directory'.($path !== public_path() ? ' and its contents' : '').' to 0775.'
'detail' => 'Please chmod this directory'.(in_array($index, $this->wildcards) ? ' and its contents' : '').' to 0775.'
];
});
}
@@ -71,4 +77,17 @@ class WritablePaths implements PrerequisiteInterface
return (substr($path, 0, 1) == '/' ? '/' : '').implode(DIRECTORY_SEPARATOR, $absolutes);
}
private function normalize(array $paths): Collection
{
return (new Collection($paths))
->map(function ($path, $index) {
if (Str::endsWith($path, '/*')) {
$this->wildcards[] = $index;
$path = substr($path, 0, -2);
}
return $path;
});
}
}

View File

@@ -11,6 +11,7 @@ namespace Flarum\Locale;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\Event\ClearingCache;
use Flarum\Foundation\Paths;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\Translation\Translator as TranslatorContract;
@@ -73,6 +74,6 @@ class LocaleServiceProvider extends AbstractServiceProvider
private function getCacheDir(): string
{
return $this->app['flarum']->storagePath().'/locale';
return $this->app[Paths::class]->storage.'/locale';
}
}

View File

@@ -13,6 +13,7 @@ use Flarum\Console\Event\Configuring;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\ErrorHandling\Registry;
use Flarum\Foundation\ErrorHandling\Reporter;
use Flarum\Foundation\Paths;
use Illuminate\Contracts\Debug\ExceptionHandler as ExceptionHandling;
use Illuminate\Contracts\Queue\Factory;
use Illuminate\Contracts\Queue\Queue;
@@ -70,7 +71,7 @@ class QueueServiceProvider extends AbstractServiceProvider
// Override the Laravel native Listener, so that we can ignore the environment
// option and force the binary to flarum.
$this->app->singleton(QueueListener::class, function ($app) {
return new Listener($app->basePath());
return new Listener($app[Paths::class]->base);
});
// Bind a simple cache manager that returns the cache store.

View File

@@ -0,0 +1,36 @@
<?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\User\Concerns;
trait DeprecatedUserNotificationPreferences
{
/**
* Encode an array of preferences for storage in the database.
*
* @param mixed $value
* @deprecated 0.1.0-beta.13: `users.preferences` is no longer used.
*/
public function setPreferencesAttribute($value)
{
}
/**
* Get the key for a preference which flags whether or not the user will
* receive a notification for $type via $method.
*
* @param string $type
* @param string $method
* @return string
* @deprecated 0.1.0-beta.13: `users.preferences` is no longer used, use \Flarum\User\NotificationPreference.
*/
public static function getNotificationPreferenceKey($type, $method)
{
}
}

View File

@@ -0,0 +1,87 @@
<?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\User\Concerns;
use Illuminate\Support\Arr;
trait UserPreferences
{
/**
* An array of registered user preferences. Each preference is defined with
* a key, and its value is an array containing the following keys:.
*
* - transformer: a callback that confines the value of the preference
* - default: a default value if the preference isn't set
*
* @var array
*/
protected static $preferences = [];
/**
* Get the values of all registered preferences for this user, by
* transforming their stored preferences and merging them with the defaults.
*
* @param string $value
* @return array
*/
public function getPreferencesAttribute($value)
{
$defaults = array_map(function ($value) {
return $value['default'];
}, static::$preferences);
$user = Arr::only($this->notificationPreferences->toArray(), array_keys(static::$preferences));
return array_merge($defaults, $user);
}
/**
* Get the value of a preference for this user.
*
* @param string $key
* @param mixed $default
* @return mixed
*/
public function getPreference($key, $default = null)
{
return $this->$key ?? $default;
}
/**
* Set the value of a preference for this user.
*
* @param string $key
* @param mixed $value
*/
public function setPreference($key, $value)
{
$preference = static::$preferences[$key];
// If a user preference is registered, transform the value.
if ($preference) {
$value = $value === null ? $preference['default'] : $value;
$value = $preference['transformer']($value);
}
$this->{$key} = $value;
}
/**
* Register a preference with a transformer and a default value.
*
* @param string $key
* @param callable $transformer
* @param mixed $default
*/
public static function addPreference($key, callable $transformer = null, $default = null)
{
static::$preferences[$key] = compact('transformer', 'default');
}
}

View File

@@ -0,0 +1,75 @@
<?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\User;
use Flarum\Database\AbstractModel;
use Illuminate\Database\Eloquent\Builder;
use InvalidArgumentException;
/**
* @property int $user_id
* @property string $type
* @property string $channel
* @property bool $enabled
*/
class NotificationPreference extends AbstractModel
{
protected static $channels = [];
public function user()
{
return $this->belongsTo(User::class);
}
public static function addChannel(string $channel, array $defaults = [])
{
static::$channels[$channel] = $defaults;
}
public static function getNotificationPreferences(User $user, string $channel = null, string $type = null)
{
$saved = $user->notificationPreferences()
->when('type', function ($query, $type) {
$query->where('type', $type);
})
->whereIn('channel', $channel ? [$channel] : array_keys(static::$channels))
->get();
if ($channel && $type) {
return $saved->first();
}
return $saved;
}
public static function setNotificationPreference(User $user, string $channel, string $type, bool $enabled = true)
{
if (array_key_exists($channel, static::$channels)) {
$attributes = [
'channel' => $channel,
'type' => $type
];
$user->notificationPreferences()->updateOrInsert($attributes, ['enabled' => $enabled]);
} else {
throw new InvalidArgumentException("Channel '$channel' is not registered.");
}
}
public function scopeShouldBeNotified(Builder $query, string $type, string $channel = null)
{
return $query
->where('enabled', true)
->where('type', $type)
->when($channel, function ($query, $channel) {
$query->where('channel', $channel);
});
}
}

View File

@@ -36,7 +36,6 @@ use Flarum\User\Event\Registered;
use Flarum\User\Event\Renamed;
use Illuminate\Contracts\Hashing\Hasher;
use Illuminate\Contracts\Session\Session;
use Illuminate\Support\Arr;
/**
* @property int $id
@@ -58,6 +57,8 @@ class User extends AbstractModel
{
use EventGeneratorTrait;
use ScopeVisibilityTrait;
use Concerns\DeprecatedUserNotificationPreferences;
use Concerns\UserPreferences;
/**
* The attributes that should be mutated to dates.
@@ -457,34 +458,6 @@ class User extends AbstractModel
})->count();
}
/**
* Get the values of all registered preferences for this user, by
* transforming their stored preferences and merging them with the defaults.
*
* @param string $value
* @return array
*/
public function getPreferencesAttribute($value)
{
$defaults = array_map(function ($value) {
return $value['default'];
}, static::$preferences);
$user = Arr::only((array) json_decode($value, true), array_keys(static::$preferences));
return array_merge($defaults, $user);
}
/**
* Encode an array of preferences for storage in the database.
*
* @param mixed $value
*/
public function setPreferencesAttribute($value)
{
$this->attributes['preferences'] = json_encode($value);
}
/**
* Check whether or not the user should receive an alert for a notification
* type.
@@ -509,42 +482,6 @@ class User extends AbstractModel
return (bool) $this->getPreference(static::getNotificationPreferenceKey($type, 'email'));
}
/**
* Get the value of a preference for this user.
*
* @param string $key
* @param mixed $default
* @return mixed
*/
public function getPreference($key, $default = null)
{
return Arr::get($this->preferences, $key, $default);
}
/**
* Set the value of a preference for this user.
*
* @param string $key
* @param mixed $value
* @return $this
*/
public function setPreference($key, $value)
{
if (isset(static::$preferences[$key])) {
$preferences = $this->preferences;
if (! is_null($transformer = static::$preferences[$key]['transformer'])) {
$preferences[$key] = call_user_func($transformer, $value);
} else {
$preferences[$key] = $value;
}
$this->preferences = $preferences;
}
return $this;
}
/**
* Set the user as being last seen just now.
*
@@ -622,6 +559,11 @@ class User extends AbstractModel
return $this->belongsToMany(Group::class)->where('is_hidden', false);
}
public function notificationPreferences()
{
return $this->hasMany(NotificationPreference::class);
}
/**
* Define the relationship with the user's notifications.
*
@@ -739,31 +681,6 @@ class User extends AbstractModel
static::$hasher = $hasher;
}
/**
* Register a preference with a transformer and a default value.
*
* @param string $key
* @param callable $transformer
* @param mixed $default
*/
public static function addPreference($key, callable $transformer = null, $default = null)
{
static::$preferences[$key] = compact('transformer', 'default');
}
/**
* Get the key for a preference which flags whether or not the user will
* receive a notification for $type via $method.
*
* @param string $type
* @param string $method
* @return string
*/
public static function getNotificationPreferenceKey($type, $method)
{
return 'notify_'.$type.'_'.$method;
}
/**
* Refresh the user's comments count.
*

View File

@@ -7,6 +7,7 @@
* LICENSE file that was distributed with this source code.
*/
use Flarum\Foundation\Paths;
use Illuminate\Container\Container;
if (! function_exists('app')) {
@@ -27,29 +28,17 @@ if (! function_exists('app')) {
}
}
if (! function_exists('app_path')) {
/**
* Get the path to the application folder.
*
* @param string $path
* @return string
*/
function app_path($path = '')
{
return app('path').($path ? DIRECTORY_SEPARATOR.$path : $path);
}
}
if (! function_exists('base_path')) {
/**
* Get the path to the base of the install.
*
* @param string $path
* @return string
* @deprecated Will be removed in Beta.15.
*/
function base_path($path = '')
{
return app()->basePath().($path ? DIRECTORY_SEPARATOR.$path : $path);
return app(Paths::class)->base.($path ? DIRECTORY_SEPARATOR.$path : $path);
}
}
@@ -59,10 +48,11 @@ if (! function_exists('public_path')) {
*
* @param string $path
* @return string
* @deprecated Will be removed in Beta.15.
*/
function public_path($path = '')
{
return app()->publicPath().($path ? DIRECTORY_SEPARATOR.$path : $path);
return app(Paths::class)->public.($path ? DIRECTORY_SEPARATOR.$path : $path);
}
}
@@ -72,10 +62,11 @@ if (! function_exists('storage_path')) {
*
* @param string $path
* @return string
* @deprecated Will be removed in Beta.15.
*/
function storage_path($path = '')
{
return app('path.storage').($path ? DIRECTORY_SEPARATOR.$path : $path);
return app(Paths::class)->storage.($path ? DIRECTORY_SEPARATOR.$path : $path);
}
}

View File

View File

@@ -0,0 +1,57 @@
<?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\Tests\integration\extenders;
use Flarum\Extend\Notification;
use Flarum\Tests\integration\RetrievesAuthorizedUsers;
use Flarum\Tests\integration\TestCase;
use Flarum\User\NotificationPreference;
use Flarum\User\User;
class NotificationChannelTest extends TestCase
{
use RetrievesAuthorizedUsers;
public function setUp()
{
parent::setUp();
$this->prepareDatabase([
'users' => [
$this->normalUser()
]
]);
}
private function add_channel()
{
$this->extend((new Notification)->addChannel('test'));
}
/**
* @test
*/
public function can_enable_notification_channel()
{
$this->add_channel();
/** @var User $user */
$user = User::find(2);
NotificationPreference::setNotificationPreference($user, 'test', 'newPost');
$this->assertTrue(
$user->notificationPreferences()
->where('channel', 'test')
->where('type', 'newPost')
->get('enabled')
);
}
}

View File

@@ -0,0 +1,67 @@
<?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\Tests\integration\extenders;
use Flarum\Extend\User as Extender;
use Flarum\Tests\integration\RetrievesAuthorizedUsers;
use Flarum\Tests\integration\TestCase;
use Flarum\User\User;
use Illuminate\Support\Arr;
class UserPreferencesTest extends TestCase
{
use RetrievesAuthorizedUsers;
public function setUp()
{
parent::setUp();
$this->prepareDatabase([
'users' => [
$this->normalUser()
]
]);
}
private function add_preference()
{
$this->extend(
(new Extender())
->addPreference('test', 'boolval', false)
);
}
/**
* @test
*/
public function can_add_user_preference()
{
$this->add_preference();
/** @var User $user */
$user = User::find(2);
$this->assertEquals(false, Arr::get($user->preferences, 'test'));
}
/**
* @test
*/
public function can_store_user_preference()
{
$this->add_preference();
/** @var User $user */
$user = User::find(2);
$user->setPreference('test', true);
$this->assertEquals(true, $user->test);
}
}

View File

@@ -7,6 +7,7 @@
* LICENSE file that was distributed with this source code.
*/
use Flarum\Foundation\Paths;
use Flarum\Install\AdminUser;
use Flarum\Install\BaseUrl;
use Flarum\Install\DatabaseConfig;
@@ -38,10 +39,12 @@ echo "\nOff we go...\n";
*/
$installation = new Installation(
__DIR__.'/tmp',
__DIR__.'/tmp/public',
__DIR__.'/tmp/storage',
__DIR__.'/../../vendor'
new Paths([
'base' => __DIR__.'/tmp',
'public' => __DIR__.'/tmp/public',
'storage' => __DIR__.'/tmp/storage',
'vendor' => __DIR__.'/../../vendor',
])
);
$pipeline = $installation

View File

@@ -0,0 +1,71 @@
<?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\Tests\unit\Install\Prerequisite;
use Flarum\Install\Prerequisite\WritablePaths;
use Flarum\Tests\unit\TestCase;
class WritablePathsTest extends TestCase
{
public function test_no_problems_when_all_directories_are_writable()
{
$writable = new WritablePaths([
__DIR__.'/../../../fixtures/writable_paths/writable',
]);
$this->assertCount(0, $writable->problems());
}
public function test_paths_can_be_given_with_wildcard()
{
$writable = new WritablePaths([
__DIR__.'/../../../fixtures/writable_paths/writable/*',
]);
$this->assertCount(0, $writable->problems());
}
public function test_problems_when_one_path_is_missing()
{
$writable = new WritablePaths([
__DIR__.'/../../../fixtures/writable_paths/missing',
__DIR__.'/../../../fixtures/writable_paths/writable',
]);
$problems = $writable->problems();
$this->assertCount(1, $problems);
$this->assertRegExp(
"%^The .+/tests/fixtures/writable_paths/missing directory doesn't exist$%",
$problems[0]['message']
);
$this->assertEquals(
'This directory is necessary for the installation. Please create the folder.',
$problems[0]['detail']
);
}
public function test_problem_details_filter_out_wildcard()
{
$writable = new WritablePaths([
__DIR__.'/../../../fixtures/writable_paths/missing/*',
]);
$problems = $writable->problems();
$this->assertCount(1, $problems);
$this->assertRegExp(
"%^The .+/tests/fixtures/writable_paths/missing directory doesn't exist$%",
$problems[0]['message']
);
$this->assertEquals(
'This directory is necessary for the installation. Please create the folder.',
$problems[0]['detail']
);
}
}