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

Compare commits

..

3 Commits

Author SHA1 Message Date
Alexander Skvortsov
d17cc1beee Only automatically load page for UserPage if username param is present
This avoid issues with SettingsPage
2020-10-04 22:02:16 -04:00
Alexander Skvortsov
759eb80ff9 Remove redundant code from IndexPage oninit 2020-10-04 22:00:11 -04:00
Alexander Skvortsov
46cb32046d Abstract away onNewRoute and routeHasChanged
This provides a cleaner interface for dealing with pages that handle multiple routes. We also move `modal` and `drawer` close calls to ensure those happen on route change with the same component.
2020-10-04 21:49:51 -04:00
60 changed files with 496 additions and 696 deletions

View File

@@ -1,102 +1,5 @@
# Changelog
## [0.1.0-beta.14](https://github.com/flarum/core/compare/v0.1.0-beta.13...v0.1.0-beta.14)
### Added
- Check dependencies before enabling / disabling extensions (https://github.com/flarum/core/pull/2188)
- Set up temporary infrastructure for TypeScript in core (https://github.com/flarum/core/pull/2206)
- Better UI for request error modals (https://github.com/flarum/core/pull/1929)
- Display name extender, tests, frontend UI (https://github.com/flarum/core/pull/2174)
- Scroll to post or show alert when editing a post from another page (https://github.com/flarum/core/pull/2108)
- Feature to test email config by sending an email to the current user (https://github.com/flarum/core/pull/2023)
- Allow searching users by group ID using the group gambit (https://github.com/flarum/core/pull/2192)
- Use `liveHumanTimes` helper to update times without reload/rerender (https://github.com/flarum/core/pull/2208)
- View extender, tests (https://github.com/flarum/core/pull/2134)
- User extender to replace `PrepareUserGroups` (https://github.com/flarum/core/pull/2110)
- Increase extensibility of skeleton PHP (https://github.com/flarum/core/pull/2308, https://github.com/flarum/core/pull/2318)
- Pass a translator instance to `getEmailSubject` in `MailableInterface` (https://github.com/flarum/core/pull/2244)
- Force LF line endings on windows (https://github.com/flarum/core/pull/2321)
- Add a `Link` component for internal and external links (https://github.com/flarum/core/pull/2315)
- `ConfirmDocumentUnload` component
- Error handler middleware can now be manipulated by the middleware extender
### Changed
- Update to Mithril 2 (https://github.com/flarum/core/pull/2255)
- Stop storing component instances (https://github.com/flarum/core/issues/1821, https://github.com/flarum/core/issues/2144)
- Update to Laravel 6.x (https://github.com/flarum/core/issues/2055)
- `Flarum\Foundation\Application` no longer implements `Illuminate\Contracts\Foundation\Application` (#2142)
- `Flarum\Foundation\Application` no longer inherits `Illuminate\Container\Container` (#2142)
- `paths` have been split off from `Flarum\Foundation\Application` into `Flarum\Foundation\Paths`, which can be injected where needed (#2142)
- `Flarum\User\Gate` no longer implements `Illuminate\Contracts\Auth\Access\Gate` (https://github.com/flarum/core/pull/2181)
- Improve Group Gambit performance (https://github.com/flarum/core/pull/2192)
- Switch to `dayjs` from `momentjs` (https://github.com/flarum/core/pull/2219)
- Don't create a `bio` column in `users` for new installations (https://github.com/flarum/core/pull/2215)
- Start converting core JS to TypeScript (https://github.com/flarum/core/pull/2207)
- Make Carbon an explicit dependency (https://github.com/flarum/core/commit/3b39c212e0fef7522e7d541a9214ff3817138d5d)
- Use Symfony's translator interface instead of Laravel's (https://github.com/flarum/core/pull/2243)
- Use newer versions of fontawesome (https://github.com/flarum/core/pull/2274)
- Use URL generator instead of `app()->url()` where possible (https://github.com/flarum/core/pull/2302)
- Move config from `config.php` into an injectable helper class (https://github.com/flarum/core/pull/2271)
- Use reserved TLD for bogus and test urls (https://github.com/flarum/core/commit/6860b24b70bd04544dde90e537ce021a5fc5a689)
- Replace `m.stream` with `flarum/utils/Stream` (https://github.com/flarum/core/pull/2316)
- Replace `affixedSidebar` util with `AffixedSidebar` component
- Replace `m.withAttr` with `flarum/utils/withAttr`
- Scroll Listener is now passive, performance improvement (https://github.com/flarum/core/pull/2387)
### Fixed
- `generate:migration` command for extensions (https://github.com/flarum/core/commit/443949f7b9d7558dbc1e0994cb898cbac59bec87)
- Container config for `UninstalledSite` (https://github.com/flarum/core/commit/ecdce44d555dd36a365fd472b2916e677ef173cf)
- Tooltip glitch on page chang (https://github.com/flarum/core/issues/2118)
- Using multiple extenders in tests (https://github.com/flarum/core/commit/c4f4f218bf4b175a30880b807f9ccb1a37a25330)
- Header glitch when opening modals (https://github.com/flarum/core/pull/2131)
- Ensure `SameSite` is explicitly set for cookies (https://github.com/flarum/core/pull/2159)
- Ensure `Flarum\User\Event\AvatarChanged` event is properly dispatched (https://github.com/flarum/core/pull/2197)
- Show correct error message on wrong password when changing email (https://github.com/flarum/core/pull/2171)
- Discussion unreadCount could be higher than commentCount if posts deleted (https://github.com/flarum/core/pull/2195)
- Don't show page title on the default route (https://github.com/flarum/core/pull/2047)
- Add page title to `All Discussions` page when it isn't the default route (https://github.com/flarum/core/pull/2047)
- Accept `'0'` as `false` for `flarum/components/Checkbox` (https://github.com/flarum/core/pull/2210)
- Fix PostStreamScrubber background (https://github.com/flarum/core/pull/2222)
- Test port on BaseUrl tests (https://github.com/flarum/core/pull/2226)
- `UrlGenerator` can now generate urls with optional parameters (https://github.com/flarum/core/pull/2246)
- Allow `less` to be compiled independently of Flarum (https://github.com/flarum/core/pull/2252)
- Use correct number abbreviation (https://github.com/flarum/core/pull/2261)
- Ensure avatar html uses alt tags for accessibility (https://github.com/flarum/core/pull/2269)
- Escape regex when searching (https://github.com/flarum/core/pull/2273)
- Remove unneeded semicolons inserted during JS compilation (https://github.com/flarum/core/pull/2280)
- Don't require a username/password for SMTP (https://github.com/flarum/core/pull/2287)
- Allow uppercase entries for SMTP encryption validation (https://github.com/flarum/core/pull/2289)
- Ensure that the right number of posts is returned from list posts API (https://github.com/flarum/core/pull/2291)
- Fix a variety of PostStream bugs (https://github.com/flarum/core/pull/2160, https://github.com/flarum/core/pull/2160)
- Sliding discussion glitch on mobile (https://github.com/flarum/core/pull/2324)
- Sliding discussion button in wrong place (https://github.com/flarum/core/pull/2330, https://github.com/flarum/core/pull/2383)
- Sliding discussion glitch on mobile (https://github.com/flarum/core/pull/2381)
- Fix PostStream for posts with top margins, and scrubber position when scrolling below posts (https://github.com/flarum/core/pull/2369)
### Removed
- `Flarum\Event\AbstractConfigureRoutes` event class
- `Flarum\Event\ConfigureApiRoutes` event class
- `Flarum\Event\ConfigureForumRoutes` event class
- `Flarum\Console\Event\Configuring` event class
- `Flarum\Event\ConfigureModelDates` event class
- `Flarum\Event\ConfigureLocales` event class
- `Flarum\Event\ConfigureModelDefaultAttributes` event class
- `Flarum\Event\GetModelRelationship` event class
- `Flarum\User\Event\BioChanged` event class
- `Flarum\Database\MigrationServiceProvider` moved into `Flarum\Database\DatabaseServiceProvider`
- Unused `admin/components/Widget` component (`admin/component/DashboardWidget` should be used instead)
- Mandrill mail driver (https://github.com/flarum/core/commit/bca833d3f1c34d45d95bf905902368a2753b8908)
### Deprecated
- `Flarum\User\Event\GetDisplayName` event class
- Global path helpers, `Flarum\Foundation\Application` path methods (https://github.com/flarum/core/pull/2155)
- `Flarum\User\AssertPermissionTrait` (https://github.com/flarum/core/pull/2044)
## [0.1.0-beta.13](https://github.com/flarum/core/compare/v0.1.0-beta.12...v0.1.0-beta.13)
### Added

View File

@@ -10,7 +10,7 @@
"email": "franz@develophp.org"
},
{
"name": "Daniël Klabbers",
"name": "Daniel Klabbers",
"email": "daniel@klabbers.email",
"homepage": "https://luceos.com"
},
@@ -27,10 +27,6 @@
{
"name": "Matthew Kilgore",
"email": "matthew@kilgore.dev"
},
{
"name": "Alexander (Sasha) Skvortsov",
"email": "askvortsov@flarum.org"
}
],
"support": {

14
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

16
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

27
js/package-lock.json generated
View File

@@ -3556,9 +3556,9 @@
"integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8="
},
"jquery": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.1.tgz",
"integrity": "sha512-XwIBPqcMn57FxfT+Go5pzySnm4KWkT1Tv7gjrpT1srtf8Weynl6R273VJ5GjkRb51IzMp5nbaPjJXMWeju2MKg=="
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.4.1.tgz",
"integrity": "sha512-36+AdBzCL+y6qjw5Tx7HgzeGCzC81MDDgaUP8ld2zhx58HdqXGoBd+tHdrBMiyjGQs0Hxs/MLZTu/eHNJJuWPw=="
},
"jquery.hotkeys": {
"version": "0.1.0",
@@ -4546,6 +4546,11 @@
"integrity": "sha512-mUdIBBvdn0PLOeP3TEkMH7HHeUP3GjsXCwKarjv/kGmUFOYg1VqEemKhoQpWMu6X2I8kHeuVdGibLGkVK+/5Qw==",
"dev": true
},
"serialize-javascript": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-2.1.2.tgz",
"integrity": "sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ=="
},
"set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
@@ -4895,29 +4900,21 @@
}
},
"terser-webpack-plugin": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz",
"integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==",
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.3.tgz",
"integrity": "sha512-QMxecFz/gHQwteWwSo5nTc6UaICqN1bMedC5sMtUc7y3Ha3Q8y6ZO0iCR8pq4RJC8Hjf0FEPEHZqcMB/+DFCrA==",
"requires": {
"cacache": "^12.0.2",
"find-cache-dir": "^2.1.0",
"is-wsl": "^1.1.0",
"schema-utils": "^1.0.0",
"serialize-javascript": "^4.0.0",
"serialize-javascript": "^2.1.2",
"source-map": "^0.6.1",
"terser": "^4.1.2",
"webpack-sources": "^1.4.0",
"worker-farm": "^1.7.0"
},
"dependencies": {
"serialize-javascript": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
"integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
"requires": {
"randombytes": "^2.1.0"
}
},
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",

View File

@@ -10,7 +10,7 @@
"dayjs": "^1.8.28",
"expose-loader": "^0.7.5",
"flarum-webpack-config": "0.1.0-beta.10",
"jquery": "^3.5.1",
"jquery": "^3.4.1",
"jquery.hotkeys": "^0.1.0",
"lodash-es": "^4.17.14",
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",

2
js/shims.d.ts vendored
View File

@@ -4,7 +4,6 @@ import Mithril from 'mithril';
// Other third-party libs
import * as _dayjs from 'dayjs';
import * as _$ from 'jquery';
import * as _ColorThief from 'color-thief-browser';
// Globals from flarum/core
import Application from './src/common/Application';
@@ -23,7 +22,6 @@ declare global {
const $: typeof _$;
const m: Mithril.Static;
const dayjs: typeof _dayjs;
const ColorThief: _ColorThief;
}
/**

View File

@@ -27,19 +27,20 @@ export default class AdminApplication extends Application {
* @inheritdoc
*/
mount() {
// Mithril does not render the home route on https://example.com/admin, so
// we need to go to https://example.com/admin#/ explicitly.
if (!document.location.hash) document.location.hash = '#/';
m.route.prefix = '#';
super.mount();
m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
m.mount(document.getElementById('header-navigation'), Navigation);
m.mount(document.getElementById('header-primary'), HeaderPrimary);
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
m.mount(document.getElementById('admin-navigation'), AdminNav);
// Mithril does not render the home route on https://example.com/admin, so
// we need to go to https://example.com/admin#/ explicitly.
if (!document.location.hash) document.location.hash = '#/';
m.route.prefix = '#';
super.mount();
// If an extension has just been enabled, then we will run its settings
// callback.
const enabled = localStorage.getItem('enabledExtension');

View File

@@ -139,20 +139,16 @@ export default class ExtensionsPage extends Page {
// TODO: This workaround should be removed when we move away from bootstrap JS for modals.
setTimeout(() => {
app.modal.close();
}, 300); // Bootstrap's Modal.TRANSITION_DURATION is 300 ms.
if (e.status !== 409) {
throw e;
}
const error = JSON.parse(e.responseText).errors[0];
const error = e.response.errors[0];
app.alerts.show(
{ type: 'error' },
app.translator.trans(`core.lib.error.${error.code}_message`, {
extension: error.extension,
extensions: error.extensions.join(', '),
})
);
app.alerts.show(
{ type: 'error' },
app.translator.trans(`core.lib.error.${error.code}_message`, {
extension: error.extension,
extensions: error.extensions.join(', '),
})
);
}, 250);
}
}

View File

@@ -198,19 +198,13 @@ export default class Application {
m.route(document.getElementById('content'), basePath + '/', mapRoutes(this.routes, basePath));
// Add a class to the body which indicates that the page has been scrolled
// down. When this happens, we'll add classes to the header and app body
// which will set the navbar's position to fixed. We don't want to always
// have it fixed, as that could overlap with custom headers.
const scrollListener = new ScrollListener((top) => {
// down.
new ScrollListener((top) => {
const $app = $('#app');
const offset = $app.offset().top;
$app.toggleClass('affix', top >= offset).toggleClass('scrolled', top > offset);
$('.App-header').toggleClass('navbar-fixed-top', top >= offset);
});
scrollListener.start();
scrollListener.update();
}).start();
$(() => {
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
@@ -278,7 +272,7 @@ export default class Application {
/**
* Make an AJAX request, handling any low-level errors that may occur.
*
* @see https://mithril.js.org/request.html
* @see https://lhorie.github.io/mithril/mithril.request.html
* @param {Object} options
* @return {Promise}
* @public

View File

@@ -1,17 +1,3 @@
import Store from './Store';
import Mithril from 'mithril';
interface ModelData {
type?: string;
id?: string;
attributes?: any;
relationships?: any;
}
interface SaveOptions extends Mithril.RequestOptions<any> {
meta?: any;
}
/**
* The `Model` class represents a local data resource. It provides methods to
* persist changes via the API.
@@ -19,58 +5,55 @@ interface SaveOptions extends Mithril.RequestOptions<any> {
* @abstract
*/
export default class Model {
/**
* The resource object from the API.
*
* @type {Object}
* @public
*/
data: ModelData = {};
/**
* The time at which the model's data was last updated. Watching the value
* of this property is a fast way to retain/cache a subtree if data hasn't
* changed.
*
* @type {Date}
* @public
*/
freshness: Date = new Date();
/**
* Whether or not the resource exists on the server.
*
* @type {Boolean}
* @public
*/
exists: boolean = false;
/**
* The data store that this resource should be persisted to.
*
* @type {Store}
* @protected
*/
store?: Store = null;
/**
* @param {Object} data A resource object from the API.
* @param {Store} store The data store that this model should be persisted to.
* @public
*/
constructor(data: ModelData = {}, store = null) {
constructor(data = {}, store = null) {
/**
* The resource object from the API.
*
* @type {Object}
* @public
*/
this.data = data;
/**
* The time at which the model's data was last updated. Watching the value
* of this property is a fast way to retain/cache a subtree if data hasn't
* changed.
*
* @type {Date}
* @public
*/
this.freshness = new Date();
/**
* Whether or not the resource exists on the server.
*
* @type {Boolean}
* @public
*/
this.exists = false;
/**
* The data store that this resource should be persisted to.
*
* @type {Store}
* @protected
*/
this.store = store;
}
/**
* Get the model's ID.
*
* @return {String}
* @return {Integer}
* @public
* @final
*/
id(): string | undefined {
id() {
return this.data.id;
}
@@ -138,8 +121,8 @@ export default class Model {
* @return {Promise}
* @public
*/
save(attributes, options: SaveOptions = {}) {
const data: ModelData = {
save(attributes, options = {}) {
const data = {
type: this.data.type,
id: this.data.id,
attributes,
@@ -169,7 +152,7 @@ export default class Model {
this.pushData(data);
const request: any = { data };
const request = { data };
if (options.meta) request.meta = options.meta;
return app
@@ -237,11 +220,11 @@ export default class Model {
* @return {String}
* @protected
*/
apiEndpoint(): string {
apiEndpoint() {
return '/' + this.data.type + (this.exists ? '/' + this.data.id : '');
}
copyData(): ModelData {
copyData() {
return JSON.parse(JSON.stringify(this.data));
}
@@ -253,8 +236,8 @@ export default class Model {
* @return {*}
* @public
*/
static attribute<T>(name: string, transform?: Function) {
return function (this: Model): T | null | undefined {
static attribute(name, transform) {
return function () {
const value = this.data.attributes && this.data.attributes[name];
return transform ? transform(value) : value;
@@ -271,8 +254,8 @@ export default class Model {
* has not been loaded; or the model if it has been loaded.
* @public
*/
static hasOne<T>(name: string) {
return function (this: Model): T | null | false {
static hasOne(name) {
return function () {
if (this.data.relationships) {
const relationship = this.data.relationships[name];
@@ -295,8 +278,8 @@ export default class Model {
* loaded, and undefined for those that have not.
* @public
*/
static hasMany<T>(name: string) {
return function (this: Model): T[] | false {
static hasMany(name) {
return function () {
if (this.data.relationships) {
const relationship = this.data.relationships[name];
@@ -316,7 +299,7 @@ export default class Model {
* @return {Date|null}
* @public
*/
static transformDate(value: string): Date | null {
static transformDate(value) {
return value ? new Date(value) : null;
}
@@ -327,7 +310,7 @@ export default class Model {
* @return {Object}
* @protected
*/
static getIdentifier(model: Model) {
static getIdentifier(model) {
return {
type: model.data.type,
id: model.data.id,

View File

@@ -67,7 +67,6 @@ import username from './helpers/username';
import userOnline from './helpers/userOnline';
import listItems from './helpers/listItems';
import Fragment from './Fragment';
import DefaultResolver from './resolvers/DefaultResolver';
export default {
extend: extend,
@@ -139,5 +138,4 @@ export default {
'helpers/username': username,
'helpers/userOnline': userOnline,
'helpers/listItems': listItems,
'resolvers/DefaultResolver': DefaultResolver,
};

View File

@@ -12,14 +12,12 @@ import Link from './Link';
* active.
* - `href` The URL to link to. If the current URL `m.route()` matches this,
* the `active` prop will automatically be set to true.
* - `force` Whether the page should be fully rerendered. Defaults to `true`.
*/
export default class LinkButton extends Button {
static initAttrs(attrs) {
super.initAttrs(attrs);
attrs.active = this.isActive(attrs);
if (attrs.force === undefined) attrs.force = true;
}
view(vnode) {

View File

@@ -35,8 +35,7 @@ export default class Modal extends Component {
this.attrs.animateHide();
// Here, we ensure that the animation has time to complete.
// See https://mithril.js.org/lifecycle-methods.html#onbeforeremove
// Bootstrap's Modal.TRANSITION_DURATION is 300 ms.
return new Promise((resolve) => setTimeout(resolve, 300));
return new Promise((resolve) => setTimeout(resolve, 1000));
}
}

View File

@@ -10,11 +10,7 @@ export default class Page extends Component {
oninit(vnode) {
super.oninit(vnode);
app.previous = app.current;
app.current = new PageState(this.constructor, { routeName: this.attrs.routeName });
app.drawer.hide();
app.modal.close();
this.onNewRoute();
/**
* A class name to apply to the body while the route is active.
@@ -23,12 +19,35 @@ export default class Page extends Component {
*/
this.bodyClass = '';
/**
* Whether we should scroll to the top of the page when its rendered.
*
* @type {Boolean}
*/
this.scrollTopOnCreate = true;
this.currentPath = m.route.get();
}
routeHasChanged() {
return this.currentPath !== m.route.get();
}
/**
* A collections of actions to run when the route changes.
* This is extracted here, and not hardcoded in oninit, as oninit is not called
* when a different route is handled by the same component, but we still need to
* adjust the current route name.
*/
onNewRoute() {
this.currentPath = m.route.get();
app.previous = app.current;
app.current = new PageState(this.constructor, { routeName: this.attrs.routeName });
app.drawer.hide();
app.modal.close();
}
onbeforeupdate(vnode, old) {
super.onbeforeupdate(vnode, old);
if (this.routeHasChanged()) {
this.onNewRoute();
}
}
oncreate(vnode) {
@@ -37,10 +56,6 @@ export default class Page extends Component {
if (this.bodyClass) {
$('#app').addClass(this.bodyClass);
}
if (this.scrollTopOnCreate) {
$(window).scrollTop(0);
}
}
onremove() {

View File

@@ -0,0 +1,12 @@
/**
* The `icon` helper displays an icon.
*
* @param {String} fontClass The full icon class, prefix and the icons name.
* @param {Object} attrs Any other attributes to apply.
* @return {Object}
*/
export default function icon(fontClass, attrs = {}) {
attrs.className = 'icon ' + fontClass + ' ' + (attrs.className || '');
return <i {...attrs} />;
}

View File

@@ -1,13 +0,0 @@
import * as Mithril from 'mithril';
/**
* The `icon` helper displays an icon.
*
* @param fontClass The full icon class, prefix and the icons name.
* @param attrs Any other attributes to apply.
*/
export default function icon(fontClass: string, attrs: Mithril.Attributes = {}): Mithril.Vnode {
attrs.className = 'icon ' + fontClass + ' ' + (attrs.className || '');
return <i {...attrs} />;
}

View File

@@ -2,40 +2,40 @@ import Model from '../Model';
import computed from '../utils/computed';
import ItemList from '../utils/ItemList';
import Badge from '../components/Badge';
import User from './User';
import Post from './Post';
export default class Discussion extends Model {
title = Model.attribute<string>('title');
slug = Model.attribute<string>('slug');
export default class Discussion extends Model {}
createdAt = Model.attribute<Date>('createdAt', Model.transformDate);
user = Model.hasOne<User>('user');
firstPost = Model.hasOne<Post>('firstPost');
Object.assign(Discussion.prototype, {
title: Model.attribute('title'),
slug: Model.attribute('slug'),
lastPostedAt = Model.attribute<Date>('lastPostedAt', Model.transformDate);
lastPostedUser = Model.hasOne<User>('lastPostedUser');
lastPost = Model.hasOne<Post>('lastPost');
lastPostNumber = Model.attribute<number>('lastPostNumber');
createdAt: Model.attribute('createdAt', Model.transformDate),
user: Model.hasOne('user'),
firstPost: Model.hasOne('firstPost'),
commentCount = Model.attribute<number>('commentCount');
replyCount = computed<number>('commentCount', (commentCount) => Math.max(0, commentCount - 1));
posts = Model.hasMany<Post>('posts');
mostRelevantPost = Model.hasOne<Post>('mostRelevantPost');
lastPostedAt: Model.attribute('lastPostedAt', Model.transformDate),
lastPostedUser: Model.hasOne('lastPostedUser'),
lastPost: Model.hasOne('lastPost'),
lastPostNumber: Model.attribute('lastPostNumber'),
lastReadAt = Model.attribute<Date>('lastReadAt', Model.transformDate);
lastReadPostNumber = Model.attribute<number>('lastReadPostNumber');
isUnread = computed<boolean>('unreadCount', (unreadCount) => !!unreadCount);
isRead = computed<boolean>('unreadCount', (unreadCount) => app.session.user && !unreadCount);
commentCount: Model.attribute('commentCount'),
replyCount: computed('commentCount', (commentCount) => Math.max(0, commentCount - 1)),
posts: Model.hasMany('posts'),
mostRelevantPost: Model.hasOne('mostRelevantPost'),
hiddenAt = Model.attribute<Date>('hiddenAt', Model.transformDate);
hiddenUser = Model.hasOne<User>('hiddenUser');
isHidden = computed<boolean>('hiddenAt', (hiddenAt) => !!hiddenAt);
lastReadAt: Model.attribute('lastReadAt', Model.transformDate),
lastReadPostNumber: Model.attribute('lastReadPostNumber'),
isUnread: computed('unreadCount', (unreadCount) => !!unreadCount),
isRead: computed('unreadCount', (unreadCount) => app.session.user && !unreadCount),
canReply = Model.attribute<boolean>('canReply');
canRename = Model.attribute<boolean>('canRename');
canHide = Model.attribute<boolean>('canHide');
canDelete = Model.attribute<boolean>('canDelete');
hiddenAt: Model.attribute('hiddenAt', Model.transformDate),
hiddenUser: Model.hasOne('hiddenUser'),
isHidden: computed('hiddenAt', (hiddenAt) => !!hiddenAt),
canReply: Model.attribute('canReply'),
canRename: Model.attribute('canRename'),
canHide: Model.attribute('canHide'),
canDelete: Model.attribute('canDelete'),
/**
* Remove a post from the discussion's posts relationship.
@@ -55,7 +55,7 @@ export default class Discussion extends Model {
}
});
}
}
},
/**
* Get the estimated number of unread posts in this discussion for the current
@@ -64,7 +64,7 @@ export default class Discussion extends Model {
* @return {Integer}
* @public
*/
unreadCount(): number {
unreadCount() {
const user = app.session.user;
if (user && user.markedAllAsReadAt() < this.lastPostedAt()) {
@@ -75,7 +75,7 @@ export default class Discussion extends Model {
}
return 0;
}
},
/**
* Get the Badge components that apply to this discussion.
@@ -83,7 +83,7 @@ export default class Discussion extends Model {
* @return {ItemList}
* @public
*/
badges(): ItemList {
badges() {
const items = new ItemList();
if (this.isHidden()) {
@@ -91,7 +91,7 @@ export default class Discussion extends Model {
}
return items;
}
},
/**
* Get a list of all of the post IDs in this discussion.
@@ -99,9 +99,9 @@ export default class Discussion extends Model {
* @return {Array}
* @public
*/
postIds(): string[] {
postIds() {
const posts = this.data.relationships.posts;
return posts ? posts.data.map((link) => link.id) : [];
}
}
},
});

View File

@@ -0,0 +1,17 @@
import Model from '../Model';
class Group extends Model {}
Object.assign(Group.prototype, {
nameSingular: Model.attribute('nameSingular'),
namePlural: Model.attribute('namePlural'),
color: Model.attribute('color'),
icon: Model.attribute('icon'),
isHidden: Model.attribute('isHidden'),
});
Group.ADMINISTRATOR_ID = '1';
Group.GUEST_ID = '2';
Group.MEMBER_ID = '3';
export default Group;

View File

@@ -1,13 +0,0 @@
import Model from '../Model';
export default class Group extends Model {
static ADMINISTRATOR_ID = '1';
static GUEST_ID = '2';
static MEMBER_ID = '3';
nameSingular = Model.attribute<string>('nameSingular');
namePlural = Model.attribute<string>('namePlural');
color = Model.attribute<string>('color');
icon = Model.attribute<string>('icon');
isHidden = Model.attribute<boolean>('isHidden');
}

View File

@@ -0,0 +1,15 @@
import Model from '../Model';
export default class Notification extends Model {}
Object.assign(Notification.prototype, {
contentType: Model.attribute('contentType'),
content: Model.attribute('content'),
createdAt: Model.attribute('createdAt', Model.transformDate),
isRead: Model.attribute('isRead'),
user: Model.hasOne('user'),
fromUser: Model.hasOne('fromUser'),
subject: Model.hasOne('subject'),
});

View File

@@ -1,14 +0,0 @@
import Model from '../Model';
import User from './User';
export default class Notification extends Model {
contentType = Model.attribute<string>('contentType');
content = Model.attribute<any>('content');
createdAt = Model.attribute<Date>('createdAt', Model.transformDate);
isRead = Model.attribute<boolean>('isRead');
user = Model.hasOne<User>('user');
fromUser = Model.hasOne<User>('fromUser');
subject = Model.hasOne<any>('subject');
}

View File

@@ -0,0 +1,29 @@
import Model from '../Model';
import computed from '../utils/computed';
import { getPlainContent } from '../utils/string';
export default class Post extends Model {}
Object.assign(Post.prototype, {
number: Model.attribute('number'),
discussion: Model.hasOne('discussion'),
createdAt: Model.attribute('createdAt', Model.transformDate),
user: Model.hasOne('user'),
contentType: Model.attribute('contentType'),
content: Model.attribute('content'),
contentHtml: Model.attribute('contentHtml'),
contentPlain: computed('contentHtml', getPlainContent),
editedAt: Model.attribute('editedAt', Model.transformDate),
editedUser: Model.hasOne('editedUser'),
isEdited: computed('editedAt', (editedAt) => !!editedAt),
hiddenAt: Model.attribute('hiddenAt', Model.transformDate),
hiddenUser: Model.hasOne('hiddenUser'),
isHidden: computed('hiddenAt', (hiddenAt) => !!hiddenAt),
canEdit: Model.attribute('canEdit'),
canHide: Model.attribute('canHide'),
canDelete: Model.attribute('canDelete'),
});

View File

@@ -1,29 +0,0 @@
import Model from '../Model';
import computed from '../utils/computed';
import { getPlainContent } from '../utils/string';
import Discussion from './Discussion';
import User from './User';
export default class Post extends Model {
number = Model.attribute<number>('number');
discussion = Model.hasOne<Discussion>('discussion');
createdAt = Model.attribute<Date>('createdAt', Model.transformDate);
user = Model.hasOne<User>('user');
contentType = Model.attribute<string>('contentType');
content = Model.attribute<string>('content');
contentHtml = Model.attribute<string>('contentHtml');
contentPlain = computed<string>('contentHtml', getPlainContent);
editedAt = Model.attribute<Date>('editedAt', Model.transformDate);
editedUser = Model.hasOne<User>('editedUser');
isEdited = computed<boolean>('editedAt', (editedAt) => !!editedAt);
hiddenAt = Model.attribute<Date>('hiddenAt', Model.transformDate);
hiddenUser = Model.hasOne<User>('hiddenUser');
isHidden = computed<boolean>('hiddenAt', (hiddenAt) => !!hiddenAt);
canEdit = Model.attribute<boolean>('canEdit');
canHide = Model.attribute<boolean>('canHide');
canDelete = Model.attribute<boolean>('canDelete');
}

View File

@@ -5,33 +5,34 @@ import stringToColor from '../utils/stringToColor';
import ItemList from '../utils/ItemList';
import computed from '../utils/computed';
import GroupBadge from '../components/GroupBadge';
import Group from './Group';
export default class User extends Model {
username = Model.attribute<string>('username');
displayName = Model.attribute<string>('displayName');
email = Model.attribute<string>('email');
isEmailConfirmed = Model.attribute<boolean>('isEmailConfirmed');
password = Model.attribute<string>('password');
export default class User extends Model {}
avatarUrl = Model.attribute<string>('avatarUrl');
preferences = Model.attribute<any>('preferences');
groups = Model.hasMany<Group>('groups');
Object.assign(User.prototype, {
username: Model.attribute('username'),
displayName: Model.attribute('displayName'),
email: Model.attribute('email'),
isEmailConfirmed: Model.attribute('isEmailConfirmed'),
password: Model.attribute('password'),
joinTime = Model.attribute<Date>('joinTime', Model.transformDate);
lastSeenAt = Model.attribute<Date>('lastSeenAt', Model.transformDate);
markedAllAsReadAt = Model.attribute<Date>('markedAllAsReadAt', Model.transformDate);
unreadNotificationCount = Model.attribute<number>('unreadNotificationCount');
newNotificationCount = Model.attribute<number>('newNotificationCount');
avatarUrl: Model.attribute('avatarUrl'),
preferences: Model.attribute('preferences'),
groups: Model.hasMany('groups'),
discussionCount = Model.attribute<number>('discussionCount');
commentCount = Model.attribute<number>('commentCount');
joinTime: Model.attribute('joinTime', Model.transformDate),
lastSeenAt: Model.attribute('lastSeenAt', Model.transformDate),
markedAllAsReadAt: Model.attribute('markedAllAsReadAt', Model.transformDate),
unreadNotificationCount: Model.attribute('unreadNotificationCount'),
newNotificationCount: Model.attribute('newNotificationCount'),
canEdit = Model.attribute<boolean>('canEdit');
canDelete = Model.attribute<boolean>('canDelete');
discussionCount: Model.attribute('discussionCount'),
commentCount: Model.attribute('commentCount'),
avatarColor = null;
color = computed<string>('username', 'avatarUrl', 'avatarColor', (username, avatarUrl, avatarColor) => {
canEdit: Model.attribute('canEdit'),
canDelete: Model.attribute('canDelete'),
avatarColor: null,
color: computed('username', 'avatarUrl', 'avatarColor', function (username, avatarUrl, avatarColor) {
// If we've already calculated and cached the dominant color of the user's
// avatar, then we can return that in RGB format. If we haven't, we'll want
// to calculate it. Unless the user doesn't have an avatar, in which case
@@ -44,7 +45,7 @@ export default class User extends Model {
}
return '#' + stringToColor(username);
});
}),
/**
* Check whether or not the user has been seen in the last 5 minutes.
@@ -52,16 +53,16 @@ export default class User extends Model {
* @return {Boolean}
* @public
*/
isOnline(): boolean {
isOnline() {
return dayjs().subtract(5, 'minutes').isBefore(this.lastSeenAt());
}
},
/**
* Get the Badge components that apply to this user.
*
* @return {ItemList}
*/
badges(): ItemList {
badges() {
const items = new ItemList();
const groups = this.groups();
@@ -72,7 +73,7 @@ export default class User extends Model {
}
return items;
}
},
/**
* Calculate the dominant color of the user's avatar. The dominant color will
@@ -92,7 +93,7 @@ export default class User extends Model {
};
image.crossOrigin = 'anonymous';
image.src = this.avatarUrl();
}
},
/**
* Update the user's preferences.
@@ -106,5 +107,5 @@ export default class User extends Model {
Object.assign(preferences, newPreferences);
return this.save({ preferences });
}
}
},
});

View File

@@ -1,41 +0,0 @@
import Mithril from 'mithril';
/**
* Generates a route resolver for a given component.
* In addition to regular route resolver functionality:
* - It provide the current route name as an attr
* - It sets a key on the component so a rerender will be triggered on route change.
*/
export default class DefaultResolver {
component: Mithril.Component;
routeName: string;
constructor(component, routeName) {
this.component = component;
this.routeName = routeName;
}
/**
* When a route change results in a changed key, a full page
* rerender occurs. This method can be overriden in subclasses
* to prevent rerenders on some route changes.
*/
makeKey() {
return this.routeName + JSON.stringify(m.route.param());
}
makeAttrs(vnode) {
return {
...vnode.attrs,
routeName: this.routeName,
};
}
onmatch(args, requestedPath, route) {
return this.component;
}
render(vnode) {
return [{ ...vnode, attrs: this.makeAttrs(vnode), key: this.makeKey() }];
}
}

View File

@@ -58,7 +58,7 @@ export default class ScrollListener {
*/
start() {
if (!this.active) {
window.addEventListener('scroll', (this.active = this.loop.bind(this)), { passive: true });
window.addEventListener('scroll', (this.active = this.loop.bind(this)));
}
}

View File

@@ -28,9 +28,6 @@ export default class SubtreeRetainer {
constructor(...callbacks) {
this.callbacks = callbacks;
this.data = {};
// Build the initial data, so it is available when calling
// needsRebuild from the onbeforeupdate hook.
this.needsRebuild();
}
/**
@@ -63,8 +60,6 @@ export default class SubtreeRetainer {
*/
check(...callbacks) {
this.callbacks = this.callbacks.concat(callbacks);
// Update the data cache when new checks are added.
this.needsRebuild();
}
/**

View File

@@ -1,5 +1,3 @@
import Model from '../Model';
/**
* The `computed` utility creates a function that will cache its output until
* any of the dependent values are dirty.
@@ -9,14 +7,14 @@ import Model from '../Model';
* dependent values.
* @return {Function}
*/
export default function computed<T, M = Model>(...dependentKeys: any[]) {
export default function computed(...dependentKeys) {
const keys = dependentKeys.slice(0, -1);
const compute = dependentKeys.slice(-1)[0];
const dependentValues = {};
let computedValue;
return function (this: M): T {
return function () {
let recompute = false;
// Read all of the dependent values. If any of them have changed since last

View File

@@ -1,9 +1,6 @@
import DefaultResolver from '../resolvers/DefaultResolver';
/**
* The `mapRoutes` utility converts a map of named application routes into a
* format that can be understood by Mithril, and wraps them in route resolvers
* to provide each route with the current route name.
* format that can be understood by Mithril.
*
* @see https://mithril.js.org/route.html#signature
* @param {Object} routes
@@ -13,17 +10,14 @@ import DefaultResolver from '../resolvers/DefaultResolver';
export default function mapRoutes(routes, basePath = '') {
const map = {};
for (const routeName in routes) {
const route = routes[routeName];
for (const key in routes) {
const route = routes[key];
if ('resolver' in route) {
map[basePath + route.path] = route.resolver;
} else if ('component' in route) {
const resolverClass = 'resolverClass' in route ? route.resolverClass : DefaultResolver;
map[basePath + route.path] = new resolverClass(route.component, routeName);
} else {
throw new Error(`Either a resolver or a component must be provided for the route [${routeName}]`);
}
map[basePath + route.path] = {
render() {
return m(route.component, { routeName: key });
},
};
}
return map;

View File

@@ -115,19 +115,17 @@ export default class ForumApplication extends Application {
this.routes[defaultAction].path = '/';
this.history.push(defaultAction, this.translator.trans('core.forum.header.back_to_index_tooltip'), '/');
this.pane = new Pane(document.getElementById('app'));
m.route.prefix = '';
super.mount(this.forum.attribute('basePath'));
// We mount navigation and header components after the page, so components
// like the back button can access the updated state when rendering.
m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
m.mount(document.getElementById('header-navigation'), Navigation);
m.mount(document.getElementById('header-primary'), HeaderPrimary);
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
m.mount(document.getElementById('composer'), { view: () => Composer.component({ state: this.composer }) });
this.pane = new Pane(document.getElementById('app'));
m.route.prefix = '';
super.mount(this.forum.attribute('basePath'));
alertEmailConfirmation(this);
// Route the home link back home when clicked. We do not want it to register

View File

@@ -71,7 +71,6 @@ import Search from './components/Search';
import DiscussionListItem from './components/DiscussionListItem';
import LoadingPost from './components/LoadingPost';
import PostsUserPage from './components/PostsUserPage';
import DiscussionPageResolver from './resolvers/DiscussionPageResolver';
import routes from './routes';
import ForumApplication from './ForumApplication';
@@ -147,7 +146,6 @@ export default Object.assign(compat, {
'components/DiscussionListItem': DiscussionListItem,
'components/LoadingPost': LoadingPost,
'components/PostsUserPage': PostsUserPage,
'resolvers/DiscussionPageResolver': DiscussionPageResolver,
routes: routes,
ForumApplication: ForumApplication,
});

View File

@@ -91,12 +91,12 @@ export default class DiscussionListItem extends Component {
)
: ''}
<span
<a
className={'Slidable-underneath Slidable-underneath--left Slidable-underneath--elastic' + (isUnread ? '' : ' disabled')}
onclick={this.markAsRead.bind(this)}
>
{icon('fas fa-check')}
</span>
</a>
<div className={'DiscussionListItem-content Slidable-content' + (isUnread ? ' unread' : '') + (isRead ? ' read' : '')}>
<Link

View File

@@ -32,25 +32,12 @@ export default class DiscussionPage extends Page {
*/
this.near = m.route.param('near') || 0;
this.load();
// If the discussion list has been loaded, then we'll enable the pane (and
// hide it by default). Also, if we've just come from another discussion
// page, then we don't want Mithril to redraw the whole page if it did,
// then the pane would redraw which would be slow and would cause problems with
// event handlers.
if (app.discussions.hasDiscussions()) {
app.pane.enable();
app.pane.hide();
}
app.history.push('discussion');
this.bodyClass = 'App--discussion';
}
onremove() {
super.onremove();
// If we are indeed navigating away from this discussion, then disable the
// discussion list pane. Also, if we're composing a reply to this
// discussion, minimize the composer unless it's empty, in which case
@@ -82,6 +69,7 @@ export default class DiscussionPage extends Page {
{PostStream.component({
discussion,
stream: this.stream,
targetPost: this.stream.targetPost,
onPositionChange: this.positionChanged.bind(this),
})}
</div>
@@ -93,6 +81,36 @@ export default class DiscussionPage extends Page {
);
}
onNewRoute() {
// If we have routed to the same discussion as we were viewing previously,
// prompt the post stream to jump to the new 'near' param.
// Otherwise, load in a new discussion. The `else` branch will
// be followed when `onNewRoute` is called from `oninit`.
const idParam = m.route.param('id');
if (this.discussion && idParam && idParam.split('-')[0] === this.discussion.id()) {
const near = m.route.param('near') || '1';
if (near !== String(this.near)) {
this.stream.goToNumber(near);
}
this.near = near;
} else {
super.onNewRoute();
this.discussion = null;
this.load();
// If the discussion list has been loaded, then we'll enable the pane (and
// hide it by default).
if (app.discussions.hasDiscussions()) {
app.pane.enable();
app.pane.hide();
}
}
}
/**
* Load the discussion from the API or use the preloaded one.
*/
@@ -155,7 +173,7 @@ export default class DiscussionPage extends Page {
record.relationships.discussion.data.id === discussionId
)
.map((record) => app.store.getById('posts', record.id))
.sort((a, b) => a.createdAt() - b.createdAt())
.sort((a, b) => a.id() - b.id())
.slice(0, 20);
}
@@ -217,7 +235,10 @@ export default class DiscussionPage extends Page {
// replace it into the window's history and our own history stack.
const url = app.route.discussion(discussion, (this.near = startNumber));
this.prevRoute = url;
m.route.set(url, null, { replace: true });
window.history.replaceState(null, document.title, url);
app.history.push('discussion', discussion.title());
// If the user hasn't read past here before, then we'll update their read

View File

@@ -29,6 +29,14 @@ export default class IndexPage extends Page {
this.lastDiscussion = app.previous.get('discussion');
}
app.history.push('index', app.translator.trans('core.forum.header.back_to_index_tooltip'));
this.bodyClass = 'App--index';
}
onNewRoute() {
super.onNewRoute();
// 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
@@ -39,10 +47,7 @@ export default class IndexPage extends Page {
app.discussions.refreshParams(app.search.params());
app.history.push('index', app.translator.trans('core.forum.header.back_to_index_tooltip'));
this.bodyClass = 'App--index';
this.scrollTopOnCreate = false;
this.setTitle();
}
view() {
@@ -86,22 +91,18 @@ export default class IndexPage extends Page {
$('#app').css('min-height', $(window).height() + heroHeight);
// Let browser handle scrolling on page reload.
if (app.previous.type == null) return;
// When on mobile, only retain scroll if we're coming from a discussion page.
// Otherwise, we've just changed the filter, so we should go to the top of the page.
if (app.screen() == 'desktop' || app.screen() == 'desktop-hd' || this.lastDiscussion) {
$(window).scrollTop(scrollTop - oldHeroHeight + heroHeight);
} else {
$(window).scrollTop(0);
}
// Scroll to the remembered position. We do this after a short delay so that
// it happens after the browser has done its own "back button" scrolling,
// which isn't right. https://github.com/flarum/core/issues/835
const scroll = () => $(window).scrollTop(scrollTop - oldHeroHeight + heroHeight);
scroll();
setTimeout(scroll, 1);
// If we've just returned from a discussion page, then the constructor will
// have set the `lastDiscussion` property. If this is the case, we want to
// scroll down to that discussion so that it's in view.
if (this.lastDiscussion) {
const $discussion = this.$(`li[data-id="${this.lastDiscussion.id()}"] .DiscussionListItem`);
const $discussion = this.$(`.DiscussionListItem[data-id="${this.lastDiscussion.id()}"]`);
if ($discussion.length) {
const indexTop = $('#header').outerHeight();
@@ -116,16 +117,14 @@ export default class IndexPage extends Page {
}
}
onbeforeremove() {
// Save the scroll position so we can restore it when we return to the
// discussion list.
app.cache.scrollTop = $(window).scrollTop();
}
onremove() {
super.onremove();
$('#app').css('min-height', '');
// Save the scroll position so we can restore it when we return to the
// discussion list.
app.cache.scrollTop = $(window).scrollTop();
}
/**

View File

@@ -24,7 +24,7 @@ export default class Notification extends Component {
<Link
className={'Notification Notification--' + notification.contentType() + ' ' + (!notification.isRead() ? 'unread' : '')}
href={href}
external={href.includes('://')}
external={href.indexOf('://') === -1}
onclick={this.markAsRead.bind(this)}
>
{!notification.isRead() &&

View File

@@ -32,13 +32,6 @@ export default class PostStream extends Component {
const posts = this.stream.posts();
const postIds = this.discussion.postIds();
const postFadeIn = (vnode) => {
$(vnode.dom).addClass('fadeIn');
// 500 is the duration of the fadeIn CSS animation + 100ms,
// so the animation has time to complete
setTimeout(() => $(vnode.dom).removeClass('fadeIn'), 500);
};
const items = posts.map((post, i) => {
let content;
const attrs = { 'data-index': this.stream.visibleStart + i };
@@ -49,7 +42,6 @@ export default class PostStream extends Component {
content = PostComponent ? PostComponent.component({ post }) : '';
attrs.key = 'post' + post.id();
attrs.oncreate = postFadeIn;
attrs['data-time'] = time.toISOString();
attrs['data-number'] = post.number();
attrs['data-id'] = post.id();
@@ -97,7 +89,7 @@ export default class PostStream extends Component {
// is not already doing so, then show a 'write a reply' placeholder.
if (viewingEnd && (!app.session.user || this.discussion.canReply())) {
items.push(
<div className="PostStream-item" key="reply" data-index={this.stream.count()} oncreate={postFadeIn}>
<div className="PostStream-item" key="reply">
{ReplyPlaceholder.component({ discussion: this.discussion })}
</div>
);
@@ -129,15 +121,16 @@ export default class PostStream extends Component {
* Start scrolling, if appropriate, to a newly-targeted post.
*/
triggerScroll() {
if (!this.stream.needsScroll) return;
if (!this.attrs.targetPost || !this.stream.needsScroll) return;
const target = this.stream.targetPost;
const newTarget = this.attrs.targetPost;
this.stream.needsScroll = false;
if ('number' in target) {
this.scrollToNumber(target.number, this.stream.animateScroll);
} else if ('index' in target) {
this.scrollToIndex(target.index, this.stream.animateScroll, target.reply);
if ('number' in newTarget) {
this.scrollToNumber(newTarget.number, this.stream.animateScroll);
} else if ('index' in newTarget) {
const backwards = newTarget.index === this.stream.count() - 1;
this.scrollToIndex(newTarget.index, this.stream.animateScroll, backwards);
}
}
@@ -188,9 +181,9 @@ export default class PostStream extends Component {
// seen if the browser were scrolled right up to the top of the page,
// and the viewport had a height of 0.
const $items = this.$('.PostStream-item[data-index]');
let index = $items.first().data('index') || 0;
let visible = 0;
let period = '';
let indexFromViewPort = null;
// Now loop through each of the items in the discussion. An 'item' is
// either a single post or a 'gap' of one or more posts that haven't
@@ -216,10 +209,8 @@ export default class PostStream extends Component {
const visibleBottom = Math.min(height, viewportTop + viewportHeight - top);
const visiblePost = visibleBottom - visibleTop;
// We take the index of the first item that passed the previous checks.
// It is the item that is first visible in the viewport.
if (indexFromViewPort === null) {
indexFromViewPort = parseFloat($this.data('index')) + visibleTop / height;
if (top <= viewportTop) {
index = parseFloat($this.data('index')) + visibleTop / height;
}
if (visiblePost > 0) {
@@ -232,10 +223,7 @@ export default class PostStream extends Component {
if (time) period = time;
});
// If indexFromViewPort is null, it means no posts are visible in the
// viewport. This can happen, when drafting a long reply post. In that case
// set the index to the last post.
this.stream.index = indexFromViewPort !== null ? indexFromViewPort + 1 : this.stream.count();
this.stream.index = index + 1;
this.stream.visible = visible;
if (period) this.stream.description = dayjs(period).format('MMMM YYYY');
}
@@ -308,17 +296,18 @@ export default class PostStream extends Component {
*
* @param {Integer} index
* @param {Boolean} animate
* @param {Boolean} reply Whether or not to scroll to the reply placeholder.
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
* at the given index, instead of the top of it.
* @return {jQuery.Deferred}
*/
scrollToIndex(index, animate, reply) {
const $item = reply ? $('.PostStream-item:last-child') : this.$(`.PostStream-item[data-index=${index}]`);
scrollToIndex(index, animate, bottom) {
const $item = this.$(`.PostStream-item[data-index=${index}]`);
this.scrollToItem($item, animate, true, reply);
if (reply) {
this.flashItem($item);
}
return this.scrollToItem($item, animate, true, bottom).then(() => {
if (index == this.stream.count() - 1) {
this.flashItem(this.$('.PostStream-item:last-child'));
}
});
}
/**
@@ -328,10 +317,11 @@ export default class PostStream extends Component {
* @param {Boolean} animate
* @param {Boolean} force Whether or not to force scrolling to the item, even
* if it is already in the viewport.
* @param {Boolean} reply Whether or not to scroll to the reply placeholder.
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
* at the given index, instead of the top of it.
* @return {jQuery.Deferred}
*/
scrollToItem($item, animate, force, reply) {
scrollToItem($item, animate, force, bottom) {
const $container = $('html, body').stop(true);
const index = $item.data('index');
@@ -342,10 +332,10 @@ export default class PostStream extends Component {
const scrollBottom = scrollTop + $(window).height();
// If the item is already in the viewport, we may not need to scroll.
// If we're scrolling to the reply placeholder, we'll make sure its
// If we're scrolling to the bottom of an item, then we'll make sure the
// bottom will line up with the top of the composer.
if (force || itemTop < scrollTop || itemBottom > scrollBottom) {
const top = reply ? itemBottom - $(window).height() + app.composer.computedHeight() : $item.is(':first-child') ? 0 : itemTop;
const top = bottom ? itemBottom - $(window).height() + app.composer.computedHeight() : $item.is(':first-child') ? 0 : itemTop;
if (!animate) {
$container.scrollTop(top);
@@ -359,7 +349,7 @@ export default class PostStream extends Component {
// We manually set the index because we want to display the index of the
// exact post we've scrolled to, not just that of the first post within viewport.
this.updateScrubber();
if (index !== undefined) this.stream.index = index + 1;
this.stream.index = index;
};
// If we don't update this before the scroll, the scrubber will start
@@ -369,29 +359,24 @@ export default class PostStream extends Component {
return Promise.all([$container.promise(), this.stream.loadPromise]).then(() => {
m.redraw.sync();
// Rendering post contents will probably throw off our position.
// To counter this, we'll scroll either:
// - To the reply placeholder (aligned with composer top)
// - To the top of the page if we're on the first post
// - To the top of a post (if that post exists)
// If the post does not currently exist, it's probably
// outside of the range we loaded in, so we won't adjust anything,
// as it will soon be rendered by the "load more" system.
let itemOffset;
if (reply) {
const $placeholder = $('.PostStream-item:last-child');
$(window).scrollTop($placeholder.offset().top + $placeholder.height() - $(window).height() + app.composer.computedHeight());
} else if (index === 0) {
$(window).scrollTop(0);
} else if ((itemOffset = $(`.PostStream-item[data-index=${index}]`).offset())) {
$(window).scrollTop(itemOffset.top - this.getMarginTop());
}
// We want to adjust this again after posts have been loaded in
// and position adjusted so that the scrubber's height is accurate.
// We want to adjust this again after posts have been loaded in so that
// the height of the scrubber is accurate.
updateScrubberHeight();
// After post data has been loaded in, we will attempt to scroll back
// to the top of the requested post (or to the top of the page if the
// first post was requested). In some cases, we may have scrolled to
// the end of the available post range, in which case, the next range
// of posts will be loaded in. However, in those cases, the post we
// requested won't exist, so scrolling to it would cause an error.
// Accordingly, we start by checking that it's offset is defined.
const offset = $(`.PostStream-item[data-index=${index}]`).offset();
if (index === 0) {
$(window).scrollTop(0);
} else if (offset) {
$(window).scrollTop($(`.PostStream-item[data-index=${index}]`).offset().top - this.getMarginTop());
}
this.calculatePosition();
this.stream.paused = false;
});
@@ -403,11 +388,10 @@ export default class PostStream extends Component {
* @param {jQuery} $item
*/
flashItem($item) {
// This might execute before the fadeIn class has been removed in PostStreamItem's
// oncreate, so we remove it just to be safe and avoid a double animation.
$item.removeClass('fadeIn');
$item.addClass('flash').on('animationend webkitAnimationEnd', (e) => {
$item.removeClass('flash');
if (e.animationName === 'fadeIn') {
$item.removeClass('flash');
}
});
}
}

View File

@@ -33,7 +33,7 @@ export default class ReplyPlaceholder extends Component {
}
const reply = () => {
DiscussionControls.replyAction.call(this.attrs.discussion, true).catch(() => {});
DiscussionControls.replyAction.call(this.attrs.discussion, true);
};
return (

View File

@@ -29,6 +29,14 @@ export default class UserPage extends Page {
this.bodyClass = 'App--user';
}
onNewRoute() {
super.onNewRoute();
if (m.route.param('username')) {
this.loadUser(m.route.param('username'));
}
}
view() {
return (
<div className="UserPage">
@@ -135,7 +143,7 @@ export default class UserPage extends Page {
items.add(
'posts',
<LinkButton href={app.route('user.posts', { username: user.username() })} icon="far fa-comment">
<LinkButton href={app.route('user.posts', { username: user.username() })} force icon="far fa-comment">
{app.translator.trans('core.forum.user.posts_link')} <span className="Button-badge">{user.commentCount()}</span>
</LinkButton>,
100
@@ -143,7 +151,7 @@ export default class UserPage extends Page {
items.add(
'discussions',
<LinkButton href={app.route('user.discussions', { username: user.username() })} icon="fas fa-bars">
<LinkButton href={app.route('user.discussions', { username: user.username() })} force icon="fas fa-bars">
{app.translator.trans('core.forum.user.discussions_link')} <span className="Button-badge">{user.discussionCount()}</span>
</LinkButton>,
90

View File

@@ -1,49 +0,0 @@
import DefaultResolver from '../../common/resolvers/DefaultResolver';
import DiscussionPage from '../components/DiscussionPage';
/**
* This isn't exported as it is a temporary measure.
* A more robust system will be implemented alongside UTF-8 support in beta 15.
*/
function getDiscussionIdFromSlug(slug: string | undefined) {
if (!slug) return;
return slug.split('-')[0];
}
/**
* A custom route resolver for DiscussionPage that generates the same key to all posts
* on the same discussion. It triggers a scroll when going from one post to another
* in the same discussion.
*/
export default class DiscussionPageResolver extends DefaultResolver {
static scrollToPostNumber: string | null = null;
makeKey() {
const params = { ...m.route.param() };
if ('near' in params) {
delete params.near;
}
params.id = getDiscussionIdFromSlug(params.id);
return this.routeName.replace('.near', '') + JSON.stringify(params);
}
onmatch(args, requestedPath, route) {
if (app.current.matches(DiscussionPage) && getDiscussionIdFromSlug(args.id) === getDiscussionIdFromSlug(m.route.param('id'))) {
// By default, the first post number of any discussion is 1
DiscussionPageResolver.scrollToPostNumber = args.near || '1';
}
return super.onmatch(args, requestedPath, route);
}
render(vnode) {
if (DiscussionPageResolver.scrollToPostNumber !== null) {
const number = DiscussionPageResolver.scrollToPostNumber;
// Scroll after a timeout to avoid clashes with the render.
setTimeout(() => app.current.get('stream').goToNumber(number));
DiscussionPageResolver.scrollToPostNumber = null;
}
return super.render(vnode);
}
}

View File

@@ -4,7 +4,6 @@ import PostsUserPage from './components/PostsUserPage';
import DiscussionsUserPage from './components/DiscussionsUserPage';
import SettingsPage from './components/SettingsPage';
import NotificationsPage from './components/NotificationsPage';
import DiscussionPageResolver from './resolvers/DiscussionPageResolver';
/**
* The `routes` initializer defines the forum app's routes.
@@ -15,8 +14,8 @@ export default function (app) {
app.routes = {
index: { path: '/all', component: IndexPage },
discussion: { path: '/d/:id', component: DiscussionPage, resolverClass: DiscussionPageResolver },
'discussion.near': { path: '/d/:id/:near', component: DiscussionPage, resolverClass: DiscussionPageResolver },
discussion: { path: '/d/:id', component: DiscussionPage },
'discussion.near': { path: '/d/:id/:near', component: DiscussionPage },
user: { path: '/u/:username', component: PostsUserPage },
'user.posts': { path: '/u/:username', component: PostsUserPage },

View File

@@ -96,9 +96,7 @@ class PostStreamState {
// If we want to go to the reply preview, then we will go to the end of the
// discussion and then scroll to the very bottom of the page.
if (number === 'reply') {
const resultPromise = this.goToLast();
this.targetPost.reply = true;
return resultPromise;
return this.goToLast();
}
this.paused = true;
@@ -282,13 +280,7 @@ class PostStreamState {
}
});
if (loadIds.length) {
return app.store.find('posts', loadIds).then((newPosts) => {
return loaded.concat(newPosts).sort((a, b) => a.createdAt() - b.createdAt());
});
}
return Promise.resolve(loaded);
return loadIds.length ? app.store.find('posts', loadIds) : Promise.resolve(loaded);
}
/**

View File

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

View File

@@ -6,7 +6,18 @@
margin-top: 10px;
}
}
@-webkit-keyframes fadeIn {
0% {opacity: 0}
100% {opacity: 1}
}
@keyframes fadeIn {
0% {opacity: 0}
100% {opacity: 1}
}
.PostStream-item {
.animation(fadeIn 0.4s ease-in-out);
&:not(:last-child) {
border-bottom: 1px solid @control-bg;
@@ -93,16 +104,3 @@
.animation(pulsate 0.2s ease-in-out);
.animation-iteration-count(1);
}
@-webkit-keyframes fadeIn {
0% {opacity: 0}
100% {opacity: 1}
}
@keyframes fadeIn {
0% {opacity: 0}
100% {opacity: 1}
}
.fadeIn {
.animation(fadeIn 0.4s ease-in-out);
.animation-iteration-count(1);
}

View File

@@ -44,7 +44,7 @@
.sliding& {
position: relative;
background: @control-bg;
background: #fff;
z-index: 2;
border-radius: 2px;
.box-shadow(0 2px 6px @shadow-color);

View File

@@ -82,7 +82,7 @@ abstract class AbstractModel extends Eloquent
}
$this->attributes = array_map(function ($item) {
return is_callable($item) ? $item($this) : $item;
return is_callable($item) ? $item() : $item;
}, $this->attributes);
parent::__construct($attributes);

View File

@@ -21,7 +21,7 @@ class Application
*
* @var string
*/
const VERSION = '0.1.0-beta.14';
const VERSION = '0.1.0-beta.14-dev';
/**
* The IoC container for the Flarum application.

View File

@@ -1,27 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Queue\Console;
use Flarum\Foundation\Config;
class WorkCommand extends \Illuminate\Queue\Console\WorkCommand
{
protected function downForMaintenance()
{
if ($this->option('force')) {
return false;
}
/** @var Config $config */
$config = $this->laravel->make(Config::class);
return $config->inMaintenanceMode();
}
}

View File

@@ -0,0 +1,61 @@
<?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\Queue;
use Illuminate\Contracts\Queue\Factory;
use Illuminate\Queue\QueueManager;
/**
* A hacky workaround to avoid injecting an entire QueueManager (which we don't
* want to build) into Laravel's queue worker class.
*
* Laravel 6.0 will clean this up; once we upgrade, we can remove this hack and
* directly inject the factory.
*/
class HackyManagerForWorker extends QueueManager implements Factory
{
/**
* @var Factory
*/
private $factory;
/**
* HackyManagerForWorker constructor.
*
* Needs a real connection factory to delegate to.
*
* @param Factory $factory
*/
public function __construct(Factory $factory)
{
$this->factory = $factory;
}
/**
* Resolve a queue connection instance.
*
* @param string $name
* @return \Illuminate\Contracts\Queue\Queue
*/
public function connection($name = null)
{
return $this->factory->connection($name);
}
/**
* Determine if the application is in maintenance mode.
*
* @return bool
*/
public function isDownForMaintenance()
{
return false;
}
}

View File

@@ -9,8 +9,8 @@
namespace Flarum\Queue;
use Flarum\Console\Event\Configuring;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\Config;
use Flarum\Foundation\ErrorHandling\Registry;
use Flarum\Foundation\ErrorHandling\Reporter;
use Flarum\Foundation\Paths;
@@ -22,7 +22,6 @@ use Illuminate\Queue\Console as Commands;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Queue\Failed\NullFailedJobProvider;
use Illuminate\Queue\Listener as QueueListener;
use Illuminate\Queue\QueueManager;
use Illuminate\Queue\SyncQueue;
use Illuminate\Queue\Worker;
@@ -35,7 +34,7 @@ class QueueServiceProvider extends AbstractServiceProvider
Commands\ListFailedCommand::class,
Commands\RestartCommand::class,
Commands\RetryCommand::class,
Console\WorkCommand::class,
Commands\WorkCommand::class,
];
public function register()
@@ -62,16 +61,10 @@ class QueueServiceProvider extends AbstractServiceProvider
});
$this->app->singleton(Worker::class, function ($app) {
/** @var Config $config */
$config = $app->make(Config::class);
return new Worker(
new QueueManager($app),
new HackyManagerForWorker($app[Factory::class]),
$app['events'],
$app[ExceptionHandling::class],
function () use ($config) {
return $config->inMaintenanceMode();
}
$app[ExceptionHandling::class]
);
});
@@ -117,17 +110,17 @@ class QueueServiceProvider extends AbstractServiceProvider
protected function registerCommands()
{
$this->app->extend('flarum.console.commands', function ($commands) {
$this->app['events']->listen(Configuring::class, function (Configuring $event) {
$queue = $this->app->make(Queue::class);
// There is no need to have the queue commands when using the sync driver.
if ($queue instanceof SyncQueue) {
return $commands;
return;
}
// Otherwise add our commands, while allowing them to be overridden by those
// already added through the container.
return array_merge($this->commands, $commands);
foreach ($this->commands as $command) {
$event->addCommand($command);
}
});
}

View File

@@ -12,7 +12,7 @@ namespace Flarum\User\Event;
use Flarum\User\User;
/**
* @deprecated beta 14, remove in beta 15.
* @deprecated beta 14, removed beta 15.
*/
class GetDisplayName
{

View File

@@ -722,7 +722,7 @@ class User extends AbstractModel
$groupIds = array_merge($groupIds, [Group::MEMBER_ID], $this->groups->pluck('id')->all());
}
/** @deprecated in beta 14, remove in beta 15 */
// Deprecated, remove in beta 14.
event(new PrepareUserGroups($this, $groupIds));
foreach (static::$groupProcessors as $processor) {

View File

@@ -51,7 +51,7 @@ class UserValidator extends AbstractValidator
],
'email' => [
'required',
'email:filter',
'email',
'unique:users,email'.$idSuffix
],
'password' => [

View File

@@ -16,7 +16,7 @@ use Flarum\Tests\integration\RetrievesAuthorizedUsers;
use Flarum\Tests\integration\TestCase;
use Flarum\User\User;
use Illuminate\Contracts\Bus\Dispatcher;
use Symfony\Component\Translation\TranslatorInterface;
use Illuminate\Contracts\Translation\Translator;
class EventTest extends TestCase
{
@@ -87,7 +87,7 @@ class CustomListener
{
protected $translator;
public function __construct(TranslatorInterface $translator)
public function __construct(Translator $translator)
{
$this->translator = $translator;
}

View File

@@ -277,7 +277,7 @@ class ModelTest extends TestCase
{
$this->extend(
(new Extend\Model(Group::class))
->default('counter', function (Group $group) {
->default('counter', function () {
static $counter = 0;
return ++$counter;

View File

@@ -4,7 +4,7 @@
<div id="drawer" class="App-drawer">
<header id="header" class="App-header">
<header id="header" class="App-header navbar-fixed-top">
<div id="header-navigation" class="Header-navigation"></div>
<div class="container">
<h1 class="Header-title">

View File

@@ -6,7 +6,7 @@
<div id="drawer" class="App-drawer">
<header id="header" class="App-header">
<header id="header" class="App-header navbar-fixed-top">
<div id="header-navigation" class="Header-navigation"></div>
<div class="container">
<h1 class="Header-title">