mirror of
https://github.com/flarum/core.git
synced 2025-08-14 20:34:10 +02:00
Compare commits
22 Commits
as/search_
...
dk/1236-us
Author | SHA1 | Date | |
---|---|---|---|
|
d95f22424d | ||
|
92791a253d | ||
|
138c784a50 | ||
|
bb567e5278 | ||
|
cf4f2f283e | ||
|
ed01f389a8 | ||
|
71e313e677 | ||
|
88366fe8af | ||
|
b82504b4b1 | ||
|
898d68d9f3 | ||
|
69f0172b92 | ||
|
62fe9db732 | ||
|
ed566cd18f | ||
|
5c1663d8f1 | ||
|
c5d3b058ba | ||
|
4a804dbbbc | ||
|
18fb20cdb7 | ||
|
ae55cd3d20 | ||
|
23736fcfda | ||
|
3046461c77 | ||
|
46e049ecb0 | ||
|
49d8559599 |
4
js/dist/admin.js
vendored
4
js/dist/admin.js
vendored
File diff suppressed because one or more lines are too long
2
js/dist/admin.js.map
vendored
2
js/dist/admin.js.map
vendored
File diff suppressed because one or more lines are too long
8
js/dist/forum.js
vendored
8
js/dist/forum.js
vendored
File diff suppressed because one or more lines are too long
2
js/dist/forum.js.map
vendored
2
js/dist/forum.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
|
@@ -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';
|
||||
|
@@ -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';
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import Page from './Page';
|
||||
import Page from '../../common/components/Page';
|
||||
import StatusWidget from './StatusWidget';
|
||||
|
||||
export default class DashboardPage extends Page {
|
||||
|
@@ -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() {
|
||||
|
@@ -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';
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@@ -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';
|
||||
|
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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,
|
||||
|
@@ -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();
|
||||
|
@@ -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();
|
@@ -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;
|
||||
|
33
js/src/common/states/PageState.js
Normal file
33
js/src/common/states/PageState.js
Normal 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;
|
||||
}
|
||||
}
|
6
js/src/common/utils/subclassOf.js
Normal file
6
js/src/common/utils/subclassOf.js
Normal 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);
|
||||
}
|
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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,
|
||||
|
@@ -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
|
||||
|
@@ -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));
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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 = [];
|
||||
|
@@ -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.
|
||||
*
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import Page from './Page';
|
||||
import Page from '../../common/components/Page';
|
||||
import NotificationList from './NotificationList';
|
||||
|
||||
/**
|
||||
|
@@ -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');
|
||||
}
|
||||
|
||||
|
@@ -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');
|
||||
}
|
||||
}
|
||||
|
@@ -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();
|
||||
|
@@ -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
|
||||
|
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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.
|
||||
|
@@ -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();
|
||||
|
190
js/src/forum/state/DiscussionListState.js
Normal file
190
js/src/forum/state/DiscussionListState.js
Normal 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();
|
||||
}
|
||||
}
|
95
js/src/forum/states/GlobalSearchState.js
Normal file
95
js/src/forum/states/GlobalSearchState.js
Normal 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));
|
||||
}
|
||||
}
|
35
js/src/forum/states/SearchState.js
Normal file
35
js/src/forum/states/SearchState.js
Normal 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;
|
||||
}
|
||||
}
|
@@ -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));
|
||||
}
|
||||
},
|
||||
|
||||
|
@@ -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();
|
||||
|
@@ -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();
|
||||
|
@@ -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');
|
||||
}
|
||||
];
|
@@ -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');
|
||||
});
|
||||
}
|
||||
];
|
@@ -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();
|
||||
}
|
||||
];
|
@@ -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();
|
||||
});
|
||||
}
|
||||
];
|
33
src/Extend/Notification.php
Normal file
33
src/Extend/Notification.php
Normal 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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
@@ -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'
|
||||
);
|
||||
});
|
||||
|
||||
|
@@ -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()
|
||||
{
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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']);
|
||||
|
@@ -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()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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()
|
||||
|
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -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';
|
||||
}
|
||||
}
|
||||
|
@@ -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.
|
||||
|
36
src/User/Concerns/DeprecatedUserNotificationPreferences.php
Normal file
36
src/User/Concerns/DeprecatedUserNotificationPreferences.php
Normal 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)
|
||||
{
|
||||
}
|
||||
}
|
87
src/User/Concerns/UserPreferences.php
Normal file
87
src/User/Concerns/UserPreferences.php
Normal 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');
|
||||
}
|
||||
}
|
75
src/User/NotificationPreference.php
Normal file
75
src/User/NotificationPreference.php
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
@@ -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.
|
||||
*
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
0
tests/fixtures/writable_paths/writable/.keep
vendored
Normal file
0
tests/fixtures/writable_paths/writable/.keep
vendored
Normal file
57
tests/integration/extenders/NotificationChannelTest.php
Normal file
57
tests/integration/extenders/NotificationChannelTest.php
Normal 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')
|
||||
);
|
||||
}
|
||||
}
|
67
tests/integration/extenders/UserPreferencesTest.php
Normal file
67
tests/integration/extenders/UserPreferencesTest.php
Normal 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);
|
||||
}
|
||||
}
|
@@ -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
|
||||
|
71
tests/unit/Install/Prerequisite/WritablePathsTest.php
Normal file
71
tests/unit/Install/Prerequisite/WritablePathsTest.php
Normal 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']
|
||||
);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user