mirror of
https://github.com/flarum/core.git
synced 2025-08-29 11:00:12 +02:00
Compare commits
3 Commits
cw/drop-mo
...
as/cleanup
Author | SHA1 | Date | |
---|---|---|---|
|
d17cc1beee | ||
|
759eb80ff9 | ||
|
46cb32046d |
97
CHANGELOG.md
97
CHANGELOG.md
@@ -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
|
||||
|
@@ -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
14
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
16
js/dist/forum.js
vendored
16
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
27
js/package-lock.json
generated
27
js/package-lock.json
generated
@@ -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",
|
||||
|
@@ -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
2
js/shims.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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');
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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,
|
@@ -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,
|
||||
};
|
||||
|
@@ -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) {
|
||||
|
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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() {
|
||||
|
12
js/src/common/helpers/icon.js
Normal file
12
js/src/common/helpers/icon.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* The `icon` helper displays an icon.
|
||||
*
|
||||
* @param {String} fontClass The full icon class, prefix and the icon’s 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} />;
|
||||
}
|
@@ -1,13 +0,0 @@
|
||||
import * as Mithril from 'mithril';
|
||||
|
||||
/**
|
||||
* The `icon` helper displays an icon.
|
||||
*
|
||||
* @param fontClass The full icon class, prefix and the icon’s 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} />;
|
||||
}
|
@@ -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) : [];
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
17
js/src/common/models/Group.js
Normal file
17
js/src/common/models/Group.js
Normal 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;
|
@@ -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');
|
||||
}
|
15
js/src/common/models/Notification.js
Normal file
15
js/src/common/models/Notification.js
Normal 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'),
|
||||
});
|
@@ -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');
|
||||
}
|
29
js/src/common/models/Post.js
Normal file
29
js/src/common/models/Post.js
Normal 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'),
|
||||
});
|
@@ -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');
|
||||
}
|
@@ -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 });
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
@@ -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() }];
|
||||
}
|
||||
}
|
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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
|
@@ -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;
|
||||
|
@@ -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
|
||||
|
@@ -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,
|
||||
});
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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() &&
|
||||
|
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -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 (
|
||||
|
@@ -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
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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 },
|
||||
|
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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);
|
||||
|
@@ -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);
|
||||
|
@@ -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.
|
||||
|
@@ -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();
|
||||
}
|
||||
}
|
61
src/Queue/HackyManagerForWorker.php
Normal file
61
src/Queue/HackyManagerForWorker.php
Normal 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;
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -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
|
||||
{
|
||||
|
@@ -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) {
|
||||
|
@@ -51,7 +51,7 @@ class UserValidator extends AbstractValidator
|
||||
],
|
||||
'email' => [
|
||||
'required',
|
||||
'email:filter',
|
||||
'email',
|
||||
'unique:users,email'.$idSuffix
|
||||
],
|
||||
'password' => [
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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;
|
||||
|
@@ -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">
|
||||
|
@@ -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">
|
||||
|
Reference in New Issue
Block a user