mirror of
https://github.com/flarum/core.git
synced 2025-09-03 21:22:47 +02:00
Compare commits
206 Commits
v0.1.0-bet
...
post_strea
Author | SHA1 | Date | |
---|---|---|---|
|
fc5eddb99d | ||
|
bad8115a4a | ||
|
1fc76acf06 | ||
|
527d93120a | ||
|
53582ab999 | ||
|
6f6a09d7c4 | ||
|
e8394e4a1d | ||
|
e455e6c431 | ||
|
a044c642f6 | ||
|
01384139ef | ||
|
57f5ad4893 | ||
|
8b69b24272 | ||
|
09c722e522 | ||
|
3ce94757fc | ||
|
aae6f24356 | ||
|
1a2f9527fd | ||
|
8c362bf7c7 | ||
|
f99f79e3c0 | ||
|
bbd8136695 | ||
|
1d8662088f | ||
|
a850f4a6fb | ||
|
af55a13c61 | ||
|
92b62e7ab6 | ||
|
5ef4de75d1 | ||
|
88e6be9d0e | ||
|
228c7b883d | ||
|
cdcf64852e | ||
|
d20650fb42 | ||
|
875a1f70c1 | ||
|
ef206495cd | ||
|
2360745237 | ||
|
cc10eaadd2 | ||
|
c98c0b027f | ||
|
73507f403a | ||
|
d3fb5ee77c | ||
|
479e5a8cf6 | ||
|
4bce030115 | ||
|
9f2540dbe3 | ||
|
aa15db6f44 | ||
|
0c63be527b | ||
|
9db2f78939 | ||
|
9572863648 | ||
|
ac1eef7578 | ||
|
514165c3af | ||
|
e84960dcd1 | ||
|
f8d1c7a317 | ||
|
ba82969a58 | ||
|
b2917c8716 | ||
|
c150c097c1 | ||
|
beab8ce39c | ||
|
1360723c3f | ||
|
5cdfeaf9a5 | ||
|
6e1d385268 | ||
|
193f3b040d | ||
|
74cb4f9007 | ||
|
eb24e628fa | ||
|
c03feceb9f | ||
|
51008bc65d | ||
|
9a357f5d19 | ||
|
9c63c54868 | ||
|
5427b35c6d | ||
|
2ec49db6df | ||
|
062dc8f57f | ||
|
8a9e50d192 | ||
|
6c087da65f | ||
|
6bcecd623b | ||
|
614bb0d71e | ||
|
cff9b327a9 | ||
|
7af8e35a6e | ||
|
f9c9b5d5e4 | ||
|
8a73cc522e | ||
|
db83003eb5 | ||
|
4dc4dc624e | ||
|
ad42058a8a | ||
|
5e465f6051 | ||
|
62a2e8463d | ||
|
0098c64ebf | ||
|
2b5939d538 | ||
|
2431df5602 | ||
|
264ff67304 | ||
|
c08a56e9d8 | ||
|
4ee6d6fd88 | ||
|
9c09fe8465 | ||
|
b46d5e67a3 | ||
|
7fd23ff950 | ||
|
e4077ab4ad | ||
|
3b39c212e0 | ||
|
bca833d3f1 | ||
|
451a557532 | ||
|
eaac78650f | ||
|
2b3dec2be1 | ||
|
37ebeb5705 | ||
|
71abac0323 | ||
|
7e3d71a0a0 | ||
|
b5e891df30 | ||
|
3117d2ad7a | ||
|
1ce0b926b6 | ||
|
24b16f9d7c | ||
|
bd40353bcc | ||
|
455327cca1 | ||
|
20baa93ca7 | ||
|
4f34e326ef | ||
|
521cefbc2d | ||
|
dc738d68dc | ||
|
286af7084b | ||
|
4869baea74 | ||
|
24a48310ff | ||
|
bdb759c558 | ||
|
36eb5cc5fb | ||
|
d189272473 | ||
|
7d48c24dda | ||
|
5786f1a10b | ||
|
b4421e1cce | ||
|
359b4ab5a3 | ||
|
8a686911ff | ||
|
0b5a9a2fe6 | ||
|
50a9f7ce86 | ||
|
8dd5420405 | ||
|
640cc0989b | ||
|
44376cef61 | ||
|
4f181c84fc | ||
|
ea9d601338 | ||
|
aaebd3581f | ||
|
e2c416903e | ||
|
e81159249f | ||
|
d93cf4a574 | ||
|
a33fbbf814 | ||
|
0c645a6c15 | ||
|
b44b79eba9 | ||
|
93398b738b | ||
|
7816b61bfb | ||
|
7dc3a194c3 | ||
|
cea7824b57 | ||
|
088eb0c4f2 | ||
|
2ba67b021f | ||
|
92791a253d | ||
|
138c784a50 | ||
|
bb567e5278 | ||
|
cf4f2f283e | ||
|
ed01f389a8 | ||
|
71e313e677 | ||
|
88366fe8af | ||
|
b82504b4b1 | ||
|
898d68d9f3 | ||
|
69f0172b92 | ||
|
62fe9db732 | ||
|
ed566cd18f | ||
|
5c1663d8f1 | ||
|
c5d3b058ba | ||
|
4a804dbbbc | ||
|
f4afb006ed | ||
|
646b35374d | ||
|
4fc06336df | ||
|
65f2d5fb75 | ||
|
5bca4fda9d | ||
|
b87c7189cc | ||
|
17c239388a | ||
|
4da2994d1f | ||
|
293e2251ca | ||
|
3b1f5ca07b | ||
|
d1750fecc0 | ||
|
63242edeb3 | ||
|
0aed3764c4 | ||
|
7b1269207e | ||
|
bab084a75f | ||
|
3c87f800dd | ||
|
26256c436f | ||
|
63397bb466 | ||
|
4b6864534b | ||
|
c4f4f218bf | ||
|
4866e7d9ba | ||
|
d6acf28fcb | ||
|
e627616750 | ||
|
bbd815a9ab | ||
|
acf4e9c80d | ||
|
1bb5f99a27 | ||
|
b0822df759 | ||
|
998e32c208 | ||
|
f89f114fad | ||
|
9b936d4baa | ||
|
7e661df15d | ||
|
b7355db2b7 | ||
|
5dc9451c21 | ||
|
220c8c66b0 | ||
|
484933db7d | ||
|
f6347dcc46 | ||
|
107b4be726 | ||
|
93d4192b54 | ||
|
ecdce44d55 | ||
|
a5e286e662 | ||
|
443949f7b9 | ||
|
4884aad2f0 | ||
|
365eb15d29 | ||
|
85e2623622 | ||
|
7d99727168 | ||
|
84784c9839 | ||
|
a9470b463f | ||
|
deb48bd173 | ||
|
b38bd60362 | ||
|
260e7cd48f | ||
|
41a56c4ad1 | ||
|
d0ae2839f0 | ||
|
d31a747631 | ||
|
526081bd06 | ||
|
cbdd3c5cc7 | ||
|
7d1ef9d891 |
48
CHANGELOG.md
48
CHANGELOG.md
@@ -3,47 +3,55 @@
|
|||||||
## [0.1.0-beta.13](https://github.com/flarum/core/compare/v0.1.0-beta.12...v0.1.0-beta.13)
|
## [0.1.0-beta.13](https://github.com/flarum/core/compare/v0.1.0-beta.12...v0.1.0-beta.13)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Middleware extender (#2017, #2063, #2084)
|
|
||||||
- Console extender (#2057)
|
- Console extender (#2057)
|
||||||
- CSRF extender (#2095)
|
- CSRF extender (#2095)
|
||||||
- Event extender (#2097)
|
- Event extender (#2097)
|
||||||
- Mail extender (#2012)
|
- Mail extender (#2012)
|
||||||
- Model extender (#2100)
|
- Model extender (#2100)
|
||||||
- Show discussion start user as html class on post
|
- Posts by users that started a discussion now have the CSS class `.Post--by-start-user`
|
||||||
- PHPUnit 8 compatibility.
|
- PHPUnit 8 compatibility
|
||||||
- Composer 2 compatibility
|
- Composer 2 compatibility
|
||||||
- Permission groups can now be hidden (#2129)
|
- Permission groups can now be hidden (#2129)
|
||||||
- Confirmation popup when hiding or deleting posts (#2135)
|
- Confirmation popup when hiding or deleting posts (#2135)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Updated less.php dependency version to 3.0.
|
- Updated less.php dependency version to 3.0
|
||||||
- All notifications now processed through the queue (#1931)
|
|
||||||
- Updated JS dependencies
|
- Updated JS dependencies
|
||||||
|
- All notifications and other emails now processed through the queue, if enabled (#978, #1928, #1931, #2096)
|
||||||
- Simplified uploads, removing need to store intermediate files (#2117)
|
- Simplified uploads, removing need to store intermediate files (#2117)
|
||||||
- Improved date handling for dates older than 1 year (#2034)
|
- Improved date handling for dates older than 1 year (#2034)
|
||||||
- Linting and automatic formatting for JS (#2099)
|
- Linting and automatic formatting for JS (#2099)
|
||||||
- Translation files from Language Packs are only loaded for extensions that are enabled (#2020)
|
- Translation files from Language Packs are only loaded for extensions that are enabled (#2020)
|
||||||
|
- PHP extenders' properties are now `private` instead of `protected`, intentionally making it harder to extend these classes (#1958)
|
||||||
|
- Preparation for upgrading Laravel components to 5.8 and then 6.0 (#2055, #2117)
|
||||||
|
- Allowed permission checks based on model classes in addition to instances (#1977)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Users can no longer restore discussions hidden by others (#2037)
|
- Users can no longer restore discussions hidden by admins (#2037)
|
||||||
- Issues of the Modal not showing or auto hiding (#2080)
|
- Issues of the Modal not showing or auto hiding (#1504, #1813, #2080)
|
||||||
- Extensions page in admin showning columns incorrectly (#2111)
|
- Columnar layout on admin extensions page was broken in Firefox (#2029, #2111)
|
||||||
- Non dismissable modals can be dismissed using the ESC key (#1917)
|
- Non-dismissible modals could still be dismissed using the ESC key (#1917)
|
||||||
- New post injected above unread sticky (#1868)
|
- New discussions were added to the discussion list above unread sticky posts (#1751, #1868)
|
||||||
- New discussions not visible to users when using Pusher (#2077)
|
- New discussions not visible to users when using Pusher (#2076, #2077)
|
||||||
- Icons on admin permissions page (#2016, #2018)
|
- Permission icons were aligned unevenly in admin permissions list (#2016, #2018)
|
||||||
- Notification bubble contrast on mobile with colored header (#2109)
|
- Notification bubble not inversed on mobile with colored header (#1983, #2109)
|
||||||
- PostStreamScrubber click jumps back to first position (#1945)
|
- Post stream scrubber clicks jumped back to first post (#1945)
|
||||||
- Loading state of Switch toggle component is hard to see (#2039, #1491)
|
- Loading state of Switch toggle component was hard to see (#2039, #1491)
|
||||||
- Allowing permission check to use class name based gate checks (#1977)
|
- `Flarum\Extend\Middleware`: The methods `insertBefore()` and `insertAfter()` did not work as described (#2063, #2084)
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
- Support for PHP 7.1 (#2014)
|
||||||
- Zend compatibility bridge (#2010)
|
- Zend compatibility bridge (#2010)
|
||||||
- SES mail support (#2011)
|
- SES mail support (#2011)
|
||||||
- Backward compatibility dropped for mail drivers
|
- Backward compatibility layer for `Flarum\Mail\DriverInterface`, new methods from beta.12 are now required
|
||||||
- Support for PHP 7.1
|
- `Flarum\Util\Str` helper class
|
||||||
- Deprecated Flarum\Util\Str helper class
|
- `Flarum\Event\ConfigureMiddleware` event
|
||||||
- Deprecated ConfigureMiddleware event
|
|
||||||
|
### Deprecated
|
||||||
|
- `Flarum\Event\AbstractConfigureRoutes` event class
|
||||||
|
- `Flarum\Event\ConfigureApiRoutes` event class
|
||||||
|
- `Flarum\Event\ConfigureForumRoutes` event class
|
||||||
|
- `Flarum\Event\ConfigureLocales` event class
|
||||||
|
|
||||||
## [0.1.0-beta.12](https://github.com/flarum/core/compare/v0.1.0-beta.11.1...v0.1.0-beta.12)
|
## [0.1.0-beta.12](https://github.com/flarum/core/compare/v0.1.0-beta.11.1...v0.1.0-beta.12)
|
||||||
|
|
||||||
|
@@ -38,24 +38,24 @@
|
|||||||
"php": ">=7.2",
|
"php": ">=7.2",
|
||||||
"axy/sourcemap": "^0.1.4",
|
"axy/sourcemap": "^0.1.4",
|
||||||
"components/font-awesome": "5.9.*",
|
"components/font-awesome": "5.9.*",
|
||||||
"dflydev/fig-cookies": "^1.0.2",
|
"dflydev/fig-cookies": "^2.0.1",
|
||||||
"doctrine/dbal": "^2.7",
|
"doctrine/dbal": "^2.7",
|
||||||
"franzl/whoops-middleware": "^0.4.0",
|
"franzl/whoops-middleware": "^0.4.0",
|
||||||
"illuminate/bus": "5.7.*",
|
"illuminate/bus": "^6.0",
|
||||||
"illuminate/cache": "5.7.*",
|
"illuminate/cache": "^6.0",
|
||||||
"illuminate/config": "5.7.*",
|
"illuminate/config": "^6.0",
|
||||||
"illuminate/container": "5.7.*",
|
"illuminate/container": "^6.0",
|
||||||
"illuminate/contracts": "5.7.*",
|
"illuminate/contracts": "^6.0",
|
||||||
"illuminate/database": "5.7.*",
|
"illuminate/database": "^6.0",
|
||||||
"illuminate/events": "5.7.*",
|
"illuminate/events": "^6.0",
|
||||||
"illuminate/filesystem": "5.7.*",
|
"illuminate/filesystem": "^6.0",
|
||||||
"illuminate/hashing": "5.7.*",
|
"illuminate/hashing": "^6.0",
|
||||||
"illuminate/mail": "5.7.*",
|
"illuminate/mail": "^6.0",
|
||||||
"illuminate/queue": "5.7.*",
|
"illuminate/queue": "^6.0",
|
||||||
"illuminate/session": "5.7.*",
|
"illuminate/session": "^6.0",
|
||||||
"illuminate/support": "5.7.*",
|
"illuminate/support": "^6.0",
|
||||||
"illuminate/validation": "5.7.*",
|
"illuminate/validation": "^6.0",
|
||||||
"illuminate/view": "5.7.*",
|
"illuminate/view": "^6.0",
|
||||||
"intervention/image": "^2.5.0",
|
"intervention/image": "^2.5.0",
|
||||||
"laminas/laminas-diactoros": "^1.8.4",
|
"laminas/laminas-diactoros": "^1.8.4",
|
||||||
"laminas/laminas-httphandlerrunner": "^1.0",
|
"laminas/laminas-httphandlerrunner": "^1.0",
|
||||||
@@ -66,6 +66,7 @@
|
|||||||
"middlewares/base-path-router": "^0.2.1",
|
"middlewares/base-path-router": "^0.2.1",
|
||||||
"middlewares/request-handler": "^1.2",
|
"middlewares/request-handler": "^1.2",
|
||||||
"monolog/monolog": "^1.16.0",
|
"monolog/monolog": "^1.16.0",
|
||||||
|
"nesbot/carbon": "^2.0",
|
||||||
"nikic/fast-route": "^0.6",
|
"nikic/fast-route": "^0.6",
|
||||||
"psr/http-message": "^1.0",
|
"psr/http-message": "^1.0",
|
||||||
"psr/http-server-handler": "^1.0",
|
"psr/http-server-handler": "^1.0",
|
||||||
|
6
js/dist/admin.js
vendored
6
js/dist/admin.js
vendored
File diff suppressed because one or more lines are too long
2
js/dist/admin.js.map
vendored
2
js/dist/admin.js.map
vendored
File diff suppressed because one or more lines are too long
8
js/dist/forum.js
vendored
8
js/dist/forum.js
vendored
File diff suppressed because one or more lines are too long
2
js/dist/forum.js.map
vendored
2
js/dist/forum.js.map
vendored
File diff suppressed because one or more lines are too long
1038
js/package-lock.json
generated
1038
js/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,11 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"name": "@flarum/core",
|
"name": "@flarum/core",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@babel/preset-typescript": "^7.10.1",
|
||||||
"bootstrap": "^3.4.1",
|
"bootstrap": "^3.4.1",
|
||||||
"classnames": "^2.2.5",
|
"classnames": "^2.2.5",
|
||||||
"color-thief-browser": "^2.0.2",
|
"color-thief-browser": "^2.0.2",
|
||||||
|
"dayjs": "^1.8.28",
|
||||||
"expose-loader": "^0.7.5",
|
"expose-loader": "^0.7.5",
|
||||||
"flarum-webpack-config": "0.1.0-beta.10",
|
"flarum-webpack-config": "0.1.0-beta.10",
|
||||||
"jquery": "^3.4.1",
|
"jquery": "^3.4.1",
|
||||||
@@ -12,11 +14,10 @@
|
|||||||
"lodash-es": "^4.17.14",
|
"lodash-es": "^4.17.14",
|
||||||
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",
|
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",
|
||||||
"mithril": "^0.2.8",
|
"mithril": "^0.2.8",
|
||||||
"moment": "^2.22.2",
|
|
||||||
"punycode": "^2.1.1",
|
"punycode": "^2.1.1",
|
||||||
"spin.js": "^3.1.0",
|
"spin.js": "^3.1.0",
|
||||||
"webpack": "^4.41.2",
|
"webpack": "^4.43.0",
|
||||||
"webpack-cli": "^3.1.2",
|
"webpack-cli": "^3.3.11",
|
||||||
"webpack-merge": "^4.1.4"
|
"webpack-merge": "^4.1.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@@ -6,7 +6,6 @@ import EditCustomFooterModal from './components/EditCustomFooterModal';
|
|||||||
import SessionDropdown from './components/SessionDropdown';
|
import SessionDropdown from './components/SessionDropdown';
|
||||||
import HeaderPrimary from './components/HeaderPrimary';
|
import HeaderPrimary from './components/HeaderPrimary';
|
||||||
import AppearancePage from './components/AppearancePage';
|
import AppearancePage from './components/AppearancePage';
|
||||||
import Page from './components/Page';
|
|
||||||
import StatusWidget from './components/StatusWidget';
|
import StatusWidget from './components/StatusWidget';
|
||||||
import HeaderSecondary from './components/HeaderSecondary';
|
import HeaderSecondary from './components/HeaderSecondary';
|
||||||
import SettingsModal from './components/SettingsModal';
|
import SettingsModal from './components/SettingsModal';
|
||||||
@@ -15,7 +14,6 @@ import AddExtensionModal from './components/AddExtensionModal';
|
|||||||
import ExtensionsPage from './components/ExtensionsPage';
|
import ExtensionsPage from './components/ExtensionsPage';
|
||||||
import AdminLinkButton from './components/AdminLinkButton';
|
import AdminLinkButton from './components/AdminLinkButton';
|
||||||
import PermissionGrid from './components/PermissionGrid';
|
import PermissionGrid from './components/PermissionGrid';
|
||||||
import Widget from './components/Widget';
|
|
||||||
import MailPage from './components/MailPage';
|
import MailPage from './components/MailPage';
|
||||||
import UploadImageButton from './components/UploadImageButton';
|
import UploadImageButton from './components/UploadImageButton';
|
||||||
import LoadingModal from './components/LoadingModal';
|
import LoadingModal from './components/LoadingModal';
|
||||||
@@ -37,7 +35,6 @@ export default Object.assign(compat, {
|
|||||||
'components/SessionDropdown': SessionDropdown,
|
'components/SessionDropdown': SessionDropdown,
|
||||||
'components/HeaderPrimary': HeaderPrimary,
|
'components/HeaderPrimary': HeaderPrimary,
|
||||||
'components/AppearancePage': AppearancePage,
|
'components/AppearancePage': AppearancePage,
|
||||||
'components/Page': Page,
|
|
||||||
'components/StatusWidget': StatusWidget,
|
'components/StatusWidget': StatusWidget,
|
||||||
'components/HeaderSecondary': HeaderSecondary,
|
'components/HeaderSecondary': HeaderSecondary,
|
||||||
'components/SettingsModal': SettingsModal,
|
'components/SettingsModal': SettingsModal,
|
||||||
@@ -46,7 +43,6 @@ export default Object.assign(compat, {
|
|||||||
'components/ExtensionsPage': ExtensionsPage,
|
'components/ExtensionsPage': ExtensionsPage,
|
||||||
'components/AdminLinkButton': AdminLinkButton,
|
'components/AdminLinkButton': AdminLinkButton,
|
||||||
'components/PermissionGrid': PermissionGrid,
|
'components/PermissionGrid': PermissionGrid,
|
||||||
'components/Widget': Widget,
|
|
||||||
'components/MailPage': MailPage,
|
'components/MailPage': MailPage,
|
||||||
'components/UploadImageButton': UploadImageButton,
|
'components/UploadImageButton': UploadImageButton,
|
||||||
'components/LoadingModal': LoadingModal,
|
'components/LoadingModal': LoadingModal,
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import Page from './Page';
|
import Page from '../../common/components/Page';
|
||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
import Switch from '../../common/components/Switch';
|
import Switch from '../../common/components/Switch';
|
||||||
import EditCustomCssModal from './EditCustomCssModal';
|
import EditCustomCssModal from './EditCustomCssModal';
|
||||||
@@ -13,8 +13,8 @@ export default class AppearancePage extends Page {
|
|||||||
|
|
||||||
this.primaryColor = m.prop(app.data.settings.theme_primary_color);
|
this.primaryColor = m.prop(app.data.settings.theme_primary_color);
|
||||||
this.secondaryColor = m.prop(app.data.settings.theme_secondary_color);
|
this.secondaryColor = m.prop(app.data.settings.theme_secondary_color);
|
||||||
this.darkMode = m.prop(app.data.settings.theme_dark_mode === '1');
|
this.darkMode = m.prop(app.data.settings.theme_dark_mode);
|
||||||
this.coloredHeader = m.prop(app.data.settings.theme_colored_header === '1');
|
this.coloredHeader = m.prop(app.data.settings.theme_colored_header);
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
@@ -82,7 +82,7 @@ export default class AppearancePage extends Page {
|
|||||||
{Button.component({
|
{Button.component({
|
||||||
className: 'Button',
|
className: 'Button',
|
||||||
children: app.translator.trans('core.admin.appearance.edit_header_button'),
|
children: app.translator.trans('core.admin.appearance.edit_header_button'),
|
||||||
onclick: () => app.modal.show(new EditCustomHeaderModal()),
|
onclick: () => app.modal.show(EditCustomHeaderModal),
|
||||||
})}
|
})}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ export default class AppearancePage extends Page {
|
|||||||
{Button.component({
|
{Button.component({
|
||||||
className: 'Button',
|
className: 'Button',
|
||||||
children: app.translator.trans('core.admin.appearance.edit_footer_button'),
|
children: app.translator.trans('core.admin.appearance.edit_footer_button'),
|
||||||
onclick: () => app.modal.show(new EditCustomFooterModal()),
|
onclick: () => app.modal.show(EditCustomFooterModal),
|
||||||
})}
|
})}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ export default class AppearancePage extends Page {
|
|||||||
{Button.component({
|
{Button.component({
|
||||||
className: 'Button',
|
className: 'Button',
|
||||||
children: app.translator.trans('core.admin.appearance.edit_css_button'),
|
children: app.translator.trans('core.admin.appearance.edit_css_button'),
|
||||||
onclick: () => app.modal.show(new EditCustomCssModal()),
|
onclick: () => app.modal.show(EditCustomCssModal),
|
||||||
})}
|
})}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,8 +1,7 @@
|
|||||||
import Page from './Page';
|
import Page from '../../common/components/Page';
|
||||||
import FieldSet from '../../common/components/FieldSet';
|
import FieldSet from '../../common/components/FieldSet';
|
||||||
import Select from '../../common/components/Select';
|
import Select from '../../common/components/Select';
|
||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
import Alert from '../../common/components/Alert';
|
|
||||||
import saveSettings from '../utils/saveSettings';
|
import saveSettings from '../utils/saveSettings';
|
||||||
import ItemList from '../../common/utils/ItemList';
|
import ItemList from '../../common/utils/ItemList';
|
||||||
import Switch from '../../common/components/Switch';
|
import Switch from '../../common/components/Switch';
|
||||||
@@ -21,6 +20,7 @@ export default class BasicsPage extends Page {
|
|||||||
'default_route',
|
'default_route',
|
||||||
'welcome_title',
|
'welcome_title',
|
||||||
'welcome_message',
|
'welcome_message',
|
||||||
|
'display_name_driver',
|
||||||
];
|
];
|
||||||
this.values = {};
|
this.values = {};
|
||||||
|
|
||||||
@@ -33,6 +33,14 @@ export default class BasicsPage extends Page {
|
|||||||
this.localeOptions[i] = `${locales[i]} (${i})`;
|
this.localeOptions[i] = `${locales[i]} (${i})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.displayNameOptions = {};
|
||||||
|
const displayNameDrivers = app.data.displayNameDrivers;
|
||||||
|
displayNameDrivers.forEach(function (identifier) {
|
||||||
|
this.displayNameOptions[identifier] = identifier;
|
||||||
|
}, this);
|
||||||
|
|
||||||
|
if (!this.values.display_name_driver() && displayNameDrivers.includes('username')) this.values.display_name_driver('username');
|
||||||
|
|
||||||
if (typeof this.values.show_language_selector() !== 'number') this.values.show_language_selector(1);
|
if (typeof this.values.show_language_selector() !== 'number') this.values.show_language_selector(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +122,20 @@ export default class BasicsPage extends Page {
|
|||||||
],
|
],
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{Object.keys(this.displayNameOptions).length > 1
|
||||||
|
? FieldSet.component({
|
||||||
|
label: app.translator.trans('core.admin.basics.display_name_heading'),
|
||||||
|
children: [
|
||||||
|
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>,
|
||||||
|
Select.component({
|
||||||
|
options: this.displayNameOptions,
|
||||||
|
value: this.values.display_name_driver(),
|
||||||
|
onchange: this.values.display_name_driver,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
: ''}
|
||||||
|
|
||||||
{Button.component({
|
{Button.component({
|
||||||
type: 'submit',
|
type: 'submit',
|
||||||
className: 'Button Button--primary',
|
className: 'Button Button--primary',
|
||||||
@@ -163,7 +185,10 @@ export default class BasicsPage extends Page {
|
|||||||
|
|
||||||
saveSettings(settings)
|
saveSettings(settings)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
app.alerts.show((this.successAlert = new Alert({ type: 'success', children: app.translator.trans('core.admin.basics.saved_message') })));
|
this.successAlert = app.alerts.show({
|
||||||
|
type: 'success',
|
||||||
|
children: app.translator.trans('core.admin.basics.saved_message'),
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import Page from './Page';
|
import Page from '../../common/components/Page';
|
||||||
import StatusWidget from './StatusWidget';
|
import StatusWidget from './StatusWidget';
|
||||||
|
|
||||||
export default class DashboardPage extends Page {
|
export default class DashboardPage extends Page {
|
||||||
|
@@ -1,17 +1,8 @@
|
|||||||
/*
|
|
||||||
* This file is part of Flarum.
|
|
||||||
*
|
|
||||||
* (c) Toby Zerner <toby.zerner@gmail.com>
|
|
||||||
*
|
|
||||||
* For the full copyright and license information, please view the LICENSE
|
|
||||||
* file that was distributed with this source code.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Component from '../../common/Component';
|
import Component from '../../common/Component';
|
||||||
|
|
||||||
export default class Widget extends Component {
|
export default class DashboardWidget extends Component {
|
||||||
view() {
|
view() {
|
||||||
return <div className={'Widget ' + this.className()}>{this.content()}</div>;
|
return <div className={'DashboardWidget Widget ' + this.className()}>{this.content()}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -1,13 +1,10 @@
|
|||||||
import Page from './Page';
|
import Page from '../../common/components/Page';
|
||||||
import LinkButton from '../../common/components/LinkButton';
|
|
||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
import Dropdown from '../../common/components/Dropdown';
|
import Dropdown from '../../common/components/Dropdown';
|
||||||
import Separator from '../../common/components/Separator';
|
|
||||||
import AddExtensionModal from './AddExtensionModal';
|
import AddExtensionModal from './AddExtensionModal';
|
||||||
import LoadingModal from './LoadingModal';
|
import LoadingModal from './LoadingModal';
|
||||||
import ItemList from '../../common/utils/ItemList';
|
import ItemList from '../../common/utils/ItemList';
|
||||||
import icon from '../../common/helpers/icon';
|
import icon from '../../common/helpers/icon';
|
||||||
import listItems from '../../common/helpers/listItems';
|
|
||||||
|
|
||||||
export default class ExtensionsPage extends Page {
|
export default class ExtensionsPage extends Page {
|
||||||
view() {
|
view() {
|
||||||
@@ -19,7 +16,7 @@ export default class ExtensionsPage extends Page {
|
|||||||
children: app.translator.trans('core.admin.extensions.add_button'),
|
children: app.translator.trans('core.admin.extensions.add_button'),
|
||||||
icon: 'fas fa-plus',
|
icon: 'fas fa-plus',
|
||||||
className: 'Button Button--primary',
|
className: 'Button Button--primary',
|
||||||
onclick: () => app.modal.show(new AddExtensionModal()),
|
onclick: () => app.modal.show(AddExtensionModal),
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,7 +94,7 @@ export default class ExtensionsPage extends Page {
|
|||||||
})
|
})
|
||||||
.then(() => window.location.reload());
|
.then(() => window.location.reload());
|
||||||
|
|
||||||
app.modal.show(new LoadingModal());
|
app.modal.show(LoadingModal);
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -126,6 +123,6 @@ export default class ExtensionsPage extends Page {
|
|||||||
window.location.reload();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.modal.show(new LoadingModal());
|
app.modal.show(LoadingModal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,9 +1,10 @@
|
|||||||
import Modal from '../../common/components/Modal';
|
import Modal from '../../common/components/Modal';
|
||||||
|
|
||||||
export default class LoadingModal extends Modal {
|
export default class LoadingModal extends Modal {
|
||||||
isDismissible() {
|
/**
|
||||||
return false;
|
* @inheritdoc
|
||||||
}
|
*/
|
||||||
|
static isDismissible = false;
|
||||||
|
|
||||||
className() {
|
className() {
|
||||||
return 'LoadingModal Modal--small';
|
return 'LoadingModal Modal--small';
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import Page from './Page';
|
import Page from '../../common/components/Page';
|
||||||
import FieldSet from '../../common/components/FieldSet';
|
import FieldSet from '../../common/components/FieldSet';
|
||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
import Alert from '../../common/components/Alert';
|
import Alert from '../../common/components/Alert';
|
||||||
@@ -11,6 +11,7 @@ export default class MailPage extends Page {
|
|||||||
super.init();
|
super.init();
|
||||||
|
|
||||||
this.saving = false;
|
this.saving = false;
|
||||||
|
this.sendingTest = false;
|
||||||
this.refresh();
|
this.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ export default class MailPage extends Page {
|
|||||||
app
|
app
|
||||||
.request({
|
.request({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: app.forum.attribute('apiUrl') + '/mail-settings',
|
url: app.forum.attribute('apiUrl') + '/mail/settings',
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
this.driverFields = response['data']['attributes']['fields'];
|
this.driverFields = response['data']['attributes']['fields'];
|
||||||
@@ -121,12 +122,28 @@ export default class MailPage extends Page {
|
|||||||
],
|
],
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
<FieldSet>
|
||||||
{Button.component({
|
{Button.component({
|
||||||
type: 'submit',
|
type: 'submit',
|
||||||
className: 'Button Button--primary',
|
className: 'Button Button--primary',
|
||||||
children: app.translator.trans('core.admin.email.submit_button'),
|
children: app.translator.trans('core.admin.email.submit_button'),
|
||||||
disabled: !this.changed(),
|
disabled: !this.changed(),
|
||||||
})}
|
})}
|
||||||
|
</FieldSet>
|
||||||
|
|
||||||
|
{FieldSet.component({
|
||||||
|
label: app.translator.trans('core.admin.email.send_test_mail_heading'),
|
||||||
|
className: 'MailPage-MailSettings',
|
||||||
|
children: [
|
||||||
|
<div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user.email() })}</div>,
|
||||||
|
Button.component({
|
||||||
|
className: 'Button Button--primary',
|
||||||
|
children: app.translator.trans('core.admin.email.send_test_mail_button'),
|
||||||
|
disabled: this.sendingTest || this.changed(),
|
||||||
|
onclick: () => this.sendTestEmail(),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,10 +166,35 @@ export default class MailPage extends Page {
|
|||||||
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
|
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendTestEmail() {
|
||||||
|
if (this.saving || this.sendingTest) return;
|
||||||
|
|
||||||
|
this.sendingTest = true;
|
||||||
|
app.alerts.dismiss(this.testEmailSuccessAlert);
|
||||||
|
|
||||||
|
app
|
||||||
|
.request({
|
||||||
|
method: 'POST',
|
||||||
|
url: app.forum.attribute('apiUrl') + '/mail/test',
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
this.sendingTest = false;
|
||||||
|
this.testEmailSuccessAlert = app.alerts.show({
|
||||||
|
type: 'success',
|
||||||
|
children: app.translator.trans('core.admin.email.send_test_mail_success'),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.sendingTest = false;
|
||||||
|
m.redraw();
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onsubmit(e) {
|
onsubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (this.saving) return;
|
if (this.saving || this.sendingTest) return;
|
||||||
|
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
app.alerts.dismiss(this.successAlert);
|
app.alerts.dismiss(this.successAlert);
|
||||||
@@ -163,7 +205,10 @@ export default class MailPage extends Page {
|
|||||||
|
|
||||||
saveSettings(settings)
|
saveSettings(settings)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
app.alerts.show((this.successAlert = new Alert({ type: 'success', children: app.translator.trans('core.admin.basics.saved_message') })));
|
this.successAlert = app.alerts.show({
|
||||||
|
type: 'success',
|
||||||
|
children: app.translator.trans('core.admin.basics.saved_message'),
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
@@ -1,32 +0,0 @@
|
|||||||
import Component from '../../common/Component';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `Page` component
|
|
||||||
*
|
|
||||||
* @abstract
|
|
||||||
*/
|
|
||||||
export default class Page extends Component {
|
|
||||||
init() {
|
|
||||||
app.previous = app.current;
|
|
||||||
app.current = this;
|
|
||||||
|
|
||||||
app.modal.close();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A class name to apply to the body while the route is active.
|
|
||||||
*
|
|
||||||
* @type {String}
|
|
||||||
*/
|
|
||||||
this.bodyClass = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
config(isInitialized, context) {
|
|
||||||
if (isInitialized) return;
|
|
||||||
|
|
||||||
if (this.bodyClass) {
|
|
||||||
$('#app').addClass(this.bodyClass);
|
|
||||||
|
|
||||||
context.onunload = () => $('#app').removeClass(this.bodyClass);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,4 +1,4 @@
|
|||||||
import Page from './Page';
|
import Page from '../../common/components/Page';
|
||||||
import GroupBadge from '../../common/components/GroupBadge';
|
import GroupBadge from '../../common/components/GroupBadge';
|
||||||
import EditGroupModal from './EditGroupModal';
|
import EditGroupModal from './EditGroupModal';
|
||||||
import Group from '../../common/models/Group';
|
import Group from '../../common/models/Group';
|
||||||
@@ -15,7 +15,7 @@ export default class PermissionsPage extends Page {
|
|||||||
.all('groups')
|
.all('groups')
|
||||||
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
||||||
.map((group) => (
|
.map((group) => (
|
||||||
<button className="Button Group" onclick={() => app.modal.show(new EditGroupModal({ group }))}>
|
<button className="Button Group" onclick={() => app.modal.show(EditGroupModal, { group })}>
|
||||||
{GroupBadge.component({
|
{GroupBadge.component({
|
||||||
group,
|
group,
|
||||||
className: 'Group-icon',
|
className: 'Group-icon',
|
||||||
@@ -24,7 +24,7 @@ export default class PermissionsPage extends Page {
|
|||||||
<span className="Group-name">{group.namePlural()}</span>
|
<span className="Group-name">{group.namePlural()}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button className="Button Group Group--add" onclick={() => app.modal.show(new EditGroupModal())}>
|
<button className="Button Group Group--add" onclick={() => app.modal.show(EditGroupModal)}>
|
||||||
{icon('fas fa-plus', { className: 'Group-icon' })}
|
{icon('fas fa-plus', { className: 'Group-icon' })}
|
||||||
<span className="Group-name">{app.translator.trans('core.admin.permissions.new_group_button')}</span>
|
<span className="Group-name">{app.translator.trans('core.admin.permissions.new_group_button')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
@@ -46,7 +46,7 @@ export default class StatusWidget extends DashboardWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleClearCache(e) {
|
handleClearCache(e) {
|
||||||
app.modal.show(new LoadingModal());
|
app.modal.show(LoadingModal);
|
||||||
|
|
||||||
app
|
app
|
||||||
.request({
|
.request({
|
||||||
|
@@ -1,34 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of Flarum.
|
|
||||||
*
|
|
||||||
* (c) Toby Zerner <toby.zerner@gmail.com>
|
|
||||||
*
|
|
||||||
* For the full copyright and license information, please view the LICENSE
|
|
||||||
* file that was distributed with this source code.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Component from '../../common/Component';
|
|
||||||
|
|
||||||
export default class DashboardWidget extends Component {
|
|
||||||
view() {
|
|
||||||
return <div className={'DashboardWidget ' + this.className()}>{this.content()}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the class name to apply to the widget.
|
|
||||||
*
|
|
||||||
* @return {String}
|
|
||||||
*/
|
|
||||||
className() {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the content of the widget.
|
|
||||||
*
|
|
||||||
* @return {VirtualElement}
|
|
||||||
*/
|
|
||||||
content() {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,5 +1,4 @@
|
|||||||
import ItemList from './utils/ItemList';
|
import ItemList from './utils/ItemList';
|
||||||
import Alert from './components/Alert';
|
|
||||||
import Button from './components/Button';
|
import Button from './components/Button';
|
||||||
import ModalManager from './components/ModalManager';
|
import ModalManager from './components/ModalManager';
|
||||||
import AlertManager from './components/AlertManager';
|
import AlertManager from './components/AlertManager';
|
||||||
@@ -12,6 +11,7 @@ import Drawer from './utils/Drawer';
|
|||||||
import mapRoutes from './utils/mapRoutes';
|
import mapRoutes from './utils/mapRoutes';
|
||||||
import RequestError from './utils/RequestError';
|
import RequestError from './utils/RequestError';
|
||||||
import ScrollListener from './utils/ScrollListener';
|
import ScrollListener from './utils/ScrollListener';
|
||||||
|
import liveHumanTimes from './utils/liveHumanTimes';
|
||||||
import { extend } from './extend';
|
import { extend } from './extend';
|
||||||
|
|
||||||
import Forum from './models/Forum';
|
import Forum from './models/Forum';
|
||||||
@@ -21,6 +21,9 @@ import Post from './models/Post';
|
|||||||
import Group from './models/Group';
|
import Group from './models/Group';
|
||||||
import Notification from './models/Notification';
|
import Notification from './models/Notification';
|
||||||
import { flattenDeep } from 'lodash-es';
|
import { flattenDeep } from 'lodash-es';
|
||||||
|
import PageState from './states/PageState';
|
||||||
|
import ModalManagerState from './states/ModalManagerState';
|
||||||
|
import AlertManagerState from './states/AlertManagerState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `App` class provides a container for an application, as well as various
|
* The `App` class provides a container for an application, as well as various
|
||||||
@@ -107,13 +110,49 @@ export default class Application {
|
|||||||
booted = false;
|
booted = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An Alert that was shown as a result of an AJAX request error. If present,
|
* The key for an Alert that was shown as a result of an AJAX request error.
|
||||||
* it will be dismissed on the next successful request.
|
* If present, it will be dismissed on the next successful request.
|
||||||
*
|
*
|
||||||
* @type {null|Alert}
|
* @type {int}
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
requestError = null;
|
requestErrorAlert = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page the app is currently on.
|
||||||
|
*
|
||||||
|
* This object holds information about the type of page we are currently
|
||||||
|
* visiting, and sometimes additional arbitrary page state that may be
|
||||||
|
* relevant to lower-level components.
|
||||||
|
*
|
||||||
|
* @type {PageState}
|
||||||
|
*/
|
||||||
|
current = new PageState(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page the app was on before the current page.
|
||||||
|
*
|
||||||
|
* Once the application navigates to another page, the object previously
|
||||||
|
* assigned to this.current will be moved to this.previous, while this.current
|
||||||
|
* is re-initialized.
|
||||||
|
*
|
||||||
|
* @type {PageState}
|
||||||
|
*/
|
||||||
|
previous = new PageState(null);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* An object that manages modal state.
|
||||||
|
*
|
||||||
|
* @type {ModalManagerState}
|
||||||
|
*/
|
||||||
|
modal = new ModalManagerState();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object that manages the state of active alerts.
|
||||||
|
*
|
||||||
|
* @type {AlertManagerState}
|
||||||
|
*/
|
||||||
|
alerts = new AlertManagerState();
|
||||||
|
|
||||||
data;
|
data;
|
||||||
|
|
||||||
@@ -150,8 +189,8 @@ export default class Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mount(basePath = '') {
|
mount(basePath = '') {
|
||||||
this.modal = m.mount(document.getElementById('modal'), <ModalManager />);
|
m.mount(document.getElementById('modal'), <ModalManager state={this.modal} />);
|
||||||
this.alerts = m.mount(document.getElementById('alerts'), <AlertManager />);
|
m.mount(document.getElementById('alerts'), <AlertManager state={this.alerts} />);
|
||||||
|
|
||||||
this.drawer = new Drawer();
|
this.drawer = new Drawer();
|
||||||
|
|
||||||
@@ -169,6 +208,8 @@ export default class Application {
|
|||||||
$(() => {
|
$(() => {
|
||||||
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
|
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
liveHumanTimes();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -189,6 +230,16 @@ export default class Application {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the current screen mode, based on our media queries.
|
||||||
|
*
|
||||||
|
* @returns {String} - one of "phone", "tablet", "desktop" or "desktop-hd"
|
||||||
|
*/
|
||||||
|
screen() {
|
||||||
|
const styles = getComputedStyle(document.documentElement);
|
||||||
|
return styles.getPropertyValue('--flarum-screen');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the <title> of the page.
|
* Set the <title> of the page.
|
||||||
*
|
*
|
||||||
@@ -211,7 +262,10 @@ export default class Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateTitle() {
|
updateTitle() {
|
||||||
document.title = (this.titleCount ? `(${this.titleCount}) ` : '') + (this.title ? this.title + ' - ' : '') + this.forum.attribute('title');
|
const count = this.titleCount ? `(${this.titleCount}) ` : '';
|
||||||
|
const pageTitleWithSeparator = this.title && m.route() !== '/' ? this.title + ' - ' : '';
|
||||||
|
const title = this.forum.attribute('title');
|
||||||
|
document.title = count + pageTitleWithSeparator + title;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -284,7 +338,7 @@ export default class Application {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.requestError) this.alerts.dismiss(this.requestError.alert);
|
if (this.requestErrorAlert) this.alerts.dismiss(this.requestErrorAlert);
|
||||||
|
|
||||||
// Now make the request. If it's a failure, inspect the error that was
|
// Now make the request. If it's a failure, inspect the error that was
|
||||||
// returned and show an alert containing its contents.
|
// returned and show an alert containing its contents.
|
||||||
@@ -293,8 +347,6 @@ export default class Application {
|
|||||||
m.request(options).then(
|
m.request(options).then(
|
||||||
(response) => deferred.resolve(response),
|
(response) => deferred.resolve(response),
|
||||||
(error) => {
|
(error) => {
|
||||||
this.requestError = error;
|
|
||||||
|
|
||||||
let children;
|
let children;
|
||||||
|
|
||||||
switch (error.status) {
|
switch (error.status) {
|
||||||
@@ -324,21 +376,35 @@ export default class Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isDebug = app.forum.attribute('debug');
|
const isDebug = app.forum.attribute('debug');
|
||||||
|
// contains a formatted errors if possible, response must be an JSON API array of errors
|
||||||
|
// the details property is decoded to transform escaped characters such as '\n'
|
||||||
|
const formattedError = error.response && Array.isArray(error.response.errors) && error.response.errors.map((e) => decodeURI(e.detail));
|
||||||
|
|
||||||
error.alert = new Alert({
|
error.alert = {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
children,
|
children,
|
||||||
controls: isDebug && [
|
controls: isDebug && [
|
||||||
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error)}>
|
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error, formattedError)}>
|
||||||
Debug
|
Debug
|
||||||
</Button>,
|
</Button>,
|
||||||
],
|
],
|
||||||
});
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
options.errorHandler(error);
|
options.errorHandler(error);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.alerts.show(error.alert);
|
if (isDebug && error.xhr) {
|
||||||
|
const { method, url } = error.options;
|
||||||
|
const { status = '' } = error.xhr;
|
||||||
|
|
||||||
|
console.group(`${method} ${url} ${status}`);
|
||||||
|
|
||||||
|
console.error(...(formattedError || [error]));
|
||||||
|
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.requestErrorAlert = this.alerts.show(error.alert);
|
||||||
}
|
}
|
||||||
|
|
||||||
deferred.reject(error);
|
deferred.reject(error);
|
||||||
@@ -350,12 +416,13 @@ export default class Application {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {RequestError} error
|
* @param {RequestError} error
|
||||||
|
* @param {string[]} [formattedError]
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
showDebug(error) {
|
showDebug(error, formattedError) {
|
||||||
this.alerts.dismiss(this.requestError.alert);
|
this.alerts.dismiss(this.requestErrorAlert);
|
||||||
|
|
||||||
this.modal.show(new RequestErrorModal({ error }));
|
this.modal.show(RequestErrorModal, { error, formattedError });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -30,6 +30,7 @@ import Forum from './models/Forum';
|
|||||||
import Component from './Component';
|
import Component from './Component';
|
||||||
import Translator from './Translator';
|
import Translator from './Translator';
|
||||||
import AlertManager from './components/AlertManager';
|
import AlertManager from './components/AlertManager';
|
||||||
|
import Page from './components/Page';
|
||||||
import Switch from './components/Switch';
|
import Switch from './components/Switch';
|
||||||
import Badge from './components/Badge';
|
import Badge from './components/Badge';
|
||||||
import LoadingIndicator from './components/LoadingIndicator';
|
import LoadingIndicator from './components/LoadingIndicator';
|
||||||
@@ -94,6 +95,7 @@ export default {
|
|||||||
Component: Component,
|
Component: Component,
|
||||||
Translator: Translator,
|
Translator: Translator,
|
||||||
'components/AlertManager': AlertManager,
|
'components/AlertManager': AlertManager,
|
||||||
|
'components/Page': Page,
|
||||||
'components/Switch': Switch,
|
'components/Switch': Switch,
|
||||||
'components/Badge': Badge,
|
'components/Badge': Badge,
|
||||||
'components/LoadingIndicator': LoadingIndicator,
|
'components/LoadingIndicator': LoadingIndicator,
|
||||||
|
@@ -7,20 +7,16 @@ import Alert from './Alert';
|
|||||||
*/
|
*/
|
||||||
export default class AlertManager extends Component {
|
export default class AlertManager extends Component {
|
||||||
init() {
|
init() {
|
||||||
/**
|
this.state = this.props.state;
|
||||||
* An array of Alert components which are currently showing.
|
|
||||||
*
|
|
||||||
* @type {Alert[]}
|
|
||||||
* @protected
|
|
||||||
*/
|
|
||||||
this.components = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
return (
|
return (
|
||||||
<div className="AlertManager">
|
<div className="AlertManager">
|
||||||
{this.components.map((component) => (
|
{Object.entries(this.state.getActiveAlerts()).map(([key, alert]) => (
|
||||||
<div className="AlertManager-alert">{component}</div>
|
<div className="AlertManager-alert">
|
||||||
|
{(alert.componentClass || Alert).component({ ...alert.attrs, ondismiss: this.state.dismiss.bind(this.state, key) })}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -32,46 +28,4 @@ export default class AlertManager extends Component {
|
|||||||
// to be retained across route changes.
|
// to be retained across route changes.
|
||||||
context.retain = true;
|
context.retain = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Show an Alert in the alerts area.
|
|
||||||
*
|
|
||||||
* @param {Alert} component
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
show(component) {
|
|
||||||
if (!(component instanceof Alert)) {
|
|
||||||
throw new Error('The AlertManager component can only show Alert components');
|
|
||||||
}
|
|
||||||
|
|
||||||
component.props.ondismiss = this.dismiss.bind(this, component);
|
|
||||||
|
|
||||||
this.components.push(component);
|
|
||||||
m.redraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dismiss an alert.
|
|
||||||
*
|
|
||||||
* @param {Alert} component
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
dismiss(component) {
|
|
||||||
const index = this.components.indexOf(component);
|
|
||||||
|
|
||||||
if (index !== -1) {
|
|
||||||
this.components.splice(index, 1);
|
|
||||||
m.redraw();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all alerts.
|
|
||||||
*
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
clear() {
|
|
||||||
this.components = [];
|
|
||||||
m.redraw();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -30,6 +30,6 @@ export default class Badge extends Component {
|
|||||||
config(isInitialized) {
|
config(isInitialized) {
|
||||||
if (isInitialized) return;
|
if (isInitialized) return;
|
||||||
|
|
||||||
if (this.props.label) this.$().tooltip({ container: 'body' });
|
if (this.props.label) this.$().tooltip();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -10,23 +10,17 @@ import icon from '../helpers/icon';
|
|||||||
* - `state` Whether or not the checkbox is checked.
|
* - `state` Whether or not the checkbox is checked.
|
||||||
* - `className` The class name for the root element.
|
* - `className` The class name for the root element.
|
||||||
* - `disabled` Whether or not the checkbox is disabled.
|
* - `disabled` Whether or not the checkbox is disabled.
|
||||||
|
* - `loading` Whether or not the checkbox is loading.
|
||||||
* - `onchange` A callback to run when the checkbox is checked/unchecked.
|
* - `onchange` A callback to run when the checkbox is checked/unchecked.
|
||||||
* - `children` A text label to display next to the checkbox.
|
* - `children` A text label to display next to the checkbox.
|
||||||
*/
|
*/
|
||||||
export default class Checkbox extends Component {
|
export default class Checkbox extends Component {
|
||||||
init() {
|
|
||||||
/**
|
|
||||||
* Whether or not the checkbox's value is in the process of being saved.
|
|
||||||
*
|
|
||||||
* @type {Boolean}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
|
// Sometimes, false is stored in the DB as '0'. This is a temporary
|
||||||
|
// conversion layer until a more robust settings encoding is introduced
|
||||||
|
if (this.props.state === '0') this.props.state = false;
|
||||||
let className = 'Checkbox ' + (this.props.state ? 'on' : 'off') + ' ' + (this.props.className || '');
|
let className = 'Checkbox ' + (this.props.state ? 'on' : 'off') + ' ' + (this.props.className || '');
|
||||||
if (this.loading) className += ' loading';
|
if (this.props.loading) className += ' loading';
|
||||||
if (this.props.disabled) className += ' disabled';
|
if (this.props.disabled) className += ' disabled';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -45,7 +39,7 @@ export default class Checkbox extends Component {
|
|||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
getDisplay() {
|
getDisplay() {
|
||||||
return this.loading ? LoadingIndicator.component({ size: 'tiny' }) : icon(this.props.state ? 'fas fa-check' : 'fas fa-times');
|
return this.props.loading ? LoadingIndicator.component({ size: 'tiny' }) : icon(this.props.state ? 'fas fa-check' : 'fas fa-times');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
37
js/src/common/components/ConfirmDocumentUnload.js
Normal file
37
js/src/common/components/ConfirmDocumentUnload.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import Component from '../Component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `ConfirmDocumentUnload` component can be used to register a global
|
||||||
|
* event handler that prevents closing the browser window/tab based on the
|
||||||
|
* return value of a given callback prop.
|
||||||
|
*
|
||||||
|
* ### Props
|
||||||
|
*
|
||||||
|
* - `when` - a callback returning true when the browser should prompt for
|
||||||
|
* confirmation before closing the window/tab
|
||||||
|
*
|
||||||
|
* ### Children
|
||||||
|
*
|
||||||
|
* NOTE: Only the first child will be rendered. (Use this component to wrap
|
||||||
|
* another component / DOM element.)
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export default class ConfirmDocumentUnload extends Component {
|
||||||
|
config(isInitialized, context) {
|
||||||
|
if (isInitialized) return;
|
||||||
|
|
||||||
|
const handler = () => this.props.when() || undefined;
|
||||||
|
|
||||||
|
$(window).on('beforeunload', handler);
|
||||||
|
|
||||||
|
context.onunload = () => {
|
||||||
|
$(window).off('beforeunload', handler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
view() {
|
||||||
|
// To avoid having to render another wrapping <div> here, we assume that
|
||||||
|
// this component is only wrapped around a single element / component.
|
||||||
|
return this.props.children[0];
|
||||||
|
}
|
||||||
|
}
|
@@ -9,24 +9,39 @@ import Button from './Button';
|
|||||||
* @abstract
|
* @abstract
|
||||||
*/
|
*/
|
||||||
export default class Modal extends Component {
|
export default class Modal extends Component {
|
||||||
|
/**
|
||||||
|
* Determine whether or not the modal should be dismissible via an 'x' button.
|
||||||
|
*/
|
||||||
|
static isDismissible = true;
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
/**
|
/**
|
||||||
* An alert component to show below the header.
|
* Attributes for an alert component to show below the header.
|
||||||
*
|
*
|
||||||
* @type {Alert}
|
* @type {object}
|
||||||
*/
|
*/
|
||||||
this.alert = null;
|
this.alertAttrs = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
config(isInitialized, context) {
|
||||||
|
if (isInitialized) return;
|
||||||
|
|
||||||
|
this.props.onshow(() => this.onready());
|
||||||
|
|
||||||
|
context.onunload = () => {
|
||||||
|
this.props.onhide();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
if (this.alert) {
|
if (this.alertAttrs) {
|
||||||
this.alert.props.dismissible = false;
|
this.alertAttrs.dismissible = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'Modal modal-dialog ' + this.className()}>
|
<div className={'Modal modal-dialog ' + this.className()}>
|
||||||
<div className="Modal-content">
|
<div className="Modal-content">
|
||||||
{this.isDismissible() ? (
|
{this.constructor.isDismissible ? (
|
||||||
<div className="Modal-close App-backControl">
|
<div className="Modal-close App-backControl">
|
||||||
{Button.component({
|
{Button.component({
|
||||||
icon: 'fas fa-times',
|
icon: 'fas fa-times',
|
||||||
@@ -43,7 +58,7 @@ export default class Modal extends Component {
|
|||||||
<h3 className="App-titleControl App-titleControl--text">{this.title()}</h3>
|
<h3 className="App-titleControl App-titleControl--text">{this.title()}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{alert ? <div className="Modal-alert">{this.alert}</div> : ''}
|
{this.alertAttrs ? <div className="Modal-alert">{Alert.component(this.alertAttrs)}</div> : ''}
|
||||||
|
|
||||||
{this.content()}
|
{this.content()}
|
||||||
</form>
|
</form>
|
||||||
@@ -52,15 +67,6 @@ export default class Modal extends Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine whether or not the modal should be dismissible via an 'x' button.
|
|
||||||
*
|
|
||||||
* @return {Boolean}
|
|
||||||
*/
|
|
||||||
isDismissible() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the class name to apply to the modal.
|
* Get the class name to apply to the modal.
|
||||||
*
|
*
|
||||||
@@ -105,7 +111,7 @@ export default class Modal extends Component {
|
|||||||
* Hide the modal.
|
* Hide the modal.
|
||||||
*/
|
*/
|
||||||
hide() {
|
hide() {
|
||||||
app.modal.close();
|
this.props.onhide();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,7 +129,7 @@ export default class Modal extends Component {
|
|||||||
* @param {RequestError} error
|
* @param {RequestError} error
|
||||||
*/
|
*/
|
||||||
onerror(error) {
|
onerror(error) {
|
||||||
this.alert = error.alert;
|
this.alertAttrs = error.alert;
|
||||||
|
|
||||||
m.redraw();
|
m.redraw();
|
||||||
|
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
import Component from '../Component';
|
import Component from '../Component';
|
||||||
import Modal from './Modal';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `ModalManager` component manages a modal dialog. Only one modal dialog
|
* The `ModalManager` component manages a modal dialog. Only one modal dialog
|
||||||
@@ -8,12 +7,17 @@ import Modal from './Modal';
|
|||||||
*/
|
*/
|
||||||
export default class ModalManager extends Component {
|
export default class ModalManager extends Component {
|
||||||
init() {
|
init() {
|
||||||
this.showing = false;
|
this.state = this.props.state;
|
||||||
this.component = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
return <div className="ModalManager modal fade">{this.component && this.component.render()}</div>;
|
const modal = this.state.modal;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ModalManager modal fade">
|
||||||
|
{modal ? modal.componentClass.component({ ...modal.attrs, onshow: this.animateShow.bind(this), onhide: this.animateHide.bind(this) }) : ''}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
config(isInitialized, context) {
|
config(isInitialized, context) {
|
||||||
@@ -24,31 +28,17 @@ export default class ModalManager extends Component {
|
|||||||
// to be retained across route changes.
|
// to be retained across route changes.
|
||||||
context.retain = true;
|
context.retain = true;
|
||||||
|
|
||||||
this.$().on('hidden.bs.modal', this.clear.bind(this)).on('shown.bs.modal', this.onready.bind(this));
|
// Ensure the modal state is notified about a closed modal, even when the
|
||||||
|
// DOM-based Bootstrap JavaScript code triggered the closing of the modal,
|
||||||
|
// e.g. via ESC key or a click on the modal backdrop.
|
||||||
|
this.$().on('hidden.bs.modal', this.state.close.bind(this.state));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
animateShow(readyCallback) {
|
||||||
* Show a modal dialog.
|
const dismissible = !!this.state.modal.componentClass.isDismissible;
|
||||||
*
|
|
||||||
* @param {Modal} component
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
show(component) {
|
|
||||||
if (!(component instanceof Modal)) {
|
|
||||||
throw new Error('The ModalManager component can only show Modal components');
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimeout(this.hideTimeout);
|
|
||||||
|
|
||||||
this.showing = true;
|
|
||||||
this.component = component;
|
|
||||||
|
|
||||||
if (app.current) app.current.retain = true;
|
|
||||||
|
|
||||||
m.redraw(true);
|
|
||||||
|
|
||||||
const dismissible = !!this.component.isDismissible();
|
|
||||||
this.$()
|
this.$()
|
||||||
|
.one('shown.bs.modal', readyCallback)
|
||||||
.modal({
|
.modal({
|
||||||
backdrop: dismissible || 'static',
|
backdrop: dismissible || 'static',
|
||||||
keyboard: dismissible,
|
keyboard: dismissible,
|
||||||
@@ -56,50 +46,7 @@ export default class ModalManager extends Component {
|
|||||||
.modal('show');
|
.modal('show');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
animateHide() {
|
||||||
* Close the modal dialog.
|
|
||||||
*
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
close() {
|
|
||||||
if (!this.showing) return;
|
|
||||||
|
|
||||||
// Don't hide the modal immediately, because if the consumer happens to call
|
|
||||||
// the `show` method straight after to show another modal dialog, it will
|
|
||||||
// cause Bootstrap's modal JS to misbehave. Instead we will wait for a tiny
|
|
||||||
// bit to give the `show` method the opportunity to prevent this from going
|
|
||||||
// ahead.
|
|
||||||
this.hideTimeout = setTimeout(() => {
|
|
||||||
this.$().modal('hide');
|
this.$().modal('hide');
|
||||||
this.showing = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear content from the modal area.
|
|
||||||
*
|
|
||||||
* @protected
|
|
||||||
*/
|
|
||||||
clear() {
|
|
||||||
if (this.component) {
|
|
||||||
this.component.onhide();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.component = null;
|
|
||||||
|
|
||||||
app.current.retain = false;
|
|
||||||
|
|
||||||
m.lazyRedraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When the modal dialog is ready to be used, tell it!
|
|
||||||
*
|
|
||||||
* @protected
|
|
||||||
*/
|
|
||||||
onready() {
|
|
||||||
if (this.component && this.component.onready) {
|
|
||||||
this.component.onready(this.$());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import Component from '../../common/Component';
|
import Component from '../Component';
|
||||||
|
import PageState from '../states/PageState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `Page` component
|
* The `Page` component
|
||||||
@@ -8,7 +9,7 @@ import Component from '../../common/Component';
|
|||||||
export default class Page extends Component {
|
export default class Page extends Component {
|
||||||
init() {
|
init() {
|
||||||
app.previous = app.current;
|
app.previous = app.current;
|
||||||
app.current = this;
|
app.current = new PageState(this.constructor);
|
||||||
|
|
||||||
app.drawer.hide();
|
app.drawer.hide();
|
||||||
app.modal.close();
|
app.modal.close();
|
@@ -6,16 +6,26 @@ export default class RequestErrorModal extends Modal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
title() {
|
title() {
|
||||||
return this.props.error.xhr ? this.props.error.xhr.status + ' ' + this.props.error.xhr.statusText : '';
|
return this.props.error.xhr ? `${this.props.error.xhr.status} ${this.props.error.xhr.statusText}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
content() {
|
content() {
|
||||||
|
const { error, formattedError } = this.props;
|
||||||
|
|
||||||
let responseText;
|
let responseText;
|
||||||
|
|
||||||
|
// If the error is already formatted, just add line endings;
|
||||||
|
// else try to parse it as JSON and stringify it with indentation
|
||||||
|
if (formattedError) {
|
||||||
|
responseText = formattedError.join('\n\n');
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
responseText = JSON.stringify(JSON.parse(this.props.error.responseText), null, 2);
|
const json = error.response || JSON.parse(error.responseText);
|
||||||
|
|
||||||
|
responseText = JSON.stringify(json, null, 2);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
responseText = this.props.error.responseText;
|
responseText = error.responseText;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@@ -12,6 +12,6 @@ export default class Switch extends Checkbox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getDisplay() {
|
getDisplay() {
|
||||||
return this.loading ? super.getDisplay() : '';
|
return this.props.loading ? super.getDisplay() : '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
export default class Routes {
|
export default class Model {
|
||||||
type;
|
type;
|
||||||
attributes = [];
|
attributes = [];
|
||||||
hasOnes = [];
|
hasOnes = [];
|
||||||
|
@@ -6,10 +6,10 @@
|
|||||||
* @return {Object}
|
* @return {Object}
|
||||||
*/
|
*/
|
||||||
export default function fullTime(time) {
|
export default function fullTime(time) {
|
||||||
const mo = moment(time);
|
const d = dayjs(time);
|
||||||
|
|
||||||
const datetime = mo.format();
|
const datetime = d.format();
|
||||||
const full = mo.format('LLLL');
|
const full = d.format('LLLL');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<time pubdate datetime={datetime}>
|
<time pubdate datetime={datetime}>
|
||||||
|
@@ -9,10 +9,10 @@ import humanTimeUtil from '../utils/humanTime';
|
|||||||
* @return {Object}
|
* @return {Object}
|
||||||
*/
|
*/
|
||||||
export default function humanTime(time) {
|
export default function humanTime(time) {
|
||||||
const mo = moment(time);
|
const d = dayjs(time);
|
||||||
|
|
||||||
const datetime = mo.format();
|
const datetime = d.format();
|
||||||
const full = mo.format('LLLL');
|
const full = d.format('LLLL');
|
||||||
const ago = humanTimeUtil(time);
|
const ago = humanTimeUtil(time);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import 'expose-loader?$!expose-loader?jQuery!jquery';
|
import 'expose-loader?$!expose-loader?jQuery!jquery';
|
||||||
import 'expose-loader?m!mithril';
|
import 'expose-loader?m!mithril';
|
||||||
import 'expose-loader?moment!moment';
|
import 'expose-loader?moment!expose-loader?dayjs!dayjs';
|
||||||
import 'expose-loader?m.bidi!m.attrs.bidi';
|
import 'expose-loader?m.bidi!m.attrs.bidi';
|
||||||
import 'bootstrap/js/affix';
|
import 'bootstrap/js/affix';
|
||||||
import 'bootstrap/js/dropdown';
|
import 'bootstrap/js/dropdown';
|
||||||
@@ -9,6 +9,12 @@ import 'bootstrap/js/tooltip';
|
|||||||
import 'bootstrap/js/transition';
|
import 'bootstrap/js/transition';
|
||||||
import 'jquery.hotkeys/jquery.hotkeys';
|
import 'jquery.hotkeys/jquery.hotkeys';
|
||||||
|
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
dayjs.extend(localizedFormat);
|
||||||
|
|
||||||
import patchMithril from './utils/patchMithril';
|
import patchMithril from './utils/patchMithril';
|
||||||
|
|
||||||
patchMithril(window);
|
patchMithril(window);
|
||||||
|
@@ -68,7 +68,10 @@ Object.assign(Discussion.prototype, {
|
|||||||
const user = app.session.user;
|
const user = app.session.user;
|
||||||
|
|
||||||
if (user && user.markedAllAsReadAt() < this.lastPostedAt()) {
|
if (user && user.markedAllAsReadAt() < this.lastPostedAt()) {
|
||||||
return Math.max(0, this.lastPostNumber() - (this.lastReadPostNumber() || 0));
|
const unreadCount = Math.max(0, this.lastPostNumber() - (this.lastReadPostNumber() || 0));
|
||||||
|
// If posts have been deleted, it's possible that the unread count could exceed the
|
||||||
|
// actual post count. As such, we take the min of the two to ensure this isn't an issue.
|
||||||
|
return Math.min(unreadCount, this.commentCount());
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
|
@@ -54,7 +54,7 @@ Object.assign(User.prototype, {
|
|||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
isOnline() {
|
isOnline() {
|
||||||
return this.lastSeenAt() > moment().subtract(5, 'minutes').toDate();
|
return dayjs().subtract(5, 'minutes').isBefore(this.lastSeenAt());
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
50
js/src/common/states/AlertManagerState.js
Normal file
50
js/src/common/states/AlertManagerState.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import Alert from '../components/Alert';
|
||||||
|
|
||||||
|
export default class AlertManagerState {
|
||||||
|
constructor() {
|
||||||
|
this.activeAlerts = {};
|
||||||
|
this.alertId = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveAlerts() {
|
||||||
|
return this.activeAlerts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show an Alert in the alerts area.
|
||||||
|
*/
|
||||||
|
show(attrs, componentClass = Alert) {
|
||||||
|
// Breaking Change Compliance Warning, Remove in Beta 15.
|
||||||
|
// This is applied to the first argument (attrs) because previously, the alert was passed as the first argument.
|
||||||
|
if (attrs === Alert || attrs instanceof Alert) {
|
||||||
|
// This is duplicated so that if the error is caught, an error message still shows up in the debug console.
|
||||||
|
console.error('The AlertManager can only show Alerts. Whichever extension triggered this alert should be updated to comply with beta 14.');
|
||||||
|
throw new Error('The AlertManager can only show Alerts. Whichever extension triggered this alert should be updated to comply with beta 14.');
|
||||||
|
}
|
||||||
|
// End Change Compliance Warning, Remove in Beta 15
|
||||||
|
this.activeAlerts[++this.alertId] = { attrs, componentClass };
|
||||||
|
m.redraw();
|
||||||
|
|
||||||
|
return this.alertId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismiss an alert.
|
||||||
|
*/
|
||||||
|
dismiss(key) {
|
||||||
|
if (!key || !(key in this.activeAlerts)) return;
|
||||||
|
|
||||||
|
delete this.activeAlerts[key];
|
||||||
|
m.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all alerts.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
clear() {
|
||||||
|
this.activeAlerts = {};
|
||||||
|
m.redraw();
|
||||||
|
}
|
||||||
|
}
|
56
js/src/common/states/ModalManagerState.js
Normal file
56
js/src/common/states/ModalManagerState.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import Modal from '../components/Modal';
|
||||||
|
|
||||||
|
export default class ModalManagerState {
|
||||||
|
constructor() {
|
||||||
|
this.modal = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a modal dialog.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
show(componentClass, attrs) {
|
||||||
|
// Breaking Change Compliance Warning, Remove in Beta 15.
|
||||||
|
if (!(componentClass.prototype instanceof Modal)) {
|
||||||
|
// This is duplicated so that if the error is caught, an error message still shows up in the debug console.
|
||||||
|
console.error('The ModalManager can only show Modals');
|
||||||
|
throw new Error('The ModalManager can only show Modals');
|
||||||
|
}
|
||||||
|
if (componentClass.init) {
|
||||||
|
// This is duplicated so that if the error is caught, an error message still shows up in the debug console.
|
||||||
|
console.error(
|
||||||
|
'The componentClass parameter must be a modal class, not a modal instance. Whichever extension triggered this modal should be updated to comply with beta 14.'
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
'The componentClass parameter must be a modal class, not a modal instance. Whichever extension triggered this modal should be updated to comply with beta 14.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// End Change Compliance Warning, Remove in Beta 15
|
||||||
|
|
||||||
|
clearTimeout(this.closeTimeout);
|
||||||
|
|
||||||
|
this.modal = { componentClass, attrs };
|
||||||
|
|
||||||
|
m.redraw(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the modal dialog.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
close() {
|
||||||
|
if (!this.modal) return;
|
||||||
|
|
||||||
|
// Don't hide the modal immediately, because if the consumer happens to call
|
||||||
|
// the `show` method straight after to show another modal dialog, it will
|
||||||
|
// cause Bootstrap's modal JS to misbehave. Instead we will wait for a tiny
|
||||||
|
// bit to give the `show` method the opportunity to prevent this from going
|
||||||
|
// ahead.
|
||||||
|
this.closeTimeout = setTimeout(() => {
|
||||||
|
this.modal = null;
|
||||||
|
m.lazyRedraw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
33
js/src/common/states/PageState.js
Normal file
33
js/src/common/states/PageState.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import subclassOf from '../../common/utils/subclassOf';
|
||||||
|
|
||||||
|
export default class PageState {
|
||||||
|
constructor(type, data = {}) {
|
||||||
|
this.type = type;
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the page matches the given class and data.
|
||||||
|
*
|
||||||
|
* @param {object} type The page class to check against. Subclasses are
|
||||||
|
* accepted as well.
|
||||||
|
* @param {object} data
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
matches(type, data = {}) {
|
||||||
|
// Fail early when the page is of a different type
|
||||||
|
if (!subclassOf(this.type, type)) return false;
|
||||||
|
|
||||||
|
// Now that the type is known to be correct, we loop through the provided
|
||||||
|
// data to see whether it matches the data in our state.
|
||||||
|
return Object.keys(data).every((key) => this.data[key] === data[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key) {
|
||||||
|
return this.data[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key, value) {
|
||||||
|
this.data[key] = value;
|
||||||
|
}
|
||||||
|
}
|
@@ -1,5 +1,9 @@
|
|||||||
class Item {
|
class Item {
|
||||||
constructor(content, priority) {
|
content: any;
|
||||||
|
priority: number;
|
||||||
|
key?: number;
|
||||||
|
|
||||||
|
constructor(content: any, priority?: number) {
|
||||||
this.content = content;
|
this.content = content;
|
||||||
this.priority = priority;
|
this.priority = priority;
|
||||||
}
|
}
|
||||||
@@ -10,23 +14,15 @@ class Item {
|
|||||||
* by priority.
|
* by priority.
|
||||||
*/
|
*/
|
||||||
export default class ItemList {
|
export default class ItemList {
|
||||||
constructor() {
|
|
||||||
/**
|
/**
|
||||||
* The items in the list.
|
* The items in the list
|
||||||
*
|
|
||||||
* @type {Object}
|
|
||||||
* @public
|
|
||||||
*/
|
*/
|
||||||
this.items = {};
|
items: { [key: string]: Item } = {};
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether the list is empty.
|
* Check whether the list is empty.
|
||||||
*
|
|
||||||
* @returns {boolean}
|
|
||||||
* @public
|
|
||||||
*/
|
*/
|
||||||
isEmpty() {
|
isEmpty(): boolean {
|
||||||
for (const i in this.items) {
|
for (const i in this.items) {
|
||||||
if (this.items.hasOwnProperty(i)) {
|
if (this.items.hasOwnProperty(i)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -38,36 +34,27 @@ export default class ItemList {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether an item is present in the list.
|
* Check whether an item is present in the list.
|
||||||
*
|
|
||||||
* @param key
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
*/
|
||||||
has(key) {
|
has(key: string): boolean {
|
||||||
return !!this.items[key];
|
return !!this.items[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the content of an item.
|
* Get the content of an item.
|
||||||
*
|
|
||||||
* @param {String} key
|
|
||||||
* @return {*}
|
|
||||||
* @public
|
|
||||||
*/
|
*/
|
||||||
get(key) {
|
get(key: string): any {
|
||||||
return this.items[key].content;
|
return this.items[key].content;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add an item to the list.
|
* Add an item to the list.
|
||||||
*
|
*
|
||||||
* @param {String} key A unique key for the item.
|
* @param key A unique key for the item.
|
||||||
* @param {*} content The item's content.
|
* @param content The item's content.
|
||||||
* @param {Integer} [priority] The priority of the item. Items with a higher
|
* @param [priority] The priority of the item. Items with a higher
|
||||||
* priority will be positioned before items with a lower priority.
|
* priority will be positioned before items with a lower priority.
|
||||||
* @return {ItemList}
|
|
||||||
* @public
|
|
||||||
*/
|
*/
|
||||||
add(key, content, priority = 0) {
|
add(key: string, content: any, priority: number = 0): this {
|
||||||
this.items[key] = new Item(content, priority);
|
this.items[key] = new Item(content, priority);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
@@ -75,14 +62,8 @@ export default class ItemList {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace an item in the list, only if it is already present.
|
* Replace an item in the list, only if it is already present.
|
||||||
*
|
|
||||||
* @param {String} key
|
|
||||||
* @param {*} [content]
|
|
||||||
* @param {Integer} [priority]
|
|
||||||
* @return {ItemList}
|
|
||||||
* @public
|
|
||||||
*/
|
*/
|
||||||
replace(key, content = null, priority = null) {
|
replace(key: string, content: any = null, priority: number = null): this {
|
||||||
if (this.items[key]) {
|
if (this.items[key]) {
|
||||||
if (content !== null) {
|
if (content !== null) {
|
||||||
this.items[key].content = content;
|
this.items[key].content = content;
|
||||||
@@ -98,12 +79,8 @@ export default class ItemList {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove an item from the list.
|
* Remove an item from the list.
|
||||||
*
|
|
||||||
* @param {String} key
|
|
||||||
* @return {ItemList}
|
|
||||||
* @public
|
|
||||||
*/
|
*/
|
||||||
remove(key) {
|
remove(key: string): this {
|
||||||
delete this.items[key];
|
delete this.items[key];
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
@@ -111,12 +88,8 @@ export default class ItemList {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge another list's items into this one.
|
* Merge another list's items into this one.
|
||||||
*
|
|
||||||
* @param {ItemList} items
|
|
||||||
* @return {ItemList}
|
|
||||||
* @public
|
|
||||||
*/
|
*/
|
||||||
merge(items) {
|
merge(items: this): this {
|
||||||
for (const i in items.items) {
|
for (const i in items.items) {
|
||||||
if (items.items.hasOwnProperty(i) && items.items[i] instanceof Item) {
|
if (items.items.hasOwnProperty(i) && items.items[i] instanceof Item) {
|
||||||
this.items[i] = items.items[i];
|
this.items[i] = items.items[i];
|
||||||
@@ -130,12 +103,9 @@ export default class ItemList {
|
|||||||
* Convert the list into an array of item content arranged by priority. Each
|
* Convert the list into an array of item content arranged by priority. Each
|
||||||
* item's content will be assigned an `itemName` property equal to the item's
|
* item's content will be assigned an `itemName` property equal to the item's
|
||||||
* unique key.
|
* unique key.
|
||||||
*
|
|
||||||
* @return {Array}
|
|
||||||
* @public
|
|
||||||
*/
|
*/
|
||||||
toArray() {
|
toArray(): any[] {
|
||||||
const items = [];
|
const items: Item[] = [];
|
||||||
|
|
||||||
for (const i in this.items) {
|
for (const i in this.items) {
|
||||||
if (this.items.hasOwnProperty(i) && this.items[i] instanceof Item) {
|
if (this.items.hasOwnProperty(i) && this.items[i] instanceof Item) {
|
@@ -1,5 +1,14 @@
|
|||||||
export default class RequestError {
|
export default class RequestError {
|
||||||
constructor(status, responseText, options, xhr) {
|
status: string;
|
||||||
|
options: object;
|
||||||
|
xhr: XMLHttpRequest;
|
||||||
|
|
||||||
|
responseText: string | null;
|
||||||
|
response: object | null;
|
||||||
|
|
||||||
|
alert: any;
|
||||||
|
|
||||||
|
constructor(status: string, responseText: string | null, options: object, xhr: XMLHttpRequest) {
|
||||||
this.status = status;
|
this.status = status;
|
||||||
this.responseText = responseText;
|
this.responseText = responseText;
|
||||||
this.options = options;
|
this.options = options;
|
109
js/src/common/utils/SuperTextarea.js
Normal file
109
js/src/common/utils/SuperTextarea.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* A textarea wrapper with powerful helpers for text manipulation.
|
||||||
|
*
|
||||||
|
* This wraps a <textarea> DOM element and allows directly manipulating its text
|
||||||
|
* contents and cursor positions.
|
||||||
|
*
|
||||||
|
* I apologize for the pretentious name. :)
|
||||||
|
*/
|
||||||
|
export default class SuperTextarea {
|
||||||
|
/**
|
||||||
|
* @param {HTMLTextAreaElement} textarea
|
||||||
|
*/
|
||||||
|
constructor(textarea) {
|
||||||
|
this.el = textarea;
|
||||||
|
this.$ = $(textarea);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the value of the text editor.
|
||||||
|
*
|
||||||
|
* @param {String} value
|
||||||
|
*/
|
||||||
|
setValue(value) {
|
||||||
|
this.$.val(value).trigger('input');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focus the textarea and place the cursor at the given index.
|
||||||
|
*
|
||||||
|
* @param {number} position
|
||||||
|
*/
|
||||||
|
moveCursorTo(position) {
|
||||||
|
this.setSelectionRange(position, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the selected range of the textarea.
|
||||||
|
*
|
||||||
|
* @return {Array}
|
||||||
|
*/
|
||||||
|
getSelectionRange() {
|
||||||
|
return [this.el.selectionStart, this.el.selectionEnd];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert content into the textarea at the position of the cursor.
|
||||||
|
*
|
||||||
|
* @param {String} text
|
||||||
|
*/
|
||||||
|
insertAtCursor(text) {
|
||||||
|
this.insertAt(this.el.selectionStart, text);
|
||||||
|
|
||||||
|
this.el.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert content into the textarea at the given position.
|
||||||
|
*
|
||||||
|
* @param {number} pos
|
||||||
|
* @param {String} text
|
||||||
|
*/
|
||||||
|
insertAt(pos, text) {
|
||||||
|
this.insertBetween(pos, pos, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert content into the textarea between the given positions.
|
||||||
|
*
|
||||||
|
* If the start and end positions are different, any text between them will be
|
||||||
|
* overwritten.
|
||||||
|
*
|
||||||
|
* @param start
|
||||||
|
* @param end
|
||||||
|
* @param text
|
||||||
|
*/
|
||||||
|
insertBetween(start, end, text) {
|
||||||
|
const value = this.el.value;
|
||||||
|
|
||||||
|
const before = value.slice(0, start);
|
||||||
|
const after = value.slice(end);
|
||||||
|
|
||||||
|
this.setValue(`${before}${text}${after}`);
|
||||||
|
|
||||||
|
// Move the textarea cursor to the end of the content we just inserted.
|
||||||
|
this.moveCursorTo(start + text.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace existing content from the start to the current cursor position.
|
||||||
|
*
|
||||||
|
* @param start
|
||||||
|
* @param text
|
||||||
|
*/
|
||||||
|
replaceBeforeCursor(start, text) {
|
||||||
|
this.insertBetween(start, this.el.selectionStart, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the selected range of the textarea.
|
||||||
|
*
|
||||||
|
* @param {number} start
|
||||||
|
* @param {number} end
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
setSelectionRange(start, end) {
|
||||||
|
this.el.setSelectionRange(start, end);
|
||||||
|
this.$.focus();
|
||||||
|
}
|
||||||
|
}
|
@@ -4,11 +4,8 @@
|
|||||||
* @example
|
* @example
|
||||||
* abbreviateNumber(1234);
|
* abbreviateNumber(1234);
|
||||||
* // "1.2K"
|
* // "1.2K"
|
||||||
*
|
|
||||||
* @param {Integer} number
|
|
||||||
* @return {String}
|
|
||||||
*/
|
*/
|
||||||
export default function abbreviateNumber(number) {
|
export default function abbreviateNumber(number: number): string {
|
||||||
// TODO: translation
|
// TODO: translation
|
||||||
if (number >= 1000000) {
|
if (number >= 1000000) {
|
||||||
return Math.floor(number / 1000000) + app.translator.trans('core.lib.number_suffix.mega_text');
|
return Math.floor(number / 1000000) + app.translator.trans('core.lib.number_suffix.mega_text');
|
@@ -1,15 +0,0 @@
|
|||||||
/**
|
|
||||||
* The `extract` utility deletes a property from an object and returns its
|
|
||||||
* value.
|
|
||||||
*
|
|
||||||
* @param {Object} object The object that owns the property
|
|
||||||
* @param {String} property The name of the property to extract
|
|
||||||
* @return {*} The value of the property
|
|
||||||
*/
|
|
||||||
export default function extract(object, property) {
|
|
||||||
const value = object[property];
|
|
||||||
|
|
||||||
delete object[property];
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
15
js/src/common/utils/extract.ts
Normal file
15
js/src/common/utils/extract.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* The `extract` utility deletes a property from an object and returns its
|
||||||
|
* value.
|
||||||
|
*
|
||||||
|
* @param object The object that owns the property
|
||||||
|
* @param property The name of the property to extract
|
||||||
|
* @return The value of the property
|
||||||
|
*/
|
||||||
|
export default function extract<T, K extends keyof T>(object: T, property: K): T[K] {
|
||||||
|
const value = object[property];
|
||||||
|
|
||||||
|
delete object[property];
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
@@ -5,10 +5,7 @@
|
|||||||
* @example
|
* @example
|
||||||
* formatNumber(1234);
|
* formatNumber(1234);
|
||||||
* // 1,234
|
* // 1,234
|
||||||
*
|
|
||||||
* @param {Number} number
|
|
||||||
* @return {String}
|
|
||||||
*/
|
*/
|
||||||
export default function formatNumber(number) {
|
export default function formatNumber(number: number): string {
|
||||||
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
}
|
}
|
@@ -1,35 +1,32 @@
|
|||||||
/**
|
/**
|
||||||
* The `humanTime` utility converts a date to a localized, human-readable time-
|
* The `humanTime` utility converts a date to a localized, human-readable time-
|
||||||
* ago string.
|
* ago string.
|
||||||
*
|
|
||||||
* @param {Date} time
|
|
||||||
* @return {String}
|
|
||||||
*/
|
*/
|
||||||
export default function humanTime(time) {
|
export default function humanTime(time: Date): string {
|
||||||
let m = moment(time);
|
let d = dayjs(time);
|
||||||
const now = moment();
|
const now = dayjs();
|
||||||
|
|
||||||
// To prevent showing things like "in a few seconds" due to small offsets
|
// To prevent showing things like "in a few seconds" due to small offsets
|
||||||
// between client and server time, we always reset future dates to the
|
// between client and server time, we always reset future dates to the
|
||||||
// current time. This will result in "just now" being shown instead.
|
// current time. This will result in "just now" being shown instead.
|
||||||
if (m.isAfter(now)) {
|
if (d.isAfter(now)) {
|
||||||
m = now;
|
d = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
const day = 864e5;
|
const day = 864e5;
|
||||||
const diff = m.diff(moment());
|
const diff = d.diff(dayjs());
|
||||||
let ago = null;
|
let ago: string;
|
||||||
|
|
||||||
// If this date was more than a month ago, we'll show the name of the month
|
// If this date was more than a month ago, we'll show the name of the month
|
||||||
// in the string. If it wasn't this year, we'll show the year as well.
|
// in the string. If it wasn't this year, we'll show the year as well.
|
||||||
if (diff < -30 * day) {
|
if (diff < -30 * day) {
|
||||||
if (m.year() === moment().year()) {
|
if (d.year() === dayjs().year()) {
|
||||||
ago = m.format('D MMM');
|
ago = d.format('D MMM');
|
||||||
} else {
|
} else {
|
||||||
ago = m.format('ll');
|
ago = d.format('ll');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ago = m.fromNow();
|
ago = d.fromNow();
|
||||||
}
|
}
|
||||||
|
|
||||||
return ago;
|
return ago;
|
@@ -1,18 +1,18 @@
|
|||||||
import humanTimeUtil from './humanTime';
|
import humanTime from './humanTime';
|
||||||
|
|
||||||
function updateHumanTimes() {
|
function updateHumanTimes() {
|
||||||
$('[data-humantime]').each(function () {
|
$('[data-humantime]').each(function () {
|
||||||
const $this = $(this);
|
const $this = $(this);
|
||||||
const ago = humanTimeUtil($this.attr('datetime'));
|
const ago = humanTime($this.attr('datetime'));
|
||||||
|
|
||||||
$this.html(ago);
|
$this.html(ago);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `humanTime` initializer sets up a loop every 1 second to update
|
* The `liveHumanTimes` initializer sets up a loop every 1 second to update
|
||||||
* timestamps rendered with the `humanTime` helper.
|
* timestamps rendered with the `humanTime` helper.
|
||||||
*/
|
*/
|
||||||
export default function humanTime() {
|
export default function liveHumanTimes() {
|
||||||
setInterval(updateHumanTimes, 10000);
|
setInterval(updateHumanTimes, 10000);
|
||||||
}
|
}
|
@@ -1,12 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Truncate a string to the given length, appending ellipses if necessary.
|
* Truncate a string to the given length, appending ellipses if necessary.
|
||||||
*
|
|
||||||
* @param {String} string
|
|
||||||
* @param {Number} length
|
|
||||||
* @param {Number} [start=0]
|
|
||||||
* @return {String}
|
|
||||||
*/
|
*/
|
||||||
export function truncate(string, length, start = 0) {
|
export function truncate(string: string, length: number, start: number = 0): string {
|
||||||
return (start > 0 ? '...' : '') + string.substring(start, start + length) + (string.length > start + length ? '...' : '');
|
return (start > 0 ? '...' : '') + string.substring(start, start + length) + (string.length > start + length ? '...' : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,11 +12,8 @@ export function truncate(string, length, start = 0) {
|
|||||||
* NOTE: This method does not use the comparably sophisticated transliteration
|
* NOTE: This method does not use the comparably sophisticated transliteration
|
||||||
* mechanism that is employed in the backend. Therefore, it should only be used
|
* mechanism that is employed in the backend. Therefore, it should only be used
|
||||||
* to *suggest* slugs that can be overridden by the user.
|
* to *suggest* slugs that can be overridden by the user.
|
||||||
*
|
|
||||||
* @param {String} string
|
|
||||||
* @return {String}
|
|
||||||
*/
|
*/
|
||||||
export function slug(string) {
|
export function slug(string: string): string {
|
||||||
return string
|
return string
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]/gi, '-')
|
.replace(/[^a-z0-9]/gi, '-')
|
||||||
@@ -32,11 +24,8 @@ export function slug(string) {
|
|||||||
/**
|
/**
|
||||||
* Strip HTML tags and quotes out of the given string, replacing them with
|
* Strip HTML tags and quotes out of the given string, replacing them with
|
||||||
* meaningful punctuation.
|
* meaningful punctuation.
|
||||||
*
|
|
||||||
* @param {String} string
|
|
||||||
* @return {String}
|
|
||||||
*/
|
*/
|
||||||
export function getPlainContent(string) {
|
export function getPlainContent(string: string): string {
|
||||||
const html = string.replace(/(<\/p>|<br>)/g, '$1 ').replace(/<img\b[^>]*>/gi, ' ');
|
const html = string.replace(/(<\/p>|<br>)/g, '$1 ').replace(/<img\b[^>]*>/gi, ' ');
|
||||||
|
|
||||||
const dom = $('<div/>').html(html);
|
const dom = $('<div/>').html(html);
|
||||||
@@ -55,10 +44,7 @@ getPlainContent.removeSelectors = ['blockquote', 'script'];
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Make a string's first character uppercase.
|
* Make a string's first character uppercase.
|
||||||
*
|
|
||||||
* @param {String} string
|
|
||||||
* @return {String}
|
|
||||||
*/
|
*/
|
||||||
export function ucfirst(string) {
|
export function ucfirst(string: string): string {
|
||||||
return string.substr(0, 1).toUpperCase() + string.substr(1);
|
return string.substr(0, 1).toUpperCase() + string.substr(1);
|
||||||
}
|
}
|
@@ -1,4 +1,6 @@
|
|||||||
function hsvToRgb(h, s, v) {
|
type RGB = { r: number; g: number; b: number };
|
||||||
|
|
||||||
|
function hsvToRgb(h: number, s: number, v: number): RGB {
|
||||||
let r;
|
let r;
|
||||||
let g;
|
let g;
|
||||||
let b;
|
let b;
|
||||||
@@ -51,11 +53,8 @@ function hsvToRgb(h, s, v) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert the given string to a unique color.
|
* Convert the given string to a unique color.
|
||||||
*
|
|
||||||
* @param {String} string
|
|
||||||
* @return {String}
|
|
||||||
*/
|
*/
|
||||||
export default function stringToColor(string) {
|
export default function stringToColor(string: string): string {
|
||||||
let num = 0;
|
let num = 0;
|
||||||
|
|
||||||
// Convert the username into a number based on the ASCII value of each
|
// Convert the username into a number based on the ASCII value of each
|
6
js/src/common/utils/subclassOf.js
Normal file
6
js/src/common/utils/subclassOf.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Check if class A is the same as or a subclass of class B.
|
||||||
|
*/
|
||||||
|
export default function subclassOf(A, B) {
|
||||||
|
return A && (A === B || A.prototype instanceof B);
|
||||||
|
}
|
@@ -1,7 +1,5 @@
|
|||||||
import History from './utils/History';
|
import History from './utils/History';
|
||||||
import Pane from './utils/Pane';
|
import Pane from './utils/Pane';
|
||||||
import Search from './components/Search';
|
|
||||||
import ReplyComposer from './components/ReplyComposer';
|
|
||||||
import DiscussionPage from './components/DiscussionPage';
|
import DiscussionPage from './components/DiscussionPage';
|
||||||
import SignUpModal from './components/SignUpModal';
|
import SignUpModal from './components/SignUpModal';
|
||||||
import HeaderPrimary from './components/HeaderPrimary';
|
import HeaderPrimary from './components/HeaderPrimary';
|
||||||
@@ -14,6 +12,10 @@ import routes from './routes';
|
|||||||
import alertEmailConfirmation from './utils/alertEmailConfirmation';
|
import alertEmailConfirmation from './utils/alertEmailConfirmation';
|
||||||
import Application from '../common/Application';
|
import Application from '../common/Application';
|
||||||
import Navigation from '../common/components/Navigation';
|
import Navigation from '../common/components/Navigation';
|
||||||
|
import NotificationListState from './states/NotificationListState';
|
||||||
|
import GlobalSearchState from './states/GlobalSearchState';
|
||||||
|
import DiscussionListState from './states/DiscussionListState';
|
||||||
|
import ComposerState from './states/ComposerState';
|
||||||
|
|
||||||
export default class ForumApplication extends Application {
|
export default class ForumApplication extends Application {
|
||||||
/**
|
/**
|
||||||
@@ -34,13 +36,6 @@ export default class ForumApplication extends Application {
|
|||||||
discussionRenamed: DiscussionRenamedPost,
|
discussionRenamed: DiscussionRenamedPost,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* The page's search component instance.
|
|
||||||
*
|
|
||||||
* @type {Search}
|
|
||||||
*/
|
|
||||||
search = new Search();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An object which controls the state of the page's side pane.
|
* An object which controls the state of the page's side pane.
|
||||||
*
|
*
|
||||||
@@ -63,10 +58,43 @@ export default class ForumApplication extends Application {
|
|||||||
*/
|
*/
|
||||||
history = new History();
|
history = new History();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object which controls the state of the user's notifications.
|
||||||
|
*
|
||||||
|
* @type {NotificationListState}
|
||||||
|
*/
|
||||||
|
notifications = new NotificationListState(this);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* An object which stores previously searched queries and provides convenient
|
||||||
|
* tools for retrieving and managing search values.
|
||||||
|
*
|
||||||
|
* @type {GlobalSearchState}
|
||||||
|
*/
|
||||||
|
search = new GlobalSearchState();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* An object which controls the state of the composer.
|
||||||
|
*/
|
||||||
|
composer = new ComposerState();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
routes(this);
|
routes(this);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object which controls the state of the cached discussion list, which
|
||||||
|
* is used in the index page and the slideout pane.
|
||||||
|
*
|
||||||
|
* @type {DiscussionListState}
|
||||||
|
*/
|
||||||
|
this.discussions = new DiscussionListState({}, this);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated beta 14, remove in beta 15.
|
||||||
|
*/
|
||||||
|
this.cache.discussionList = this.discussions;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,9 +119,9 @@ export default class ForumApplication extends Application {
|
|||||||
m.mount(document.getElementById('header-navigation'), Navigation.component());
|
m.mount(document.getElementById('header-navigation'), Navigation.component());
|
||||||
m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
|
m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
|
||||||
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component());
|
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component());
|
||||||
|
m.mount(document.getElementById('composer'), Composer.component({ state: this.composer }));
|
||||||
|
|
||||||
this.pane = new Pane(document.getElementById('app'));
|
this.pane = new Pane(document.getElementById('app'));
|
||||||
this.composer = m.mount(document.getElementById('composer'), Composer.component());
|
|
||||||
|
|
||||||
m.route.mode = 'pathname';
|
m.route.mode = 'pathname';
|
||||||
super.mount(this.forum.attribute('basePath'));
|
super.mount(this.forum.attribute('basePath'));
|
||||||
@@ -115,21 +143,6 @@ export default class ForumApplication extends Application {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check whether or not the user is currently composing a reply to a
|
|
||||||
* discussion.
|
|
||||||
*
|
|
||||||
* @param {Discussion} discussion
|
|
||||||
* @return {Boolean}
|
|
||||||
*/
|
|
||||||
composingReplyTo(discussion) {
|
|
||||||
return (
|
|
||||||
this.composer.component instanceof ReplyComposer &&
|
|
||||||
this.composer.component.props.discussion === discussion &&
|
|
||||||
this.composer.position !== Composer.PositionEnum.HIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether or not the user is currently viewing a discussion.
|
* Check whether or not the user is currently viewing a discussion.
|
||||||
*
|
*
|
||||||
@@ -137,7 +150,7 @@ export default class ForumApplication extends Application {
|
|||||||
* @return {Boolean}
|
* @return {Boolean}
|
||||||
*/
|
*/
|
||||||
viewingDiscussion(discussion) {
|
viewingDiscussion(discussion) {
|
||||||
return this.current instanceof DiscussionPage && this.current.discussion === discussion;
|
return this.current.matches(DiscussionPage, { discussion });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -157,8 +170,7 @@ export default class ForumApplication extends Application {
|
|||||||
if (payload.loggedIn) {
|
if (payload.loggedIn) {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} else {
|
} else {
|
||||||
const modal = new SignUpModal(payload);
|
this.modal.show(SignUpModal, payload);
|
||||||
this.modal.show(modal);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -9,6 +9,10 @@ import DiscussionControls from './utils/DiscussionControls';
|
|||||||
import alertEmailConfirmation from './utils/alertEmailConfirmation';
|
import alertEmailConfirmation from './utils/alertEmailConfirmation';
|
||||||
import UserControls from './utils/UserControls';
|
import UserControls from './utils/UserControls';
|
||||||
import Pane from './utils/Pane';
|
import Pane from './utils/Pane';
|
||||||
|
import DiscussionListState from './states/DiscussionListState';
|
||||||
|
import GlobalSearchState from './states/GlobalSearchState';
|
||||||
|
import NotificationListState from './states/NotificationListState';
|
||||||
|
import SearchState from './states/SearchState';
|
||||||
import DiscussionPage from './components/DiscussionPage';
|
import DiscussionPage from './components/DiscussionPage';
|
||||||
import LogInModal from './components/LogInModal';
|
import LogInModal from './components/LogInModal';
|
||||||
import ComposerBody from './components/ComposerBody';
|
import ComposerBody from './components/ComposerBody';
|
||||||
@@ -23,7 +27,6 @@ import PostEdited from './components/PostEdited';
|
|||||||
import PostStream from './components/PostStream';
|
import PostStream from './components/PostStream';
|
||||||
import ChangePasswordModal from './components/ChangePasswordModal';
|
import ChangePasswordModal from './components/ChangePasswordModal';
|
||||||
import IndexPage from './components/IndexPage';
|
import IndexPage from './components/IndexPage';
|
||||||
import Page from './components/Page';
|
|
||||||
import DiscussionRenamedNotification from './components/DiscussionRenamedNotification';
|
import DiscussionRenamedNotification from './components/DiscussionRenamedNotification';
|
||||||
import DiscussionsSearchSource from './components/DiscussionsSearchSource';
|
import DiscussionsSearchSource from './components/DiscussionsSearchSource';
|
||||||
import HeaderSecondary from './components/HeaderSecondary';
|
import HeaderSecondary from './components/HeaderSecondary';
|
||||||
@@ -78,6 +81,10 @@ export default Object.assign(compat, {
|
|||||||
'utils/alertEmailConfirmation': alertEmailConfirmation,
|
'utils/alertEmailConfirmation': alertEmailConfirmation,
|
||||||
'utils/UserControls': UserControls,
|
'utils/UserControls': UserControls,
|
||||||
'utils/Pane': Pane,
|
'utils/Pane': Pane,
|
||||||
|
'states/DiscussionListState': DiscussionListState,
|
||||||
|
'states/GlobalSearchState': GlobalSearchState,
|
||||||
|
'states/NotificationListState': NotificationListState,
|
||||||
|
'states/SearchState': SearchState,
|
||||||
'components/DiscussionPage': DiscussionPage,
|
'components/DiscussionPage': DiscussionPage,
|
||||||
'components/LogInModal': LogInModal,
|
'components/LogInModal': LogInModal,
|
||||||
'components/ComposerBody': ComposerBody,
|
'components/ComposerBody': ComposerBody,
|
||||||
@@ -92,7 +99,6 @@ export default Object.assign(compat, {
|
|||||||
'components/PostStream': PostStream,
|
'components/PostStream': PostStream,
|
||||||
'components/ChangePasswordModal': ChangePasswordModal,
|
'components/ChangePasswordModal': ChangePasswordModal,
|
||||||
'components/IndexPage': IndexPage,
|
'components/IndexPage': IndexPage,
|
||||||
'components/Page': Page,
|
|
||||||
'components/DiscussionRenamedNotification': DiscussionRenamedNotification,
|
'components/DiscussionRenamedNotification': DiscussionRenamedNotification,
|
||||||
'components/DiscussionsSearchSource': DiscussionsSearchSource,
|
'components/DiscussionsSearchSource': DiscussionsSearchSource,
|
||||||
'components/HeaderSecondary': HeaderSecondary,
|
'components/HeaderSecondary': HeaderSecondary,
|
||||||
|
@@ -122,7 +122,7 @@ export default class ChangeEmailModal extends Modal {
|
|||||||
|
|
||||||
onerror(error) {
|
onerror(error) {
|
||||||
if (error.status === 401) {
|
if (error.status === 401) {
|
||||||
error.alert.props.children = app.translator.trans('core.forum.change_email.incorrect_password_message');
|
error.alert.children = app.translator.trans('core.forum.change_email.incorrect_password_message');
|
||||||
}
|
}
|
||||||
|
|
||||||
super.onerror(error);
|
super.onerror(error);
|
||||||
|
@@ -31,11 +31,16 @@ export default class CommentPost extends Post {
|
|||||||
*/
|
*/
|
||||||
this.revealContent = false;
|
this.revealContent = false;
|
||||||
|
|
||||||
// Create an instance of the component that displays the post's author so
|
/**
|
||||||
// that we can force the post to rerender when the user card is shown.
|
* Whether or not the user hover card inside of PostUser is visible.
|
||||||
this.postUser = new PostUser({ post: this.props.post });
|
* The property must be managed in CommentPost to be able to use it in the subtree check
|
||||||
|
*
|
||||||
|
* @type {Boolean}
|
||||||
|
*/
|
||||||
|
this.cardVisible = false;
|
||||||
|
|
||||||
this.subtree.check(
|
this.subtree.check(
|
||||||
() => this.postUser.cardVisible,
|
() => this.cardVisible,
|
||||||
() => this.isEditing()
|
() => this.isEditing()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -72,7 +77,7 @@ export default class CommentPost extends Post {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isEditing() {
|
isEditing() {
|
||||||
return app.composer.component instanceof EditPostComposer && app.composer.component.props.post === this.props.post;
|
return app.composer.bodyMatches(EditPostComposer, { post: this.props.post });
|
||||||
}
|
}
|
||||||
|
|
||||||
attrs() {
|
attrs() {
|
||||||
@@ -100,7 +105,7 @@ export default class CommentPost extends Post {
|
|||||||
// body with a preview.
|
// body with a preview.
|
||||||
let preview;
|
let preview;
|
||||||
const updatePreview = () => {
|
const updatePreview = () => {
|
||||||
const content = app.composer.component.content();
|
const content = app.composer.fields.content();
|
||||||
|
|
||||||
if (preview === content) return;
|
if (preview === content) return;
|
||||||
|
|
||||||
@@ -129,13 +134,27 @@ export default class CommentPost extends Post {
|
|||||||
headerItems() {
|
headerItems() {
|
||||||
const items = new ItemList();
|
const items = new ItemList();
|
||||||
const post = this.props.post;
|
const post = this.props.post;
|
||||||
const props = { post };
|
|
||||||
|
|
||||||
items.add('user', this.postUser.render(), 100);
|
items.add(
|
||||||
items.add('meta', PostMeta.component(props));
|
'user',
|
||||||
|
PostUser.component({
|
||||||
|
post,
|
||||||
|
cardVisible: this.cardVisible,
|
||||||
|
oncardshow: () => {
|
||||||
|
this.cardVisible = true;
|
||||||
|
m.redraw();
|
||||||
|
},
|
||||||
|
oncardhide: () => {
|
||||||
|
this.cardVisible = false;
|
||||||
|
m.redraw();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
100
|
||||||
|
);
|
||||||
|
items.add('meta', PostMeta.component({ post }));
|
||||||
|
|
||||||
if (post.isEdited() && !post.isHidden()) {
|
if (post.isEdited() && !post.isHidden()) {
|
||||||
items.add('edited', PostEdited.component(props));
|
items.add('edited', PostEdited.component({ post }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the post is hidden, add a button that allows toggling the visibility
|
// If the post is hidden, add a button that allows toggling the visibility
|
||||||
|
@@ -3,28 +3,21 @@ import ItemList from '../../common/utils/ItemList';
|
|||||||
import ComposerButton from './ComposerButton';
|
import ComposerButton from './ComposerButton';
|
||||||
import listItems from '../../common/helpers/listItems';
|
import listItems from '../../common/helpers/listItems';
|
||||||
import classList from '../../common/utils/classList';
|
import classList from '../../common/utils/classList';
|
||||||
|
import ComposerState from '../states/ComposerState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `Composer` component displays the composer. It can be loaded with a
|
* The `Composer` component displays the composer. It can be loaded with a
|
||||||
* content component with `load` and then its position/state can be altered with
|
* content component with `load` and then its position/state can be altered with
|
||||||
* `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`.
|
* `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`.
|
||||||
*/
|
*/
|
||||||
class Composer extends Component {
|
export default class Composer extends Component {
|
||||||
init() {
|
init() {
|
||||||
/**
|
/**
|
||||||
* The composer's current position.
|
* The composer's "state".
|
||||||
*
|
*
|
||||||
* @type {Composer.PositionEnum}
|
* @type {ComposerState}
|
||||||
*/
|
*/
|
||||||
this.position = Composer.PositionEnum.HIDDEN;
|
this.state = this.props.state;
|
||||||
|
|
||||||
/**
|
|
||||||
* The composer's intended height, which can be modified by the user
|
|
||||||
* (by dragging the composer handle).
|
|
||||||
*
|
|
||||||
* @type {Integer}
|
|
||||||
*/
|
|
||||||
this.height = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether or not the composer currently has focus.
|
* Whether or not the composer currently has focus.
|
||||||
@@ -32,39 +25,45 @@ class Composer extends Component {
|
|||||||
* @type {Boolean}
|
* @type {Boolean}
|
||||||
*/
|
*/
|
||||||
this.active = false;
|
this.active = false;
|
||||||
|
|
||||||
|
// Store the initial position so that we can trigger animations correctly.
|
||||||
|
this.prevPosition = this.state.position;
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
|
const body = this.state.body;
|
||||||
const classes = {
|
const classes = {
|
||||||
normal: this.position === Composer.PositionEnum.NORMAL,
|
normal: this.state.position === ComposerState.Position.NORMAL,
|
||||||
minimized: this.position === Composer.PositionEnum.MINIMIZED,
|
minimized: this.state.position === ComposerState.Position.MINIMIZED,
|
||||||
fullScreen: this.position === Composer.PositionEnum.FULLSCREEN,
|
fullScreen: this.state.position === ComposerState.Position.FULLSCREEN,
|
||||||
active: this.active,
|
active: this.active,
|
||||||
|
visible: this.state.isVisible(),
|
||||||
};
|
};
|
||||||
classes.visible = classes.normal || classes.minimized || classes.fullScreen;
|
|
||||||
|
|
||||||
// If the composer is minimized, tell the composer's content component that
|
// Set up a handler so that clicks on the content will show the composer.
|
||||||
// it shouldn't let the user interact with it. Set up a handler so that if
|
const showIfMinimized = this.state.position === ComposerState.Position.MINIMIZED ? this.state.show.bind(this.state) : undefined;
|
||||||
// the content IS clicked, the composer will be shown.
|
|
||||||
if (this.component) this.component.props.disabled = classes.minimized;
|
|
||||||
|
|
||||||
const showIfMinimized = this.position === Composer.PositionEnum.MINIMIZED ? this.show.bind(this) : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'Composer ' + classList(classes)}>
|
<div className={'Composer ' + classList(classes)}>
|
||||||
<div className="Composer-handle" config={this.configHandle.bind(this)} />
|
<div className="Composer-handle" config={this.configHandle.bind(this)} />
|
||||||
<ul className="Composer-controls">{listItems(this.controlItems().toArray())}</ul>
|
<ul className="Composer-controls">{listItems(this.controlItems().toArray())}</ul>
|
||||||
<div className="Composer-content" onclick={showIfMinimized}>
|
<div className="Composer-content" onclick={showIfMinimized}>
|
||||||
{this.component ? this.component.render() : ''}
|
{body.componentClass ? body.componentClass.component({ ...body.attrs, composer: this.state, disabled: classes.minimized }) : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
config(isInitialized, context) {
|
config(isInitialized, context) {
|
||||||
|
if (this.state.position === this.prevPosition) {
|
||||||
// Set the height of the Composer element and its contents on each redraw,
|
// Set the height of the Composer element and its contents on each redraw,
|
||||||
// so that they do not lose it if their DOM elements are recreated.
|
// so that they do not lose it if their DOM elements are recreated.
|
||||||
this.updateHeight();
|
this.updateHeight();
|
||||||
|
} else {
|
||||||
|
this.animatePositionChange();
|
||||||
|
|
||||||
|
this.prevPosition = this.state.position;
|
||||||
|
}
|
||||||
|
|
||||||
if (isInitialized) return;
|
if (isInitialized) return;
|
||||||
|
|
||||||
@@ -73,7 +72,7 @@ class Composer extends Component {
|
|||||||
context.retain = true;
|
context.retain = true;
|
||||||
|
|
||||||
this.initializeHeight();
|
this.initializeHeight();
|
||||||
this.$().hide().css('bottom', -this.computedHeight());
|
this.$().hide().css('bottom', -this.state.computedHeight());
|
||||||
|
|
||||||
// Whenever any of the inputs inside the composer are have focus, we want to
|
// Whenever any of the inputs inside the composer are have focus, we want to
|
||||||
// add a class to the composer to draw attention to it.
|
// add a class to the composer to draw attention to it.
|
||||||
@@ -85,13 +84,6 @@ class Composer extends Component {
|
|||||||
// When the escape key is pressed on any inputs, close the composer.
|
// When the escape key is pressed on any inputs, close the composer.
|
||||||
this.$().on('keydown', ':input', 'esc', () => this.close());
|
this.$().on('keydown', ':input', 'esc', () => this.close());
|
||||||
|
|
||||||
// Don't let the user leave the page without first giving the composer's
|
|
||||||
// component a chance to scream at the user to make sure they don't
|
|
||||||
// unintentionally lose any contnet.
|
|
||||||
window.onbeforeunload = () => {
|
|
||||||
return (this.component && this.component.preventExit()) || undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlers = {};
|
const handlers = {};
|
||||||
|
|
||||||
$(window)
|
$(window)
|
||||||
@@ -166,13 +158,20 @@ class Composer extends Component {
|
|||||||
$('body').css('cursor', '');
|
$('body').css('cursor', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw focus to the first focusable content element (the text editor).
|
||||||
|
*/
|
||||||
|
focus() {
|
||||||
|
this.$('.Composer-content :input:enabled:visible:first').focus();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the DOM to reflect the composer's current height. This involves
|
* Update the DOM to reflect the composer's current height. This involves
|
||||||
* setting the height of the composer's root element, and adjusting the height
|
* setting the height of the composer's root element, and adjusting the height
|
||||||
* of any flexible elements inside the composer's body.
|
* of any flexible elements inside the composer's body.
|
||||||
*/
|
*/
|
||||||
updateHeight() {
|
updateHeight() {
|
||||||
const height = this.computedHeight();
|
const height = this.state.computedHeight();
|
||||||
const $flexible = this.$('.Composer-flexible');
|
const $flexible = this.$('.Composer-flexible');
|
||||||
|
|
||||||
this.$().height(height);
|
this.$().height(height);
|
||||||
@@ -193,109 +192,59 @@ class Composer extends Component {
|
|||||||
*/
|
*/
|
||||||
updateBodyPadding() {
|
updateBodyPadding() {
|
||||||
const visible =
|
const visible =
|
||||||
this.position !== Composer.PositionEnum.HIDDEN && this.position !== Composer.PositionEnum.MINIMIZED && this.$().css('position') !== 'absolute';
|
this.state.position !== ComposerState.Position.HIDDEN && this.state.position !== ComposerState.Position.MINIMIZED && app.screen() !== 'phone';
|
||||||
|
|
||||||
const paddingBottom = visible ? this.computedHeight() - parseInt($('#app').css('padding-bottom'), 10) : 0;
|
const paddingBottom = visible ? this.state.computedHeight() - parseInt($('#app').css('padding-bottom'), 10) : 0;
|
||||||
|
|
||||||
$('#content').css({ paddingBottom });
|
$('#content').css({ paddingBottom });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine whether or not the Composer is covering the screen.
|
* Trigger the right animation depending on the desired new position.
|
||||||
*
|
|
||||||
* This will be true if the Composer is in full-screen mode on desktop, or
|
|
||||||
* if the Composer is positioned absolutely as on mobile devices.
|
|
||||||
*
|
|
||||||
* @return {Boolean}
|
|
||||||
* @public
|
|
||||||
*/
|
*/
|
||||||
isFullScreen() {
|
animatePositionChange() {
|
||||||
return this.position === Composer.PositionEnum.FULLSCREEN || this.$().css('position') === 'absolute';
|
// When exiting full-screen mode: focus content
|
||||||
|
if (this.prevPosition === ComposerState.Position.FULLSCREEN) {
|
||||||
|
this.focus();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
switch (this.state.position) {
|
||||||
* Confirm with the user that they want to close the composer and lose their
|
case ComposerState.Position.HIDDEN:
|
||||||
* content.
|
return this.hide();
|
||||||
*
|
case ComposerState.Position.MINIMIZED:
|
||||||
* @return {Boolean} Whether or not the exit was cancelled.
|
return this.minimize();
|
||||||
*/
|
case ComposerState.Position.FULLSCREEN:
|
||||||
preventExit() {
|
return this.focus();
|
||||||
if (this.component) {
|
case ComposerState.Position.NORMAL:
|
||||||
const preventExit = this.component.preventExit();
|
return this.show();
|
||||||
|
|
||||||
if (preventExit) {
|
|
||||||
return !confirm(preventExit);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load a content component into the composer.
|
* Animate the Composer into the new position by changing the height.
|
||||||
*
|
|
||||||
* @param {Component} component
|
|
||||||
* @public
|
|
||||||
*/
|
*/
|
||||||
load(component) {
|
animateHeightChange() {
|
||||||
if (this.preventExit()) return;
|
|
||||||
|
|
||||||
// If we load a similar component into the composer, then Mithril will be
|
|
||||||
// able to diff the old/new contents and some DOM-related state from the
|
|
||||||
// old composer will remain. To prevent this from happening, we clear the
|
|
||||||
// component and force a redraw, so that the new component will be working
|
|
||||||
// on a blank slate.
|
|
||||||
if (this.component) {
|
|
||||||
this.clear();
|
|
||||||
m.redraw(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.component = component;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the composer's content component.
|
|
||||||
*
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
clear() {
|
|
||||||
this.component = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Animate the Composer into the given position.
|
|
||||||
*
|
|
||||||
* @param {Composer.PositionEnum} position
|
|
||||||
*/
|
|
||||||
animateToPosition(position) {
|
|
||||||
// Before we redraw the composer to its new state, we need to save the
|
|
||||||
// current height of the composer, as well as the page's scroll position, so
|
|
||||||
// that we can smoothly transition from the old to the new state.
|
|
||||||
const oldPosition = this.position;
|
|
||||||
const $composer = this.$().stop(true);
|
const $composer = this.$().stop(true);
|
||||||
const oldHeight = $composer.outerHeight();
|
const oldHeight = $composer.outerHeight();
|
||||||
const scrollTop = $(window).scrollTop();
|
const scrollTop = $(window).scrollTop();
|
||||||
|
|
||||||
this.position = position;
|
|
||||||
|
|
||||||
m.redraw(true);
|
|
||||||
|
|
||||||
// Now that we've redrawn and the composer's DOM has been updated, we want
|
|
||||||
// to update the composer's height. Once we've done that, we'll capture the
|
|
||||||
// real value to use as the end point for our animation later on.
|
|
||||||
$composer.show();
|
$composer.show();
|
||||||
this.updateHeight();
|
this.updateHeight();
|
||||||
|
|
||||||
const newHeight = $composer.outerHeight();
|
const newHeight = $composer.outerHeight();
|
||||||
|
|
||||||
if (oldPosition === Composer.PositionEnum.HIDDEN) {
|
if (this.prevPosition === ComposerState.Position.HIDDEN) {
|
||||||
$composer.css({ bottom: -newHeight, height: newHeight });
|
$composer.css({ bottom: -newHeight, height: newHeight });
|
||||||
} else {
|
} else {
|
||||||
$composer.css({ height: oldHeight });
|
$composer.css({ height: oldHeight });
|
||||||
}
|
}
|
||||||
|
|
||||||
$composer.animate({ bottom: 0, height: newHeight }, 'fast', () => this.component.focus());
|
const animation = $composer.animate({ bottom: 0, height: newHeight }, 'fast').promise();
|
||||||
|
|
||||||
this.updateBodyPadding();
|
this.updateBodyPadding();
|
||||||
$(window).scrollTop(scrollTop);
|
$(window).scrollTop(scrollTop);
|
||||||
|
return animation;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -313,40 +262,30 @@ class Composer extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the composer.
|
* Animate the composer sliding up from the bottom to take its normal height.
|
||||||
*
|
*
|
||||||
* @public
|
* @private
|
||||||
*/
|
*/
|
||||||
show() {
|
show() {
|
||||||
if (this.position === Composer.PositionEnum.NORMAL || this.position === Composer.PositionEnum.FULLSCREEN) {
|
this.animateHeightChange().then(() => this.focus());
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.animateToPosition(Composer.PositionEnum.NORMAL);
|
if (app.screen() === 'phone') {
|
||||||
|
|
||||||
if (this.isFullScreen()) {
|
|
||||||
this.$().css('top', $(window).scrollTop());
|
this.$().css('top', $(window).scrollTop());
|
||||||
this.showBackdrop();
|
this.showBackdrop();
|
||||||
this.component.focus();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Close the composer.
|
* Animate closing the composer.
|
||||||
*
|
*
|
||||||
* @public
|
* @private
|
||||||
*/
|
*/
|
||||||
hide() {
|
hide() {
|
||||||
const $composer = this.$();
|
const $composer = this.$();
|
||||||
|
|
||||||
// Animate the composer sliding down off the bottom edge of the viewport.
|
// Animate the composer sliding down off the bottom edge of the viewport.
|
||||||
// Only when the animation is completed, update the Composer state flag and
|
// Only when the animation is completed, update other elements on the page.
|
||||||
// other elements on the page.
|
|
||||||
$composer.stop(true).animate({ bottom: -$composer.height() }, 'fast', () => {
|
$composer.stop(true).animate({ bottom: -$composer.height() }, 'fast', () => {
|
||||||
this.position = Composer.PositionEnum.HIDDEN;
|
|
||||||
this.clear();
|
|
||||||
m.redraw();
|
|
||||||
|
|
||||||
$composer.hide();
|
$composer.hide();
|
||||||
this.hideBackdrop();
|
this.hideBackdrop();
|
||||||
this.updateBodyPadding();
|
this.updateBodyPadding();
|
||||||
@@ -354,60 +293,17 @@ class Composer extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Confirm with the user so they don't lose their content, then close the
|
* Shrink the composer until only its title is visible.
|
||||||
* composer.
|
|
||||||
*
|
*
|
||||||
* @public
|
* @private
|
||||||
*/
|
|
||||||
close() {
|
|
||||||
if (!this.preventExit()) {
|
|
||||||
this.hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimize the composer. Has no effect if the composer is hidden.
|
|
||||||
*
|
|
||||||
* @public
|
|
||||||
*/
|
*/
|
||||||
minimize() {
|
minimize() {
|
||||||
if (this.position === Composer.PositionEnum.HIDDEN) return;
|
this.animateHeightChange();
|
||||||
|
|
||||||
this.animateToPosition(Composer.PositionEnum.MINIMIZED);
|
|
||||||
|
|
||||||
this.$().css('top', 'auto');
|
this.$().css('top', 'auto');
|
||||||
this.hideBackdrop();
|
this.hideBackdrop();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Take the composer into fullscreen mode. Has no effect if the composer is
|
|
||||||
* hidden.
|
|
||||||
*
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
fullScreen() {
|
|
||||||
if (this.position !== Composer.PositionEnum.HIDDEN) {
|
|
||||||
this.position = Composer.PositionEnum.FULLSCREEN;
|
|
||||||
m.redraw();
|
|
||||||
this.updateHeight();
|
|
||||||
this.component.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exit fullscreen mode.
|
|
||||||
*
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
exitFullScreen() {
|
|
||||||
if (this.position === Composer.PositionEnum.FULLSCREEN) {
|
|
||||||
this.position = Composer.PositionEnum.NORMAL;
|
|
||||||
m.redraw();
|
|
||||||
this.updateHeight();
|
|
||||||
this.component.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build an item list for the composer's controls.
|
* Build an item list for the composer's controls.
|
||||||
*
|
*
|
||||||
@@ -416,23 +312,23 @@ class Composer extends Component {
|
|||||||
controlItems() {
|
controlItems() {
|
||||||
const items = new ItemList();
|
const items = new ItemList();
|
||||||
|
|
||||||
if (this.position === Composer.PositionEnum.FULLSCREEN) {
|
if (this.state.position === ComposerState.Position.FULLSCREEN) {
|
||||||
items.add(
|
items.add(
|
||||||
'exitFullScreen',
|
'exitFullScreen',
|
||||||
ComposerButton.component({
|
ComposerButton.component({
|
||||||
icon: 'fas fa-compress',
|
icon: 'fas fa-compress',
|
||||||
title: app.translator.trans('core.forum.composer.exit_full_screen_tooltip'),
|
title: app.translator.trans('core.forum.composer.exit_full_screen_tooltip'),
|
||||||
onclick: this.exitFullScreen.bind(this),
|
onclick: this.state.exitFullScreen.bind(this.state),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
if (this.position !== Composer.PositionEnum.MINIMIZED) {
|
if (this.state.position !== ComposerState.Position.MINIMIZED) {
|
||||||
items.add(
|
items.add(
|
||||||
'minimize',
|
'minimize',
|
||||||
ComposerButton.component({
|
ComposerButton.component({
|
||||||
icon: 'fas fa-minus minimize',
|
icon: 'fas fa-minus minimize',
|
||||||
title: app.translator.trans('core.forum.composer.minimize_tooltip'),
|
title: app.translator.trans('core.forum.composer.minimize_tooltip'),
|
||||||
onclick: this.minimize.bind(this),
|
onclick: this.state.minimize.bind(this.state),
|
||||||
itemClassName: 'App-backControl',
|
itemClassName: 'App-backControl',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -442,7 +338,7 @@ class Composer extends Component {
|
|||||||
ComposerButton.component({
|
ComposerButton.component({
|
||||||
icon: 'fas fa-expand',
|
icon: 'fas fa-expand',
|
||||||
title: app.translator.trans('core.forum.composer.full_screen_tooltip'),
|
title: app.translator.trans('core.forum.composer.full_screen_tooltip'),
|
||||||
onclick: this.fullScreen.bind(this),
|
onclick: this.state.fullScreen.bind(this.state),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -452,7 +348,7 @@ class Composer extends Component {
|
|||||||
ComposerButton.component({
|
ComposerButton.component({
|
||||||
icon: 'fas fa-times',
|
icon: 'fas fa-times',
|
||||||
title: app.translator.trans('core.forum.composer.close_tooltip'),
|
title: app.translator.trans('core.forum.composer.close_tooltip'),
|
||||||
onclick: this.close.bind(this),
|
onclick: this.state.close.bind(this.state),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -464,10 +360,10 @@ class Composer extends Component {
|
|||||||
* Initialize default Composer height.
|
* Initialize default Composer height.
|
||||||
*/
|
*/
|
||||||
initializeHeight() {
|
initializeHeight() {
|
||||||
this.height = localStorage.getItem('composerHeight');
|
this.state.height = localStorage.getItem('composerHeight');
|
||||||
|
|
||||||
if (!this.height) {
|
if (!this.state.height) {
|
||||||
this.height = this.defaultHeight();
|
this.state.height = this.defaultHeight();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,60 +375,14 @@ class Composer extends Component {
|
|||||||
return this.$().height();
|
return this.$().height();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimum height of the Composer.
|
|
||||||
* @returns {Integer}
|
|
||||||
*/
|
|
||||||
minimumHeight() {
|
|
||||||
return 200;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maxmimum height of the Composer.
|
|
||||||
* @returns {Integer}
|
|
||||||
*/
|
|
||||||
maximumHeight() {
|
|
||||||
return $(window).height() - $('#header').outerHeight();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computed the composer's current height, based on the intended height, and
|
|
||||||
* the composer's current state. This will be applied to the composer's
|
|
||||||
* content's DOM element.
|
|
||||||
* @returns {Integer|String}
|
|
||||||
*/
|
|
||||||
computedHeight() {
|
|
||||||
// If the composer is minimized, then we don't want to set a height; we'll
|
|
||||||
// let the CSS decide how high it is. If it's fullscreen, then we need to
|
|
||||||
// make it as high as the window.
|
|
||||||
if (this.position === Composer.PositionEnum.MINIMIZED) {
|
|
||||||
return '';
|
|
||||||
} else if (this.position === Composer.PositionEnum.FULLSCREEN) {
|
|
||||||
return $(window).height();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, if it's normal or hidden, then we use the intended height.
|
|
||||||
// We don't let the composer get too small or too big, though.
|
|
||||||
return Math.max(this.minimumHeight(), Math.min(this.height, this.maximumHeight()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save a new Composer height and update the DOM.
|
* Save a new Composer height and update the DOM.
|
||||||
* @param {Integer} height
|
* @param {Integer} height
|
||||||
*/
|
*/
|
||||||
changeHeight(height) {
|
changeHeight(height) {
|
||||||
this.height = height;
|
this.state.height = height;
|
||||||
this.updateHeight();
|
this.updateHeight();
|
||||||
|
|
||||||
localStorage.setItem('composerHeight', this.height);
|
localStorage.setItem('composerHeight', this.state.height);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Composer.PositionEnum = {
|
|
||||||
HIDDEN: 'hidden',
|
|
||||||
NORMAL: 'normal',
|
|
||||||
MINIMIZED: 'minimized',
|
|
||||||
FULLSCREEN: 'fullScreen',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Composer;
|
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import Component from '../../common/Component';
|
import Component from '../../common/Component';
|
||||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||||
|
import ConfirmDocumentUnload from '../../common/components/ConfirmDocumentUnload';
|
||||||
import TextEditor from './TextEditor';
|
import TextEditor from './TextEditor';
|
||||||
import avatar from '../../common/helpers/avatar';
|
import avatar from '../../common/helpers/avatar';
|
||||||
import listItems from '../../common/helpers/listItems';
|
import listItems from '../../common/helpers/listItems';
|
||||||
@@ -12,6 +13,7 @@ import ItemList from '../../common/utils/ItemList';
|
|||||||
*
|
*
|
||||||
* ### Props
|
* ### Props
|
||||||
*
|
*
|
||||||
|
* - `composer`
|
||||||
* - `originalContent`
|
* - `originalContent`
|
||||||
* - `submitLabel`
|
* - `submitLabel`
|
||||||
* - `placeholder`
|
* - `placeholder`
|
||||||
@@ -23,6 +25,8 @@ import ItemList from '../../common/utils/ItemList';
|
|||||||
*/
|
*/
|
||||||
export default class ComposerBody extends Component {
|
export default class ComposerBody extends Component {
|
||||||
init() {
|
init() {
|
||||||
|
this.composer = this.props.composer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether or not the component is loading.
|
* Whether or not the component is loading.
|
||||||
*
|
*
|
||||||
@@ -30,60 +34,57 @@ export default class ComposerBody extends Component {
|
|||||||
*/
|
*/
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
|
||||||
/**
|
// Let the composer state know to ask for confirmation under certain
|
||||||
* The content of the text editor.
|
// circumstances, if the body supports / requires it and has a corresponding
|
||||||
*
|
// confirmation question to ask.
|
||||||
* @type {Function}
|
if (this.props.confirmExit) {
|
||||||
*/
|
this.composer.preventClosingWhen(() => this.hasChanges(), this.props.confirmExit);
|
||||||
this.content = m.prop(this.props.originalContent);
|
}
|
||||||
|
|
||||||
|
this.composer.fields.content(this.props.originalContent || '');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The text editor component instance.
|
* @deprecated BC layer, remove in Beta 15.
|
||||||
*
|
|
||||||
* @type {TextEditor}
|
|
||||||
*/
|
*/
|
||||||
this.editor = new TextEditor({
|
this.content = this.composer.fields.content;
|
||||||
submitLabel: this.props.submitLabel,
|
this.editor = this.composer;
|
||||||
placeholder: this.props.placeholder,
|
|
||||||
onchange: this.content,
|
|
||||||
onsubmit: this.onsubmit.bind(this),
|
|
||||||
value: this.content(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
// If the component is loading, we should disable the text editor.
|
|
||||||
this.editor.props.disabled = this.loading;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ConfirmDocumentUnload when={this.hasChanges.bind(this)}>
|
||||||
<div className={'ComposerBody ' + (this.props.className || '')}>
|
<div className={'ComposerBody ' + (this.props.className || '')}>
|
||||||
{avatar(this.props.user, { className: 'ComposerBody-avatar' })}
|
{avatar(this.props.user, { className: 'ComposerBody-avatar' })}
|
||||||
<div className="ComposerBody-content">
|
<div className="ComposerBody-content">
|
||||||
<ul className="ComposerBody-header">{listItems(this.headerItems().toArray())}</ul>
|
<ul className="ComposerBody-header">{listItems(this.headerItems().toArray())}</ul>
|
||||||
<div className="ComposerBody-editor">{this.editor.render()}</div>
|
<div className="ComposerBody-editor">
|
||||||
|
{TextEditor.component({
|
||||||
|
submitLabel: this.props.submitLabel,
|
||||||
|
placeholder: this.props.placeholder,
|
||||||
|
disabled: this.loading || this.props.disabled,
|
||||||
|
composer: this.composer,
|
||||||
|
preview: this.jumpToPreview && this.jumpToPreview.bind(this),
|
||||||
|
onchange: this.composer.fields.content,
|
||||||
|
onsubmit: this.onsubmit.bind(this),
|
||||||
|
value: this.composer.fields.content(),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{LoadingIndicator.component({ className: 'ComposerBody-loading' + (this.loading ? ' active' : '') })}
|
{LoadingIndicator.component({ className: 'ComposerBody-loading' + (this.loading ? ' active' : '') })}
|
||||||
</div>
|
</div>
|
||||||
|
</ConfirmDocumentUnload>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Draw focus to the text editor.
|
* Check if there is any unsaved data.
|
||||||
*/
|
|
||||||
focus() {
|
|
||||||
this.$(':input:enabled:visible:first').focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if there is any unsaved data – if there is, return a confirmation
|
|
||||||
* message to prompt the user with.
|
|
||||||
*
|
*
|
||||||
* @return {String}
|
* @return {String}
|
||||||
*/
|
*/
|
||||||
preventExit() {
|
hasChanges() {
|
||||||
const content = this.content();
|
const content = this.composer.fields.content();
|
||||||
|
|
||||||
return content && content !== this.props.originalContent && this.props.confirmExit;
|
return content && content !== this.props.originalContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -16,12 +16,14 @@ export default class DiscussionComposer extends ComposerBody {
|
|||||||
init() {
|
init() {
|
||||||
super.init();
|
super.init();
|
||||||
|
|
||||||
|
this.composer.fields.title = this.composer.fields.title || m.prop('');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The value of the title input.
|
* The value of the title input.
|
||||||
*
|
*
|
||||||
* @type {Function}
|
* @type {Function}
|
||||||
*/
|
*/
|
||||||
this.title = m.prop('');
|
this.title = this.composer.fields.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
static initProps(props) {
|
static initProps(props) {
|
||||||
@@ -66,14 +68,14 @@ export default class DiscussionComposer extends ComposerBody {
|
|||||||
if (e.which === 13) {
|
if (e.which === 13) {
|
||||||
// Return
|
// Return
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.editor.setSelectionRange(0, 0);
|
this.composer.editor.moveCursorTo(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
m.redraw.strategy('none');
|
m.redraw.strategy('none');
|
||||||
}
|
}
|
||||||
|
|
||||||
preventExit() {
|
hasChanges() {
|
||||||
return (this.title() || this.content()) && this.props.confirmExit;
|
return this.title() || this.composer.fields.content();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,7 +86,7 @@ export default class DiscussionComposer extends ComposerBody {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
title: this.title(),
|
title: this.title(),
|
||||||
content: this.content(),
|
content: this.composer.fields.content(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,8 +99,8 @@ export default class DiscussionComposer extends ComposerBody {
|
|||||||
.createRecord('discussions')
|
.createRecord('discussions')
|
||||||
.save(data)
|
.save(data)
|
||||||
.then((discussion) => {
|
.then((discussion) => {
|
||||||
app.composer.hide();
|
this.composer.hide();
|
||||||
app.cache.discussionList.refresh();
|
app.discussions.refresh();
|
||||||
m.route(app.route.discussion(discussion));
|
m.route(app.route.discussion(discussion));
|
||||||
}, this.loaded.bind(this));
|
}, this.loaded.bind(this));
|
||||||
}
|
}
|
||||||
|
@@ -9,58 +9,38 @@ import Placeholder from '../../common/components/Placeholder';
|
|||||||
*
|
*
|
||||||
* ### Props
|
* ### Props
|
||||||
*
|
*
|
||||||
* - `params` A map of parameters used to construct a refined parameter object
|
* - `state` A DiscussionListState object that represents the discussion lists's state.
|
||||||
* to send along in the API request to get discussion results.
|
|
||||||
*/
|
*/
|
||||||
export default class DiscussionList extends Component {
|
export default class DiscussionList extends Component {
|
||||||
init() {
|
init() {
|
||||||
/**
|
this.state = this.props.state;
|
||||||
* Whether or not discussion results are loading.
|
|
||||||
*
|
|
||||||
* @type {Boolean}
|
|
||||||
*/
|
|
||||||
this.loading = true;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether or not there are more results that can be loaded.
|
|
||||||
*
|
|
||||||
* @type {Boolean}
|
|
||||||
*/
|
|
||||||
this.moreResults = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The discussions in the discussion list.
|
|
||||||
*
|
|
||||||
* @type {Discussion[]}
|
|
||||||
*/
|
|
||||||
this.discussions = [];
|
|
||||||
|
|
||||||
this.refresh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
const params = this.props.params;
|
const state = this.state;
|
||||||
|
|
||||||
|
const params = state.getParams();
|
||||||
let loading;
|
let loading;
|
||||||
|
|
||||||
if (this.loading) {
|
if (state.isLoading()) {
|
||||||
loading = LoadingIndicator.component();
|
loading = LoadingIndicator.component();
|
||||||
} else if (this.moreResults) {
|
} else if (state.moreResults) {
|
||||||
loading = Button.component({
|
loading = Button.component({
|
||||||
children: app.translator.trans('core.forum.discussion_list.load_more_button'),
|
children: app.translator.trans('core.forum.discussion_list.load_more_button'),
|
||||||
className: 'Button',
|
className: 'Button',
|
||||||
onclick: this.loadMore.bind(this),
|
onclick: state.loadMore.bind(state),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.discussions.length === 0 && !this.loading) {
|
if (state.empty()) {
|
||||||
const text = app.translator.trans('core.forum.discussion_list.empty_text');
|
const text = app.translator.trans('core.forum.discussion_list.empty_text');
|
||||||
return <div className="DiscussionList">{Placeholder.component({ text })}</div>;
|
return <div className="DiscussionList">{Placeholder.component({ text })}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'DiscussionList' + (this.props.params.q ? ' DiscussionList--searchResults' : '')}>
|
<div className={'DiscussionList' + (state.isSearchResults() ? ' DiscussionList--searchResults' : '')}>
|
||||||
<ul className="DiscussionList-discussions">
|
<ul className="DiscussionList-discussions">
|
||||||
{this.discussions.map((discussion) => {
|
{state.discussions.map((discussion) => {
|
||||||
return (
|
return (
|
||||||
<li key={discussion.id()} data-id={discussion.id()}>
|
<li key={discussion.id()} data-id={discussion.id()}>
|
||||||
{DiscussionListItem.component({ discussion, params })}
|
{DiscussionListItem.component({ discussion, params })}
|
||||||
@@ -72,140 +52,4 @@ export default class DiscussionList extends Component {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the parameters that should be passed in the API request to get
|
|
||||||
* discussion results.
|
|
||||||
*
|
|
||||||
* @return {Object}
|
|
||||||
* @api
|
|
||||||
*/
|
|
||||||
requestParams() {
|
|
||||||
const params = { include: ['user', 'lastPostedUser'], filter: {} };
|
|
||||||
|
|
||||||
params.sort = this.sortMap()[this.props.params.sort];
|
|
||||||
|
|
||||||
if (this.props.params.q) {
|
|
||||||
params.filter.q = this.props.params.q;
|
|
||||||
|
|
||||||
params.include.push('mostRelevantPost', 'mostRelevantPost.user');
|
|
||||||
}
|
|
||||||
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a map of sort keys (which appear in the URL, and are used for
|
|
||||||
* translation) to the API sort value that they represent.
|
|
||||||
*
|
|
||||||
* @return {Object}
|
|
||||||
*/
|
|
||||||
sortMap() {
|
|
||||||
const map = {};
|
|
||||||
|
|
||||||
if (this.props.params.q) {
|
|
||||||
map.relevance = '';
|
|
||||||
}
|
|
||||||
map.latest = '-lastPostedAt';
|
|
||||||
map.top = '-commentCount';
|
|
||||||
map.newest = '-createdAt';
|
|
||||||
map.oldest = 'createdAt';
|
|
||||||
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear and reload the discussion list.
|
|
||||||
*
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
refresh(clear = true) {
|
|
||||||
if (clear) {
|
|
||||||
this.loading = true;
|
|
||||||
this.discussions = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.loadResults().then(
|
|
||||||
(results) => {
|
|
||||||
this.discussions = [];
|
|
||||||
this.parseResults(results);
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
this.loading = false;
|
|
||||||
m.redraw();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load a new page of discussion results.
|
|
||||||
*
|
|
||||||
* @param {Integer} offset The index to start the page at.
|
|
||||||
* @return {Promise}
|
|
||||||
*/
|
|
||||||
loadResults(offset) {
|
|
||||||
const preloadedDiscussions = app.preloadedApiDocument();
|
|
||||||
|
|
||||||
if (preloadedDiscussions) {
|
|
||||||
return m.deferred().resolve(preloadedDiscussions).promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = this.requestParams();
|
|
||||||
params.page = { offset };
|
|
||||||
params.include = params.include.join(',');
|
|
||||||
|
|
||||||
return app.store.find('discussions', params);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the next page of discussion results.
|
|
||||||
*
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
loadMore() {
|
|
||||||
this.loading = true;
|
|
||||||
|
|
||||||
this.loadResults(this.discussions.length).then(this.parseResults.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse results and append them to the discussion list.
|
|
||||||
*
|
|
||||||
* @param {Discussion[]} results
|
|
||||||
* @return {Discussion[]}
|
|
||||||
*/
|
|
||||||
parseResults(results) {
|
|
||||||
[].push.apply(this.discussions, results);
|
|
||||||
|
|
||||||
this.loading = false;
|
|
||||||
this.moreResults = !!results.payload.links.next;
|
|
||||||
|
|
||||||
m.lazyRedraw();
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a discussion from the list if it is present.
|
|
||||||
*
|
|
||||||
* @param {Discussion} discussion
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
removeDiscussion(discussion) {
|
|
||||||
const index = this.discussions.indexOf(discussion);
|
|
||||||
|
|
||||||
if (index !== -1) {
|
|
||||||
this.discussions.splice(index, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a discussion to the top of the list.
|
|
||||||
*
|
|
||||||
* @param {Discussion} discussion
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
addDiscussion(discussion) {
|
|
||||||
this.discussions.unshift(discussion);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import Page from './Page';
|
import Page from '../../common/components/Page';
|
||||||
import ItemList from '../../common/utils/ItemList';
|
import ItemList from '../../common/utils/ItemList';
|
||||||
import DiscussionHero from './DiscussionHero';
|
import DiscussionHero from './DiscussionHero';
|
||||||
import PostStream from './PostStream';
|
import PostStream from './PostStream';
|
||||||
@@ -7,6 +7,9 @@ import LoadingIndicator from '../../common/components/LoadingIndicator';
|
|||||||
import SplitDropdown from '../../common/components/SplitDropdown';
|
import SplitDropdown from '../../common/components/SplitDropdown';
|
||||||
import listItems from '../../common/helpers/listItems';
|
import listItems from '../../common/helpers/listItems';
|
||||||
import DiscussionControls from '../utils/DiscussionControls';
|
import DiscussionControls from '../utils/DiscussionControls';
|
||||||
|
import DiscussionList from './DiscussionList';
|
||||||
|
import PostStreamState from '../states/PostStreamState';
|
||||||
|
import ScrollListener from '../../common/utils/ScrollListener';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `DiscussionPage` component displays a whole discussion page, including
|
* The `DiscussionPage` component displays a whole discussion page, including
|
||||||
@@ -26,22 +29,24 @@ export default class DiscussionPage extends Page {
|
|||||||
/**
|
/**
|
||||||
* The number of the first post that is currently visible in the viewport.
|
* The number of the first post that is currently visible in the viewport.
|
||||||
*
|
*
|
||||||
* @type {Integer}
|
* @type {number}
|
||||||
*/
|
*/
|
||||||
this.near = null;
|
this.near = m.route.param('near') || 0;
|
||||||
|
|
||||||
this.refresh();
|
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
|
||||||
|
|
||||||
|
this.load();
|
||||||
|
|
||||||
// If the discussion list has been loaded, then we'll enable the pane (and
|
// 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
|
// 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,
|
// page, then we don't want Mithril to redraw the whole page – if it did,
|
||||||
// then the pane would which would be slow and would cause problems with
|
// then the pane would redraw which would be slow and would cause problems with
|
||||||
// event handlers.
|
// event handlers.
|
||||||
if (app.cache.discussionList) {
|
if (app.discussions.hasDiscussions()) {
|
||||||
app.pane.enable();
|
app.pane.enable();
|
||||||
app.pane.hide();
|
app.pane.hide();
|
||||||
|
|
||||||
if (app.previous instanceof DiscussionPage) {
|
if (app.previous.matches(DiscussionPage)) {
|
||||||
m.redraw.strategy('diff');
|
m.redraw.strategy('diff');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,7 +83,7 @@ export default class DiscussionPage extends Page {
|
|||||||
// we'll just close it.
|
// we'll just close it.
|
||||||
app.pane.disable();
|
app.pane.disable();
|
||||||
|
|
||||||
if (app.composingReplyTo(this.discussion) && !app.composer.component.content()) {
|
if (app.composer.composingReplyTo(this.discussion) && !app.composer.fields.content()) {
|
||||||
app.composer.hide();
|
app.composer.hide();
|
||||||
} else {
|
} else {
|
||||||
app.composer.minimize();
|
app.composer.minimize();
|
||||||
@@ -90,9 +95,9 @@ export default class DiscussionPage extends Page {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="DiscussionPage">
|
<div className="DiscussionPage">
|
||||||
{app.cache.discussionList ? (
|
{app.discussions.hasDiscussions() ? (
|
||||||
<div className="DiscussionPage-list" config={this.configPane.bind(this)}>
|
<div className="DiscussionPage-list" config={this.configPane.bind(this)}>
|
||||||
{!$('.App-navigation').is(':visible') ? app.cache.discussionList.render() : ''}
|
{!$('.App-navigation').is(':visible') && <DiscussionList state={app.discussions} />}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
''
|
''
|
||||||
@@ -106,7 +111,13 @@ export default class DiscussionPage extends Page {
|
|||||||
<nav className="DiscussionPage-nav">
|
<nav className="DiscussionPage-nav">
|
||||||
<ul>{listItems(this.sidebarItems().toArray())}</ul>
|
<ul>{listItems(this.sidebarItems().toArray())}</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="DiscussionPage-stream">{this.stream.render()}</div>
|
<div className="DiscussionPage-stream">
|
||||||
|
{PostStream.component({
|
||||||
|
discussion,
|
||||||
|
stream: this.stream,
|
||||||
|
targetPost: this.stream.targetPost,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
]
|
]
|
||||||
: LoadingIndicator.component({ className: 'LoadingIndicator--block' })}
|
: LoadingIndicator.component({ className: 'LoadingIndicator--block' })}
|
||||||
@@ -115,21 +126,24 @@ export default class DiscussionPage extends Page {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
config(...args) {
|
config(isInitialized, context) {
|
||||||
super.config(...args);
|
super.config(isInitialized, context);
|
||||||
|
|
||||||
if (this.discussion) {
|
if (this.discussion) {
|
||||||
app.setTitle(this.discussion.title());
|
app.setTitle(this.discussion.title());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context.onunload = () => {
|
||||||
|
this.scrollListener.stop();
|
||||||
|
|
||||||
|
clearTimeout(this.calculatePositionTimeout);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear and reload the discussion.
|
* Load the discussion from the API or use the preloaded one.
|
||||||
*/
|
*/
|
||||||
refresh() {
|
load() {
|
||||||
this.near = m.route.param('near') || 0;
|
|
||||||
this.discussion = null;
|
|
||||||
|
|
||||||
const preloadedDiscussion = app.preloadedApiDocument();
|
const preloadedDiscussion = app.preloadedApiDocument();
|
||||||
if (preloadedDiscussion) {
|
if (preloadedDiscussion) {
|
||||||
// We must wrap this in a setTimeout because if we are mounting this
|
// We must wrap this in a setTimeout because if we are mounting this
|
||||||
@@ -196,9 +210,13 @@ export default class DiscussionPage extends Page {
|
|||||||
// Set up the post stream for this discussion, along with the first page of
|
// Set up the post stream for this discussion, along with the first page of
|
||||||
// posts we want to display. Tell the stream to scroll down and highlight
|
// posts we want to display. Tell the stream to scroll down and highlight
|
||||||
// the specific post that was routed to.
|
// the specific post that was routed to.
|
||||||
this.stream = new PostStream({ discussion, includedPosts });
|
this.stream = new PostStreamState(discussion, includedPosts);
|
||||||
this.stream.on('positionChanged', this.positionChanged.bind(this));
|
|
||||||
this.stream.goToNumber(m.route.param('near') || (includedPosts[0] && includedPosts[0].number()), true);
|
this.stream.goToNumber(m.route.param('near') || (includedPosts[0] && includedPosts[0].number()), true);
|
||||||
|
|
||||||
|
app.current.set('discussion', discussion);
|
||||||
|
app.current.set('stream', this.stream);
|
||||||
|
|
||||||
|
this.scrollListener.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -264,8 +282,12 @@ export default class DiscussionPage extends Page {
|
|||||||
items.add(
|
items.add(
|
||||||
'scrubber',
|
'scrubber',
|
||||||
PostStreamScrubber.component({
|
PostStreamScrubber.component({
|
||||||
stream: this.stream,
|
discussion: this.discussion,
|
||||||
className: 'App-titleControl',
|
className: 'App-titleControl',
|
||||||
|
onNavigate: this.stream.goToIndex.bind(this.stream),
|
||||||
|
count: this.stream.count(),
|
||||||
|
paused: this.stream.paused,
|
||||||
|
...this.scrubberProps(),
|
||||||
}),
|
}),
|
||||||
-100
|
-100
|
||||||
);
|
);
|
||||||
@@ -273,6 +295,84 @@ export default class DiscussionPage extends Page {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the window is scrolled, check if either extreme of the post stream is
|
||||||
|
* in the viewport, and if so, trigger loading the next/previous page.
|
||||||
|
*
|
||||||
|
* @param {number} top
|
||||||
|
*/
|
||||||
|
onscroll(top = window.pageYOffset) {
|
||||||
|
if (this.stream.paused) return;
|
||||||
|
const marginTop = this.getMarginTop();
|
||||||
|
const viewportHeight = $(window).height() - marginTop;
|
||||||
|
const viewportTop = top + marginTop;
|
||||||
|
const loadAheadDistance = 300;
|
||||||
|
|
||||||
|
if (this.stream.visibleStart > 0) {
|
||||||
|
const $item = this.$('.PostStream-item[data-index=' + this.stream.visibleStart + ']');
|
||||||
|
|
||||||
|
if ($item.length && $item.offset().top > viewportTop - loadAheadDistance) {
|
||||||
|
this.stream.loadPrevious();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.stream.visibleEnd < this.stream.count()) {
|
||||||
|
const $item = this.$('.PostStream-item[data-index=' + (this.stream.visibleEnd - 1) + ']');
|
||||||
|
|
||||||
|
if ($item.length && $item.offset().top + $item.outerHeight(true) < viewportTop + viewportHeight + loadAheadDistance) {
|
||||||
|
this.stream.loadNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttle calculation of our position (start/end numbers of posts in the
|
||||||
|
// viewport) to 100ms.
|
||||||
|
clearTimeout(this.calculatePositionTimeout);
|
||||||
|
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this, top), 100);
|
||||||
|
|
||||||
|
// Update numbers for the scrubber if necessary
|
||||||
|
m.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Work out which posts (by number) are currently visible in the viewport, and
|
||||||
|
* fire an event with the information.
|
||||||
|
*/
|
||||||
|
calculatePosition(top = window.pageYOffset) {
|
||||||
|
const marginTop = this.getMarginTop();
|
||||||
|
const $window = $(window);
|
||||||
|
const viewportHeight = $window.height() - marginTop;
|
||||||
|
const scrollTop = $window.scrollTop() + marginTop;
|
||||||
|
const viewportTop = top + marginTop;
|
||||||
|
|
||||||
|
let startNumber;
|
||||||
|
let endNumber;
|
||||||
|
|
||||||
|
this.$('.PostStream-item').each(function () {
|
||||||
|
const $item = $(this);
|
||||||
|
const top = $item.offset().top;
|
||||||
|
const height = $item.outerHeight(true);
|
||||||
|
const visibleTop = Math.max(0, viewportTop - top);
|
||||||
|
|
||||||
|
const threeQuartersVisible = visibleTop / height < 0.75;
|
||||||
|
const coversQuarterOfViewport = (height - visibleTop) / viewportHeight > 0.25;
|
||||||
|
if (startNumber === undefined && (threeQuartersVisible || coversQuarterOfViewport)) {
|
||||||
|
startNumber = $item.data('number');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (top + height > scrollTop) {
|
||||||
|
if (top + height < scrollTop + viewportHeight) {
|
||||||
|
if ($item.data('number')) {
|
||||||
|
endNumber = $item.data('number');
|
||||||
|
}
|
||||||
|
} else return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (startNumber) {
|
||||||
|
this.positionChanged(startNumber || 1, endNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When the posts that are visible in the post stream change (i.e. the user
|
* When the posts that are visible in the post stream change (i.e. the user
|
||||||
* scrolls up or down), then we update the URL and mark the posts as read.
|
* scrolls up or down), then we update the URL and mark the posts as read.
|
||||||
@@ -299,4 +399,73 @@ export default class DiscussionPage extends Page {
|
|||||||
m.redraw();
|
m.redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scrubberProps(top = window.pageYOffset) {
|
||||||
|
const marginTop = this.getMarginTop();
|
||||||
|
const viewportHeight = $(window).height() - marginTop;
|
||||||
|
const viewportTop = top + marginTop;
|
||||||
|
|
||||||
|
// Before looping through all of the posts, we reset the scrollbar
|
||||||
|
// properties to a 'default' state. These values reflect what would be
|
||||||
|
// 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 = '';
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// been loaded yet.
|
||||||
|
$items.each(function () {
|
||||||
|
const $this = $(this);
|
||||||
|
const top = $this.offset().top;
|
||||||
|
const height = $this.outerHeight(true);
|
||||||
|
|
||||||
|
// If this item is above the top of the viewport, skip to the next
|
||||||
|
// one. If it's below the bottom of the viewport, break out of the
|
||||||
|
// loop.
|
||||||
|
if (top + height < viewportTop) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (top > viewportTop + viewportHeight) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Work out how many pixels of this item are visible inside the viewport.
|
||||||
|
// Then add the proportion of this item's total height to the index.
|
||||||
|
const visibleTop = Math.max(0, viewportTop - top);
|
||||||
|
const visibleBottom = Math.min(height, viewportTop + viewportHeight - top);
|
||||||
|
const visiblePost = visibleBottom - visibleTop;
|
||||||
|
|
||||||
|
if (top <= viewportTop) {
|
||||||
|
index = parseFloat($this.data('index')) + visibleTop / height;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visiblePost > 0) {
|
||||||
|
visible += visiblePost / height;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this item has a time associated with it, then set the
|
||||||
|
// scrollbar's current period to a formatted version of this time.
|
||||||
|
const time = $this.data('time');
|
||||||
|
if (time) period = time;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
index: index + 1,
|
||||||
|
visible: visible || 1,
|
||||||
|
description: period && dayjs(period).format('MMMM YYYY'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the distance from the top of the viewport to the point at which we
|
||||||
|
* would consider a post to be the first one visible.
|
||||||
|
*
|
||||||
|
* @return {Integer}
|
||||||
|
*/
|
||||||
|
getMarginTop() {
|
||||||
|
return this.$() && $('#header').outerHeight() + parseInt(this.$().css('margin-top'), 10);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import UserPage from './UserPage';
|
import UserPage from './UserPage';
|
||||||
import DiscussionList from './DiscussionList';
|
import DiscussionList from './DiscussionList';
|
||||||
|
import DiscussionListState from '../states/DiscussionListState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `DiscussionsUserPage` component shows a discussion list inside of a user
|
* The `DiscussionsUserPage` component shows a discussion list inside of a user
|
||||||
@@ -12,16 +13,18 @@ export default class DiscussionsUserPage extends UserPage {
|
|||||||
this.loadUser(m.route.param('username'));
|
this.loadUser(m.route.param('username'));
|
||||||
}
|
}
|
||||||
|
|
||||||
content() {
|
show(user) {
|
||||||
return (
|
super.show(user);
|
||||||
<div className="DiscussionsUserPage">
|
|
||||||
{DiscussionList.component({
|
this.state = new DiscussionListState({
|
||||||
params: {
|
q: 'author:' + user.username(),
|
||||||
q: 'author:' + this.user.username(),
|
|
||||||
sort: 'newest',
|
sort: 'newest',
|
||||||
},
|
});
|
||||||
})}
|
|
||||||
</div>
|
this.state.refresh();
|
||||||
);
|
}
|
||||||
|
|
||||||
|
content() {
|
||||||
|
return <div className="DiscussionsUserPage">{DiscussionList.component({ state: this.state })}</div>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import ComposerBody from './ComposerBody';
|
import ComposerBody from './ComposerBody';
|
||||||
|
import Button from '../../common/components/Button';
|
||||||
import icon from '../../common/helpers/icon';
|
import icon from '../../common/helpers/icon';
|
||||||
|
|
||||||
function minimizeComposerIfFullScreen(e) {
|
function minimizeComposerIfFullScreen(e) {
|
||||||
@@ -19,16 +20,6 @@ function minimizeComposerIfFullScreen(e) {
|
|||||||
* - `post`
|
* - `post`
|
||||||
*/
|
*/
|
||||||
export default class EditPostComposer extends ComposerBody {
|
export default class EditPostComposer extends ComposerBody {
|
||||||
init() {
|
|
||||||
super.init();
|
|
||||||
|
|
||||||
this.editor.props.preview = (e) => {
|
|
||||||
minimizeComposerIfFullScreen(e);
|
|
||||||
|
|
||||||
m.route(app.route.post(this.props.post));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static initProps(props) {
|
static initProps(props) {
|
||||||
super.initProps(props);
|
super.initProps(props);
|
||||||
|
|
||||||
@@ -63,6 +54,15 @@ export default class EditPostComposer extends ComposerBody {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jump to the preview when triggered by the text editor.
|
||||||
|
*/
|
||||||
|
jumpToPreview(e) {
|
||||||
|
minimizeComposerIfFullScreen(e);
|
||||||
|
|
||||||
|
m.route(app.route.post(this.props.post));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the data to submit to the server when the post is saved.
|
* Get the data to submit to the server when the post is saved.
|
||||||
*
|
*
|
||||||
@@ -70,15 +70,43 @@ export default class EditPostComposer extends ComposerBody {
|
|||||||
*/
|
*/
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
content: this.content(),
|
content: this.composer.fields.content(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
onsubmit() {
|
onsubmit() {
|
||||||
|
const discussion = this.props.post.discussion();
|
||||||
|
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
const data = this.data();
|
const data = this.data();
|
||||||
|
|
||||||
this.props.post.save(data).then(() => app.composer.hide(), this.loaded.bind(this));
|
this.props.post.save(data).then((post) => {
|
||||||
|
// If we're currently viewing the discussion which this edit was made
|
||||||
|
// in, then we can scroll to the post.
|
||||||
|
if (app.viewingDiscussion(discussion)) {
|
||||||
|
app.current.get('stream').goToNumber(post.number());
|
||||||
|
} else {
|
||||||
|
// Otherwise, we'll create an alert message to inform the user that
|
||||||
|
// their edit has been made, containing a button which will
|
||||||
|
// transition to their edited post when clicked.
|
||||||
|
let alert;
|
||||||
|
const viewButton = Button.component({
|
||||||
|
className: 'Button Button--link',
|
||||||
|
children: app.translator.trans('core.forum.composer_edit.view_button'),
|
||||||
|
onclick: () => {
|
||||||
|
m.route(app.route.post(post));
|
||||||
|
app.alerts.dismiss(alert);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
alert = app.alerts.show({
|
||||||
|
type: 'success',
|
||||||
|
children: app.translator.trans('core.forum.composer_edit.edited_message'),
|
||||||
|
controls: [viewButton],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.composer.hide();
|
||||||
|
}, this.loaded.bind(this));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -104,7 +104,7 @@ export default class ForgotPasswordModal extends Modal {
|
|||||||
|
|
||||||
onerror(error) {
|
onerror(error) {
|
||||||
if (error.status === 404) {
|
if (error.status === 404) {
|
||||||
error.alert.props.children = app.translator.trans('core.forum.forgot_password.not_found_message');
|
error.alert.children = app.translator.trans('core.forum.forgot_password.not_found_message');
|
||||||
}
|
}
|
||||||
|
|
||||||
super.onerror(error);
|
super.onerror(error);
|
||||||
|
@@ -7,6 +7,7 @@ import SelectDropdown from '../../common/components/SelectDropdown';
|
|||||||
import NotificationsDropdown from './NotificationsDropdown';
|
import NotificationsDropdown from './NotificationsDropdown';
|
||||||
import ItemList from '../../common/utils/ItemList';
|
import ItemList from '../../common/utils/ItemList';
|
||||||
import listItems from '../../common/helpers/listItems';
|
import listItems from '../../common/helpers/listItems';
|
||||||
|
import Search from '../components/Search';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `HeaderSecondary` component displays secondary header controls, such as
|
* The `HeaderSecondary` component displays secondary header controls, such as
|
||||||
@@ -33,7 +34,7 @@ export default class HeaderSecondary extends Component {
|
|||||||
items() {
|
items() {
|
||||||
const items = new ItemList();
|
const items = new ItemList();
|
||||||
|
|
||||||
items.add('search', app.search.render(), 30);
|
items.add('search', Search.component({ state: app.search }), 30);
|
||||||
|
|
||||||
if (app.forum.attribute('showLanguageSelector') && Object.keys(app.data.locales).length > 1) {
|
if (app.forum.attribute('showLanguageSelector') && Object.keys(app.data.locales).length > 1) {
|
||||||
const locales = [];
|
const locales = [];
|
||||||
@@ -67,7 +68,7 @@ export default class HeaderSecondary extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (app.session.user) {
|
if (app.session.user) {
|
||||||
items.add('notifications', NotificationsDropdown.component(), 10);
|
items.add('notifications', NotificationsDropdown.component({ state: app.notifications }), 10);
|
||||||
items.add('session', SessionDropdown.component(), 0);
|
items.add('session', SessionDropdown.component(), 0);
|
||||||
} else {
|
} else {
|
||||||
if (app.forum.attribute('allowSignUp')) {
|
if (app.forum.attribute('allowSignUp')) {
|
||||||
@@ -76,7 +77,7 @@ export default class HeaderSecondary extends Component {
|
|||||||
Button.component({
|
Button.component({
|
||||||
children: app.translator.trans('core.forum.header.sign_up_link'),
|
children: app.translator.trans('core.forum.header.sign_up_link'),
|
||||||
className: 'Button Button--link',
|
className: 'Button Button--link',
|
||||||
onclick: () => app.modal.show(new SignUpModal()),
|
onclick: () => app.modal.show(SignUpModal),
|
||||||
}),
|
}),
|
||||||
10
|
10
|
||||||
);
|
);
|
||||||
@@ -87,7 +88,7 @@ export default class HeaderSecondary extends Component {
|
|||||||
Button.component({
|
Button.component({
|
||||||
children: app.translator.trans('core.forum.header.log_in_link'),
|
children: app.translator.trans('core.forum.header.log_in_link'),
|
||||||
className: 'Button Button--link',
|
className: 'Button Button--link',
|
||||||
onclick: () => app.modal.show(new LogInModal()),
|
onclick: () => app.modal.show(LogInModal),
|
||||||
}),
|
}),
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
@@ -1,8 +1,7 @@
|
|||||||
import { extend } from '../../common/extend';
|
import { extend } from '../../common/extend';
|
||||||
import Page from './Page';
|
import Page from '../../common/components/Page';
|
||||||
import ItemList from '../../common/utils/ItemList';
|
import ItemList from '../../common/utils/ItemList';
|
||||||
import listItems from '../../common/helpers/listItems';
|
import listItems from '../../common/helpers/listItems';
|
||||||
import icon from '../../common/helpers/icon';
|
|
||||||
import DiscussionList from './DiscussionList';
|
import DiscussionList from './DiscussionList';
|
||||||
import WelcomeHero from './WelcomeHero';
|
import WelcomeHero from './WelcomeHero';
|
||||||
import DiscussionComposer from './DiscussionComposer';
|
import DiscussionComposer from './DiscussionComposer';
|
||||||
@@ -18,42 +17,27 @@ import SelectDropdown from '../../common/components/SelectDropdown';
|
|||||||
* hero, the sidebar, and the discussion list.
|
* hero, the sidebar, and the discussion list.
|
||||||
*/
|
*/
|
||||||
export default class IndexPage extends Page {
|
export default class IndexPage extends Page {
|
||||||
|
static providesInitialSearch = true;
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
super.init();
|
super.init();
|
||||||
|
|
||||||
// If the user is returning from a discussion page, then take note of which
|
// If the user is returning from a discussion page, then take note of which
|
||||||
// discussion they have just visited. After the view is rendered, we will
|
// discussion they have just visited. After the view is rendered, we will
|
||||||
// scroll down so that this discussion is in view.
|
// scroll down so that this discussion is in view.
|
||||||
if (app.previous instanceof DiscussionPage) {
|
if (app.previous.matches(DiscussionPage)) {
|
||||||
this.lastDiscussion = app.previous.discussion;
|
this.lastDiscussion = app.previous.get('discussion');
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the user is coming from the discussion list, then they have either
|
// If the user is coming from the discussion list, then they have either
|
||||||
// just switched one of the parameters (filter, sort, search) or they
|
// just switched one of the parameters (filter, sort, search) or they
|
||||||
// probably want to refresh the results. We will clear the discussion list
|
// probably want to refresh the results. We will clear the discussion list
|
||||||
// cache so that results are reloaded.
|
// cache so that results are reloaded.
|
||||||
if (app.previous instanceof IndexPage) {
|
if (app.previous.matches(IndexPage)) {
|
||||||
app.cache.discussionList = null;
|
app.discussions.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = this.params();
|
app.discussions.refreshParams(app.search.params());
|
||||||
|
|
||||||
if (app.cache.discussionList) {
|
|
||||||
// Compare the requested parameters (sort, search query) to the ones that
|
|
||||||
// are currently present in the cached discussion list. If they differ, we
|
|
||||||
// will clear the cache and set up a new discussion list component with
|
|
||||||
// the new parameters.
|
|
||||||
Object.keys(params).some((key) => {
|
|
||||||
if (app.cache.discussionList.props.params[key] !== params[key]) {
|
|
||||||
app.cache.discussionList = null;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!app.cache.discussionList) {
|
|
||||||
app.cache.discussionList = new DiscussionList({ params });
|
|
||||||
}
|
|
||||||
|
|
||||||
app.history.push('index', app.translator.trans('core.forum.header.back_to_index_tooltip'));
|
app.history.push('index', app.translator.trans('core.forum.header.back_to_index_tooltip'));
|
||||||
|
|
||||||
@@ -80,7 +64,7 @@ export default class IndexPage extends Page {
|
|||||||
<ul className="IndexPage-toolbar-view">{listItems(this.viewItems().toArray())}</ul>
|
<ul className="IndexPage-toolbar-view">{listItems(this.viewItems().toArray())}</ul>
|
||||||
<ul className="IndexPage-toolbar-action">{listItems(this.actionItems().toArray())}</ul>
|
<ul className="IndexPage-toolbar-action">{listItems(this.actionItems().toArray())}</ul>
|
||||||
</div>
|
</div>
|
||||||
{app.cache.discussionList.render()}
|
<DiscussionList state={app.discussions} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,7 +79,7 @@ export default class IndexPage extends Page {
|
|||||||
|
|
||||||
extend(context, 'onunload', () => $('#app').css('min-height', ''));
|
extend(context, 'onunload', () => $('#app').css('min-height', ''));
|
||||||
|
|
||||||
app.setTitle('');
|
app.setTitle(app.translator.trans('core.forum.index.meta_title_text'));
|
||||||
app.setTitleCount(0);
|
app.setTitleCount(0);
|
||||||
|
|
||||||
// Work out the difference between the height of this hero and that of the
|
// Work out the difference between the height of this hero and that of the
|
||||||
@@ -187,7 +171,7 @@ export default class IndexPage extends Page {
|
|||||||
*/
|
*/
|
||||||
navItems() {
|
navItems() {
|
||||||
const items = new ItemList();
|
const items = new ItemList();
|
||||||
const params = this.stickyParams();
|
const params = app.search.stickyParams();
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'allDiscussions',
|
'allDiscussions',
|
||||||
@@ -211,7 +195,7 @@ export default class IndexPage extends Page {
|
|||||||
*/
|
*/
|
||||||
viewItems() {
|
viewItems() {
|
||||||
const items = new ItemList();
|
const items = new ItemList();
|
||||||
const sortMap = app.cache.discussionList.sortMap();
|
const sortMap = app.discussions.sortMap();
|
||||||
|
|
||||||
const sortOptions = {};
|
const sortOptions = {};
|
||||||
for (const i in sortMap) {
|
for (const i in sortMap) {
|
||||||
@@ -222,15 +206,15 @@ export default class IndexPage extends Page {
|
|||||||
'sort',
|
'sort',
|
||||||
Dropdown.component({
|
Dropdown.component({
|
||||||
buttonClassName: 'Button',
|
buttonClassName: 'Button',
|
||||||
label: sortOptions[this.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0],
|
label: sortOptions[app.search.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0],
|
||||||
children: Object.keys(sortOptions).map((value) => {
|
children: Object.keys(sortOptions).map((value) => {
|
||||||
const label = sortOptions[value];
|
const label = sortOptions[value];
|
||||||
const active = (this.params().sort || Object.keys(sortMap)[0]) === value;
|
const active = (app.search.params().sort || Object.keys(sortMap)[0]) === value;
|
||||||
|
|
||||||
return Button.component({
|
return Button.component({
|
||||||
children: label,
|
children: label,
|
||||||
icon: active ? 'fas fa-check' : true,
|
icon: active ? 'fas fa-check' : true,
|
||||||
onclick: this.changeSort.bind(this, value),
|
onclick: app.search.changeSort.bind(app.search, value),
|
||||||
active: active,
|
active: active,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@@ -256,7 +240,7 @@ export default class IndexPage extends Page {
|
|||||||
icon: 'fas fa-sync',
|
icon: 'fas fa-sync',
|
||||||
className: 'Button Button--icon',
|
className: 'Button Button--icon',
|
||||||
onclick: () => {
|
onclick: () => {
|
||||||
app.cache.discussionList.refresh();
|
app.discussions.refresh();
|
||||||
if (app.session.user) {
|
if (app.session.user) {
|
||||||
app.store.find('users', app.session.user.id());
|
app.store.find('users', app.session.user.id());
|
||||||
m.redraw();
|
m.redraw();
|
||||||
@@ -280,72 +264,6 @@ export default class IndexPage extends Page {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the current search query, if any. This is implemented to activate
|
|
||||||
* the search box in the header.
|
|
||||||
*
|
|
||||||
* @see Search
|
|
||||||
* @return {String}
|
|
||||||
*/
|
|
||||||
searching() {
|
|
||||||
return this.params().q;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redirect to the index page without a search filter. This is called when the
|
|
||||||
* 'x' is clicked in the search box in the header.
|
|
||||||
*
|
|
||||||
* @see Search
|
|
||||||
*/
|
|
||||||
clearSearch() {
|
|
||||||
const params = this.params();
|
|
||||||
delete params.q;
|
|
||||||
|
|
||||||
m.route(app.route(this.props.routeName, params));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redirect to the index page using the given sort parameter.
|
|
||||||
*
|
|
||||||
* @param {String} sort
|
|
||||||
*/
|
|
||||||
changeSort(sort) {
|
|
||||||
const params = this.params();
|
|
||||||
|
|
||||||
if (sort === Object.keys(app.cache.discussionList.sortMap())[0]) {
|
|
||||||
delete params.sort;
|
|
||||||
} else {
|
|
||||||
params.sort = sort;
|
|
||||||
}
|
|
||||||
|
|
||||||
m.route(app.route(this.props.routeName, params));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get URL parameters that stick between filter changes.
|
|
||||||
*
|
|
||||||
* @return {Object}
|
|
||||||
*/
|
|
||||||
stickyParams() {
|
|
||||||
return {
|
|
||||||
sort: m.route.param('sort'),
|
|
||||||
q: m.route.param('q'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get parameters to pass to the DiscussionList component.
|
|
||||||
*
|
|
||||||
* @return {Object}
|
|
||||||
*/
|
|
||||||
params() {
|
|
||||||
const params = this.stickyParams();
|
|
||||||
|
|
||||||
params.filter = m.route.param('filter');
|
|
||||||
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the composer for a new discussion or prompt the user to login.
|
* Open the composer for a new discussion or prompt the user to login.
|
||||||
*
|
*
|
||||||
@@ -355,16 +273,14 @@ export default class IndexPage extends Page {
|
|||||||
const deferred = m.deferred();
|
const deferred = m.deferred();
|
||||||
|
|
||||||
if (app.session.user) {
|
if (app.session.user) {
|
||||||
const component = new DiscussionComposer({ user: app.session.user });
|
app.composer.load(DiscussionComposer, { user: app.session.user });
|
||||||
|
|
||||||
app.composer.load(component);
|
|
||||||
app.composer.show();
|
app.composer.show();
|
||||||
|
|
||||||
deferred.resolve(component);
|
deferred.resolve(app.composer);
|
||||||
} else {
|
} else {
|
||||||
deferred.reject();
|
deferred.reject();
|
||||||
|
|
||||||
app.modal.show(new LogInModal());
|
app.modal.show(LogInModal);
|
||||||
}
|
}
|
||||||
|
|
||||||
return deferred.promise;
|
return deferred.promise;
|
||||||
|
@@ -142,7 +142,7 @@ export default class LogInModal extends Modal {
|
|||||||
const email = this.identification();
|
const email = this.identification();
|
||||||
const props = email.indexOf('@') !== -1 ? { email } : undefined;
|
const props = email.indexOf('@') !== -1 ? { email } : undefined;
|
||||||
|
|
||||||
app.modal.show(new ForgotPasswordModal(props));
|
app.modal.show(ForgotPasswordModal, props);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -156,7 +156,7 @@ export default class LogInModal extends Modal {
|
|||||||
const identification = this.identification();
|
const identification = this.identification();
|
||||||
props[identification.indexOf('@') !== -1 ? 'email' : 'username'] = identification;
|
props[identification.indexOf('@') !== -1 ? 'email' : 'username'] = identification;
|
||||||
|
|
||||||
app.modal.show(new SignUpModal(props));
|
app.modal.show(SignUpModal, props);
|
||||||
}
|
}
|
||||||
|
|
||||||
onready() {
|
onready() {
|
||||||
@@ -179,7 +179,7 @@ export default class LogInModal extends Modal {
|
|||||||
|
|
||||||
onerror(error) {
|
onerror(error) {
|
||||||
if (error.status === 401) {
|
if (error.status === 401) {
|
||||||
error.alert.props.children = app.translator.trans('core.forum.log_in.invalid_login_message');
|
error.alert.children = app.translator.trans('core.forum.log_in.invalid_login_message');
|
||||||
}
|
}
|
||||||
|
|
||||||
super.onerror(error);
|
super.onerror(error);
|
||||||
|
@@ -21,12 +21,11 @@ export default class NotificationGrid extends Component {
|
|||||||
this.methods = this.notificationMethods().toArray();
|
this.methods = this.notificationMethods().toArray();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A map of notification type-method combinations to the checkbox instances
|
* A map of which notification checkboxes are loading.
|
||||||
* that represent them.
|
|
||||||
*
|
*
|
||||||
* @type {Object}
|
* @type {Object}
|
||||||
*/
|
*/
|
||||||
this.inputs = {};
|
this.loading = {};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Information about the available notification types.
|
* Information about the available notification types.
|
||||||
@@ -34,24 +33,11 @@ export default class NotificationGrid extends Component {
|
|||||||
* @type {Array}
|
* @type {Array}
|
||||||
*/
|
*/
|
||||||
this.types = this.notificationTypes().toArray();
|
this.types = this.notificationTypes().toArray();
|
||||||
|
|
||||||
// For each of the notification type-method combinations, create and store a
|
|
||||||
// new checkbox component instance, which we will render in the view.
|
|
||||||
this.types.forEach((type) => {
|
|
||||||
this.methods.forEach((method) => {
|
|
||||||
const key = this.preferenceKey(type.name, method.name);
|
|
||||||
const preference = this.props.user.preferences()[key];
|
|
||||||
|
|
||||||
this.inputs[key] = new Checkbox({
|
|
||||||
state: !!preference,
|
|
||||||
disabled: typeof preference === 'undefined',
|
|
||||||
onchange: () => this.toggle([key]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
|
const preferences = this.props.user.preferences();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<table className="NotificationGrid">
|
<table className="NotificationGrid">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -71,9 +57,20 @@ export default class NotificationGrid extends Component {
|
|||||||
<td className="NotificationGrid-groupToggle" onclick={this.toggleType.bind(this, type.name)}>
|
<td className="NotificationGrid-groupToggle" onclick={this.toggleType.bind(this, type.name)}>
|
||||||
{icon(type.icon)} {type.label}
|
{icon(type.icon)} {type.label}
|
||||||
</td>
|
</td>
|
||||||
{this.methods.map((method) => (
|
{this.methods.map((method) => {
|
||||||
<td className="NotificationGrid-checkbox">{this.inputs[this.preferenceKey(type.name, method.name)].render()}</td>
|
const key = this.preferenceKey(type.name, method.name);
|
||||||
))}
|
|
||||||
|
return (
|
||||||
|
<td className="NotificationGrid-checkbox">
|
||||||
|
{Checkbox.component({
|
||||||
|
state: !!preferences[key],
|
||||||
|
loading: this.loading[key],
|
||||||
|
disabled: !(key in preferences),
|
||||||
|
onchange: () => this.toggle([key]),
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -112,16 +109,14 @@ export default class NotificationGrid extends Component {
|
|||||||
const enabled = !preferences[keys[0]];
|
const enabled = !preferences[keys[0]];
|
||||||
|
|
||||||
keys.forEach((key) => {
|
keys.forEach((key) => {
|
||||||
const control = this.inputs[key];
|
this.loading[key] = true;
|
||||||
|
preferences[key] = enabled;
|
||||||
control.loading = true;
|
|
||||||
preferences[key] = control.props.state = enabled;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
m.redraw();
|
m.redraw();
|
||||||
|
|
||||||
user.save({ preferences }).then(() => {
|
user.save({ preferences }).then(() => {
|
||||||
keys.forEach((key) => (this.inputs[key].loading = false));
|
keys.forEach((key) => (this.loading[key] = false));
|
||||||
|
|
||||||
m.redraw();
|
m.redraw();
|
||||||
});
|
});
|
||||||
@@ -133,7 +128,7 @@ export default class NotificationGrid extends Component {
|
|||||||
* @param {String} method
|
* @param {String} method
|
||||||
*/
|
*/
|
||||||
toggleMethod(method) {
|
toggleMethod(method) {
|
||||||
const keys = this.types.map((type) => this.preferenceKey(type.name, method)).filter((key) => !this.inputs[key].props.disabled);
|
const keys = this.types.map((type) => this.preferenceKey(type.name, method)).filter((key) => key in this.props.user.preferences());
|
||||||
|
|
||||||
this.toggle(keys);
|
this.toggle(keys);
|
||||||
}
|
}
|
||||||
@@ -144,7 +139,7 @@ export default class NotificationGrid extends Component {
|
|||||||
* @param {String} type
|
* @param {String} type
|
||||||
*/
|
*/
|
||||||
toggleType(type) {
|
toggleType(type) {
|
||||||
const keys = this.methods.map((method) => this.preferenceKey(type, method.name)).filter((key) => !this.inputs[key].props.disabled);
|
const keys = this.methods.map((method) => this.preferenceKey(type, method.name)).filter((key) => key in this.props.user.preferences());
|
||||||
|
|
||||||
this.toggle(keys);
|
this.toggle(keys);
|
||||||
}
|
}
|
||||||
|
@@ -10,23 +10,11 @@ import Discussion from '../../common/models/Discussion';
|
|||||||
*/
|
*/
|
||||||
export default class NotificationList extends Component {
|
export default class NotificationList extends Component {
|
||||||
init() {
|
init() {
|
||||||
/**
|
this.state = this.props.state;
|
||||||
* Whether or not the notifications are loading.
|
|
||||||
*
|
|
||||||
* @type {Boolean}
|
|
||||||
*/
|
|
||||||
this.loading = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether or not there are more results that can be loaded.
|
|
||||||
*
|
|
||||||
* @type {Boolean}
|
|
||||||
*/
|
|
||||||
this.moreResults = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
const pages = app.cache.notifications || [];
|
const pages = this.state.getNotificationPages();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="NotificationList">
|
<div className="NotificationList">
|
||||||
@@ -36,7 +24,7 @@ export default class NotificationList extends Component {
|
|||||||
className: 'Button Button--icon Button--link',
|
className: 'Button Button--icon Button--link',
|
||||||
icon: 'fas fa-check',
|
icon: 'fas fa-check',
|
||||||
title: app.translator.trans('core.forum.notifications.mark_all_as_read_tooltip'),
|
title: app.translator.trans('core.forum.notifications.mark_all_as_read_tooltip'),
|
||||||
onclick: this.markAllAsRead.bind(this),
|
onclick: this.state.markAllAsRead.bind(this.state),
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -97,7 +85,7 @@ export default class NotificationList extends Component {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
: ''}
|
: ''}
|
||||||
{this.loading ? (
|
{this.state.isLoading() ? (
|
||||||
<LoadingIndicator className="LoadingIndicator--block" />
|
<LoadingIndicator className="LoadingIndicator--block" />
|
||||||
) : pages.length ? (
|
) : pages.length ? (
|
||||||
''
|
''
|
||||||
@@ -121,8 +109,8 @@ export default class NotificationList extends Component {
|
|||||||
const contentTop = $scrollParent === $notifications ? 0 : $notifications.offset().top;
|
const contentTop = $scrollParent === $notifications ? 0 : $notifications.offset().top;
|
||||||
const contentHeight = $notifications[0].scrollHeight;
|
const contentHeight = $notifications[0].scrollHeight;
|
||||||
|
|
||||||
if (this.moreResults && !this.loading && scrollTop + viewportHeight >= contentTop + contentHeight) {
|
if (this.state.hasMoreResults() && !this.state.isLoading() && scrollTop + viewportHeight >= contentTop + contentHeight) {
|
||||||
this.loadMore();
|
this.state.loadMore();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,77 +120,4 @@ export default class NotificationList extends Component {
|
|||||||
$scrollParent.off('scroll', scrollHandler);
|
$scrollParent.off('scroll', scrollHandler);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load notifications into the application's cache if they haven't already
|
|
||||||
* been loaded.
|
|
||||||
*/
|
|
||||||
load() {
|
|
||||||
if (app.session.user.newNotificationCount()) {
|
|
||||||
delete app.cache.notifications;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (app.cache.notifications) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
app.session.user.pushAttributes({ newNotificationCount: 0 });
|
|
||||||
|
|
||||||
this.loadMore();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the next page of notification results.
|
|
||||||
*
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
loadMore() {
|
|
||||||
this.loading = true;
|
|
||||||
m.redraw();
|
|
||||||
|
|
||||||
const params = app.cache.notifications ? { page: { offset: app.cache.notifications.length * 10 } } : null;
|
|
||||||
|
|
||||||
return app.store
|
|
||||||
.find('notifications', params)
|
|
||||||
.then(this.parseResults.bind(this))
|
|
||||||
.catch(() => {})
|
|
||||||
.then(() => {
|
|
||||||
this.loading = false;
|
|
||||||
m.redraw();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse results and append them to the notification list.
|
|
||||||
*
|
|
||||||
* @param {Notification[]} results
|
|
||||||
* @return {Notification[]}
|
|
||||||
*/
|
|
||||||
parseResults(results) {
|
|
||||||
app.cache.notifications = app.cache.notifications || [];
|
|
||||||
|
|
||||||
if (results.length) app.cache.notifications.push(results);
|
|
||||||
|
|
||||||
this.moreResults = !!results.payload.links.next;
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark all of the notifications as read.
|
|
||||||
*/
|
|
||||||
markAllAsRead() {
|
|
||||||
if (!app.cache.notifications) return;
|
|
||||||
|
|
||||||
app.session.user.pushAttributes({ unreadNotificationCount: 0 });
|
|
||||||
|
|
||||||
app.cache.notifications.forEach((notifications) => {
|
|
||||||
notifications.forEach((notification) => notification.pushAttributes({ isRead: true }));
|
|
||||||
});
|
|
||||||
|
|
||||||
app.request({
|
|
||||||
url: app.forum.attribute('apiUrl') + '/notifications/read',
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -13,12 +13,6 @@ export default class NotificationsDropdown extends Dropdown {
|
|||||||
super.initProps(props);
|
super.initProps(props);
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
|
||||||
super.init();
|
|
||||||
|
|
||||||
this.list = new NotificationList();
|
|
||||||
}
|
|
||||||
|
|
||||||
getButton() {
|
getButton() {
|
||||||
const newNotifications = this.getNewCount();
|
const newNotifications = this.getNewCount();
|
||||||
const vdom = super.getButton();
|
const vdom = super.getButton();
|
||||||
@@ -44,7 +38,7 @@ export default class NotificationsDropdown extends Dropdown {
|
|||||||
getMenu() {
|
getMenu() {
|
||||||
return (
|
return (
|
||||||
<div className={'Dropdown-menu ' + this.props.menuClassName} onclick={this.menuClick.bind(this)}>
|
<div className={'Dropdown-menu ' + this.props.menuClassName} onclick={this.menuClick.bind(this)}>
|
||||||
{this.showing ? this.list.render() : ''}
|
{this.showing ? NotificationList.component({ state: this.props.state }) : ''}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -53,7 +47,7 @@ export default class NotificationsDropdown extends Dropdown {
|
|||||||
if (app.drawer.isOpen()) {
|
if (app.drawer.isOpen()) {
|
||||||
this.goToRoute();
|
this.goToRoute();
|
||||||
} else {
|
} else {
|
||||||
this.list.load();
|
this.props.state.load();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import Page from './Page';
|
import Page from '../../common/components/Page';
|
||||||
import NotificationList from './NotificationList';
|
import NotificationList from './NotificationList';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,13 +11,16 @@ export default class NotificationsPage extends Page {
|
|||||||
|
|
||||||
app.history.push('notifications');
|
app.history.push('notifications');
|
||||||
|
|
||||||
this.list = new NotificationList();
|
app.notifications.load();
|
||||||
this.list.load();
|
|
||||||
|
|
||||||
this.bodyClass = 'App--notifications';
|
this.bodyClass = 'App--notifications';
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
return <div className="NotificationsPage">{this.list.render()}</div>;
|
return (
|
||||||
|
<div className="NotificationsPage">
|
||||||
|
<NotificationList state={app.notifications}></NotificationList>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -116,6 +116,7 @@ export default class Post extends Component {
|
|||||||
let classes = (existing || '').split(' ').concat(['Post']);
|
let classes = (existing || '').split(' ').concat(['Post']);
|
||||||
|
|
||||||
const user = this.props.post.user();
|
const user = this.props.post.user();
|
||||||
|
const discussion = this.props.post.discussion();
|
||||||
|
|
||||||
if (this.loading) {
|
if (this.loading) {
|
||||||
classes.push('Post--loading');
|
classes.push('Post--loading');
|
||||||
@@ -125,7 +126,7 @@ export default class Post extends Component {
|
|||||||
classes.push('Post--by-actor');
|
classes.push('Post--by-actor');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user && app.current.discussion && app.current.discussion.attribute('startUserId') == user.id()) {
|
if (user && user === discussion.user()) {
|
||||||
classes.push('Post--by-start-user');
|
classes.push('Post--by-start-user');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,8 +1,5 @@
|
|||||||
import Component from '../../common/Component';
|
import Component from '../../common/Component';
|
||||||
import ScrollListener from '../../common/utils/ScrollListener';
|
|
||||||
import PostLoading from './LoadingPost';
|
import PostLoading from './LoadingPost';
|
||||||
import anchorScroll from '../../common/utils/anchorScroll';
|
|
||||||
import evented from '../../common/utils/evented';
|
|
||||||
import ReplyPlaceholder from './ReplyPlaceholder';
|
import ReplyPlaceholder from './ReplyPlaceholder';
|
||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
|
|
||||||
@@ -13,9 +10,10 @@ import Button from '../../common/components/Button';
|
|||||||
* ### Props
|
* ### Props
|
||||||
*
|
*
|
||||||
* - `discussion`
|
* - `discussion`
|
||||||
* - `includedPosts`
|
* - `stream`
|
||||||
|
* - `targetPost`
|
||||||
*/
|
*/
|
||||||
class PostStream extends Component {
|
export default class PostStream extends Component {
|
||||||
init() {
|
init() {
|
||||||
/**
|
/**
|
||||||
* The discussion to display the post stream for.
|
* The discussion to display the post stream for.
|
||||||
@@ -25,171 +23,11 @@ class PostStream extends Component {
|
|||||||
this.discussion = this.props.discussion;
|
this.discussion = this.props.discussion;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether or not the infinite-scrolling auto-load functionality is
|
* The shared state of the post stream.
|
||||||
* disabled.
|
|
||||||
*
|
*
|
||||||
* @type {Boolean}
|
* @type {PostStreamState}
|
||||||
*/
|
*/
|
||||||
this.paused = false;
|
this.stream = this.props.stream;
|
||||||
|
|
||||||
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
|
|
||||||
this.loadPageTimeouts = {};
|
|
||||||
this.pagesLoading = 0;
|
|
||||||
|
|
||||||
this.show(this.props.includedPosts);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load and scroll to a post with a certain number.
|
|
||||||
*
|
|
||||||
* @param {Integer|String} number The post number to go to. If 'reply', go to
|
|
||||||
* the last post and scroll the reply preview into view.
|
|
||||||
* @param {Boolean} noAnimation
|
|
||||||
* @return {Promise}
|
|
||||||
*/
|
|
||||||
goToNumber(number, noAnimation) {
|
|
||||||
// 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') {
|
|
||||||
return this.goToLast().then(() => {
|
|
||||||
$('html,body')
|
|
||||||
.stop(true)
|
|
||||||
.animate(
|
|
||||||
{
|
|
||||||
scrollTop: $(document).height() - $(window).height(),
|
|
||||||
},
|
|
||||||
'fast',
|
|
||||||
() => {
|
|
||||||
this.flashItem(this.$('.PostStream-item:last-child'));
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.paused = true;
|
|
||||||
|
|
||||||
const promise = this.loadNearNumber(number);
|
|
||||||
|
|
||||||
m.redraw(true);
|
|
||||||
|
|
||||||
return promise.then(() => {
|
|
||||||
m.redraw(true);
|
|
||||||
|
|
||||||
this.scrollToNumber(number, noAnimation).done(this.unpause.bind(this));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load and scroll to a certain index within the discussion.
|
|
||||||
*
|
|
||||||
* @param {Integer} index
|
|
||||||
* @param {Boolean} backwards Whether or not to load backwards from the given
|
|
||||||
* index.
|
|
||||||
* @param {Boolean} noAnimation
|
|
||||||
* @return {Promise}
|
|
||||||
*/
|
|
||||||
goToIndex(index, backwards, noAnimation) {
|
|
||||||
this.paused = true;
|
|
||||||
|
|
||||||
const promise = this.loadNearIndex(index);
|
|
||||||
|
|
||||||
m.redraw(true);
|
|
||||||
|
|
||||||
return promise.then(() => {
|
|
||||||
anchorScroll(this.$('.PostStream-item:' + (backwards ? 'last' : 'first')), () => m.redraw(true));
|
|
||||||
|
|
||||||
this.scrollToIndex(index, noAnimation, backwards).done(this.unpause.bind(this));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load and scroll up to the first post in the discussion.
|
|
||||||
*
|
|
||||||
* @return {Promise}
|
|
||||||
*/
|
|
||||||
goToFirst() {
|
|
||||||
return this.goToIndex(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load and scroll down to the last post in the discussion.
|
|
||||||
*
|
|
||||||
* @return {Promise}
|
|
||||||
*/
|
|
||||||
goToLast() {
|
|
||||||
return this.goToIndex(this.count() - 1, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the stream so that it loads and includes the latest posts in the
|
|
||||||
* discussion, if the end is being viewed.
|
|
||||||
*
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
update() {
|
|
||||||
if (!this.viewingEnd) return m.deferred().resolve().promise;
|
|
||||||
|
|
||||||
this.visibleEnd = this.count();
|
|
||||||
|
|
||||||
return this.loadRange(this.visibleStart, this.visibleEnd).then(() => m.redraw());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the total number of posts in the discussion.
|
|
||||||
*
|
|
||||||
* @return {Integer}
|
|
||||||
*/
|
|
||||||
count() {
|
|
||||||
return this.discussion.postIds().length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make sure that the given index is not outside of the possible range of
|
|
||||||
* indexes in the discussion.
|
|
||||||
*
|
|
||||||
* @param {Integer} index
|
|
||||||
* @protected
|
|
||||||
*/
|
|
||||||
sanitizeIndex(index) {
|
|
||||||
return Math.max(0, Math.min(this.count(), index));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up the stream with the given array of posts.
|
|
||||||
*
|
|
||||||
* @param {Post[]} posts
|
|
||||||
*/
|
|
||||||
show(posts) {
|
|
||||||
this.visibleStart = posts.length ? this.discussion.postIds().indexOf(posts[0].id()) : 0;
|
|
||||||
this.visibleEnd = this.visibleStart + posts.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset the stream so that a specific range of posts is displayed. If a range
|
|
||||||
* is not specified, the first page of posts will be displayed.
|
|
||||||
*
|
|
||||||
* @param {Integer} [start]
|
|
||||||
* @param {Integer} [end]
|
|
||||||
*/
|
|
||||||
reset(start, end) {
|
|
||||||
this.visibleStart = start || 0;
|
|
||||||
this.visibleEnd = this.sanitizeIndex(end || this.constructor.loadCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the visible page of posts.
|
|
||||||
*
|
|
||||||
* @return {Post[]}
|
|
||||||
*/
|
|
||||||
posts() {
|
|
||||||
return this.discussion
|
|
||||||
.postIds()
|
|
||||||
.slice(this.visibleStart, this.visibleEnd)
|
|
||||||
.map((id) => {
|
|
||||||
const post = app.store.getById('posts', id);
|
|
||||||
|
|
||||||
return post && post.discussion() && typeof post.canEdit() !== 'undefined' ? post : null;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
@@ -200,15 +38,13 @@ class PostStream extends Component {
|
|||||||
|
|
||||||
let lastTime;
|
let lastTime;
|
||||||
|
|
||||||
this.visibleEnd = this.sanitizeIndex(this.visibleEnd);
|
const viewingEnd = this.stream.viewingEnd();
|
||||||
this.viewingEnd = this.visibleEnd === this.count();
|
const posts = this.stream.posts();
|
||||||
|
|
||||||
const posts = this.posts();
|
|
||||||
const postIds = this.discussion.postIds();
|
const postIds = this.discussion.postIds();
|
||||||
|
|
||||||
const items = posts.map((post, i) => {
|
const items = posts.map((post, i) => {
|
||||||
let content;
|
let content;
|
||||||
const attrs = { 'data-index': this.visibleStart + i };
|
const attrs = { 'data-index': this.stream.visibleStart + i };
|
||||||
|
|
||||||
if (post) {
|
if (post) {
|
||||||
const time = post.createdAt();
|
const time = post.createdAt();
|
||||||
@@ -230,7 +66,7 @@ class PostStream extends Component {
|
|||||||
if (dt > 1000 * 60 * 60 * 24 * 4) {
|
if (dt > 1000 * 60 * 60 * 24 * 4) {
|
||||||
content = [
|
content = [
|
||||||
<div className="PostStream-timeGap">
|
<div className="PostStream-timeGap">
|
||||||
<span>{app.translator.trans('core.forum.post_stream.time_lapsed_text', { period: moment.duration(dt).humanize() })}</span>
|
<span>{app.translator.trans('core.forum.post_stream.time_lapsed_text', { period: dayjs().add(dt, 'ms').fromNow(true) })}</span>
|
||||||
</div>,
|
</div>,
|
||||||
content,
|
content,
|
||||||
];
|
];
|
||||||
@@ -238,7 +74,7 @@ class PostStream extends Component {
|
|||||||
|
|
||||||
lastTime = time;
|
lastTime = time;
|
||||||
} else {
|
} else {
|
||||||
attrs.key = 'post' + postIds[this.visibleStart + i];
|
attrs.key = 'post' + postIds[this.stream.visibleStart + i];
|
||||||
|
|
||||||
content = PostLoading.component();
|
content = PostLoading.component();
|
||||||
}
|
}
|
||||||
@@ -250,10 +86,10 @@ class PostStream extends Component {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!this.viewingEnd && posts[this.visibleEnd - this.visibleStart - 1]) {
|
if (!viewingEnd && posts[this.stream.visibleEnd - this.stream.visibleStart - 1]) {
|
||||||
items.push(
|
items.push(
|
||||||
<div className="PostStream-loadMore" key="loadMore">
|
<div className="PostStream-loadMore" key="loadMore">
|
||||||
<Button className="Button" onclick={this.loadNext.bind(this)}>
|
<Button className="Button" onclick={this.stream.loadNext.bind(this.stream)}>
|
||||||
{app.translator.trans('core.forum.post_stream.load_more_button')}
|
{app.translator.trans('core.forum.post_stream.load_more_button')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -262,7 +98,7 @@ class PostStream extends Component {
|
|||||||
|
|
||||||
// If we're viewing the end of the discussion, the user can reply, and
|
// If we're viewing the end of the discussion, the user can reply, and
|
||||||
// is not already doing so, then show a 'write a reply' placeholder.
|
// is not already doing so, then show a 'write a reply' placeholder.
|
||||||
if (this.viewingEnd && (!app.session.user || this.discussion.canReply())) {
|
if (viewingEnd && (!app.session.user || this.discussion.canReply())) {
|
||||||
items.push(
|
items.push(
|
||||||
<div className="PostStream-item" key="reply">
|
<div className="PostStream-item" key="reply">
|
||||||
{ReplyPlaceholder.component({ discussion: this.discussion })}
|
{ReplyPlaceholder.component({ discussion: this.discussion })}
|
||||||
@@ -274,237 +110,25 @@ class PostStream extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
config(isInitialized, context) {
|
config(isInitialized, context) {
|
||||||
if (isInitialized) return;
|
// Start scrolling, if appropriate, to a newly-targeted post.
|
||||||
|
if (!this.props.targetPost) return;
|
||||||
|
|
||||||
// This is wrapped in setTimeout due to the following Mithril issue:
|
const oldTarget = this.prevTarget;
|
||||||
// https://github.com/lhorie/mithril.js/issues/637
|
const newTarget = this.props.targetPost;
|
||||||
setTimeout(() => this.scrollListener.start());
|
|
||||||
|
|
||||||
context.onunload = () => {
|
if (oldTarget) {
|
||||||
this.scrollListener.stop();
|
if ('number' in oldTarget && oldTarget.number === newTarget.number) return;
|
||||||
clearTimeout(this.calculatePositionTimeout);
|
if ('index' in oldTarget && oldTarget.index === newTarget.index) return;
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
if ('number' in newTarget) {
|
||||||
* When the window is scrolled, check if either extreme of the post stream is
|
this.scrollToNumber(newTarget.number, this.stream.noAnimationScroll);
|
||||||
* in the viewport, and if so, trigger loading the next/previous page.
|
} else if ('index' in newTarget) {
|
||||||
*
|
const backwards = newTarget.index === this.stream.count() - 1;
|
||||||
* @param {Integer} top
|
this.scrollToIndex(newTarget.index, this.stream.noAnimationScroll, backwards);
|
||||||
*/
|
|
||||||
onscroll(top) {
|
|
||||||
if (this.paused) return;
|
|
||||||
|
|
||||||
const marginTop = this.getMarginTop();
|
|
||||||
const viewportHeight = $(window).height() - marginTop;
|
|
||||||
const viewportTop = top + marginTop;
|
|
||||||
const loadAheadDistance = 300;
|
|
||||||
|
|
||||||
if (this.visibleStart > 0) {
|
|
||||||
const $item = this.$('.PostStream-item[data-index=' + this.visibleStart + ']');
|
|
||||||
|
|
||||||
if ($item.length && $item.offset().top > viewportTop - loadAheadDistance) {
|
|
||||||
this.loadPrevious();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.visibleEnd < this.count()) {
|
this.prevTarget = newTarget;
|
||||||
const $item = this.$('.PostStream-item[data-index=' + (this.visibleEnd - 1) + ']');
|
|
||||||
|
|
||||||
if ($item.length && $item.offset().top + $item.outerHeight(true) < viewportTop + viewportHeight + loadAheadDistance) {
|
|
||||||
this.loadNext();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Throttle calculation of our position (start/end numbers of posts in the
|
|
||||||
// viewport) to 100ms.
|
|
||||||
clearTimeout(this.calculatePositionTimeout);
|
|
||||||
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this), 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the next page of posts.
|
|
||||||
*/
|
|
||||||
loadNext() {
|
|
||||||
const start = this.visibleEnd;
|
|
||||||
const end = (this.visibleEnd = this.sanitizeIndex(this.visibleEnd + this.constructor.loadCount));
|
|
||||||
|
|
||||||
// Unload the posts which are two pages back from the page we're currently
|
|
||||||
// loading.
|
|
||||||
const twoPagesAway = start - this.constructor.loadCount * 2;
|
|
||||||
if (twoPagesAway > this.visibleStart && twoPagesAway >= 0) {
|
|
||||||
this.visibleStart = twoPagesAway + this.constructor.loadCount + 1;
|
|
||||||
|
|
||||||
if (this.loadPageTimeouts[twoPagesAway]) {
|
|
||||||
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
|
|
||||||
this.loadPageTimeouts[twoPagesAway] = null;
|
|
||||||
this.pagesLoading--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.loadPage(start, end);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the previous page of posts.
|
|
||||||
*/
|
|
||||||
loadPrevious() {
|
|
||||||
const end = this.visibleStart;
|
|
||||||
const start = (this.visibleStart = this.sanitizeIndex(this.visibleStart - this.constructor.loadCount));
|
|
||||||
|
|
||||||
// Unload the posts which are two pages back from the page we're currently
|
|
||||||
// loading.
|
|
||||||
const twoPagesAway = start + this.constructor.loadCount * 2;
|
|
||||||
if (twoPagesAway < this.visibleEnd && twoPagesAway <= this.count()) {
|
|
||||||
this.visibleEnd = twoPagesAway;
|
|
||||||
|
|
||||||
if (this.loadPageTimeouts[twoPagesAway]) {
|
|
||||||
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
|
|
||||||
this.loadPageTimeouts[twoPagesAway] = null;
|
|
||||||
this.pagesLoading--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.loadPage(start, end, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load a page of posts into the stream and redraw.
|
|
||||||
*
|
|
||||||
* @param {Integer} start
|
|
||||||
* @param {Integer} end
|
|
||||||
* @param {Boolean} backwards
|
|
||||||
*/
|
|
||||||
loadPage(start, end, backwards) {
|
|
||||||
const redraw = () => {
|
|
||||||
if (start < this.visibleStart || end > this.visibleEnd) return;
|
|
||||||
|
|
||||||
const anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
|
|
||||||
anchorScroll(`.PostStream-item[data-index="${anchorIndex}"]`, () => m.redraw(true));
|
|
||||||
|
|
||||||
this.unpause();
|
|
||||||
};
|
|
||||||
redraw();
|
|
||||||
|
|
||||||
this.loadPageTimeouts[start] = setTimeout(
|
|
||||||
() => {
|
|
||||||
this.loadRange(start, end).then(() => {
|
|
||||||
redraw();
|
|
||||||
this.pagesLoading--;
|
|
||||||
});
|
|
||||||
this.loadPageTimeouts[start] = null;
|
|
||||||
},
|
|
||||||
this.pagesLoading ? 1000 : 0
|
|
||||||
);
|
|
||||||
|
|
||||||
this.pagesLoading++;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load and inject the specified range of posts into the stream, without
|
|
||||||
* clearing it.
|
|
||||||
*
|
|
||||||
* @param {Integer} start
|
|
||||||
* @param {Integer} end
|
|
||||||
* @return {Promise}
|
|
||||||
*/
|
|
||||||
loadRange(start, end) {
|
|
||||||
const loadIds = [];
|
|
||||||
const loaded = [];
|
|
||||||
|
|
||||||
this.discussion
|
|
||||||
.postIds()
|
|
||||||
.slice(start, end)
|
|
||||||
.forEach((id) => {
|
|
||||||
const post = app.store.getById('posts', id);
|
|
||||||
|
|
||||||
if (post && post.discussion() && typeof post.canEdit() !== 'undefined') {
|
|
||||||
loaded.push(post);
|
|
||||||
} else {
|
|
||||||
loadIds.push(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return loadIds.length ? app.store.find('posts', loadIds) : m.deferred().resolve(loaded).promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the stream and load posts near a certain number. Returns a promise.
|
|
||||||
* If the post with the given number is already loaded, the promise will be
|
|
||||||
* resolved immediately.
|
|
||||||
*
|
|
||||||
* @param {Integer} number
|
|
||||||
* @return {Promise}
|
|
||||||
*/
|
|
||||||
loadNearNumber(number) {
|
|
||||||
if (this.posts().some((post) => post && Number(post.number()) === Number(number))) {
|
|
||||||
return m.deferred().resolve().promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.reset();
|
|
||||||
|
|
||||||
return app.store
|
|
||||||
.find('posts', {
|
|
||||||
filter: { discussion: this.discussion.id() },
|
|
||||||
page: { near: number },
|
|
||||||
})
|
|
||||||
.then(this.show.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the stream and load posts near a certain index. A page of posts
|
|
||||||
* surrounding the given index will be loaded. Returns a promise. If the given
|
|
||||||
* index is already loaded, the promise will be resolved immediately.
|
|
||||||
*
|
|
||||||
* @param {Integer} index
|
|
||||||
* @return {Promise}
|
|
||||||
*/
|
|
||||||
loadNearIndex(index) {
|
|
||||||
if (index >= this.visibleStart && index <= this.visibleEnd) {
|
|
||||||
return m.deferred().resolve().promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = this.sanitizeIndex(index - this.constructor.loadCount / 2);
|
|
||||||
const end = start + this.constructor.loadCount;
|
|
||||||
|
|
||||||
this.reset(start, end);
|
|
||||||
|
|
||||||
return this.loadRange(start, end).then(this.show.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Work out which posts (by number) are currently visible in the viewport, and
|
|
||||||
* fire an event with the information.
|
|
||||||
*/
|
|
||||||
calculatePosition() {
|
|
||||||
const marginTop = this.getMarginTop();
|
|
||||||
const $window = $(window);
|
|
||||||
const viewportHeight = $window.height() - marginTop;
|
|
||||||
const scrollTop = $window.scrollTop() + marginTop;
|
|
||||||
let startNumber;
|
|
||||||
let endNumber;
|
|
||||||
|
|
||||||
this.$('.PostStream-item').each(function () {
|
|
||||||
const $item = $(this);
|
|
||||||
const top = $item.offset().top;
|
|
||||||
const height = $item.outerHeight(true);
|
|
||||||
|
|
||||||
if (top + height > scrollTop) {
|
|
||||||
if (!startNumber) {
|
|
||||||
startNumber = endNumber = $item.data('number');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (top + height < scrollTop + viewportHeight) {
|
|
||||||
if ($item.data('number')) {
|
|
||||||
endNumber = $item.data('number');
|
|
||||||
}
|
|
||||||
} else return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (startNumber) {
|
|
||||||
this.trigger('positionChanged', startNumber || 1, endNumber);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -521,42 +145,46 @@ class PostStream extends Component {
|
|||||||
* Scroll down to a certain post by number and 'flash' it.
|
* Scroll down to a certain post by number and 'flash' it.
|
||||||
*
|
*
|
||||||
* @param {Integer} number
|
* @param {Integer} number
|
||||||
* @param {Boolean} noAnimation
|
* @param {Boolean} animate
|
||||||
* @return {jQuery.Deferred}
|
* @return {jQuery.Deferred}
|
||||||
*/
|
*/
|
||||||
scrollToNumber(number, noAnimation) {
|
scrollToNumber(number, animate) {
|
||||||
const $item = this.$(`.PostStream-item[data-number=${number}]`);
|
const $item = this.$(`.PostStream-item[data-number=${number}]`);
|
||||||
|
|
||||||
return this.scrollToItem($item, noAnimation).done(this.flashItem.bind(this, $item));
|
return this.scrollToItem($item, animate).then(this.flashItem.bind(this, $item));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scroll down to a certain post by index.
|
* Scroll down to a certain post by index.
|
||||||
*
|
*
|
||||||
* @param {Integer} index
|
* @param {Integer} index
|
||||||
* @param {Boolean} noAnimation
|
* @param {Boolean} animate
|
||||||
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
|
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
|
||||||
* at the given index, instead of the top of it.
|
* at the given index, instead of the top of it.
|
||||||
* @return {jQuery.Deferred}
|
* @return {jQuery.Deferred}
|
||||||
*/
|
*/
|
||||||
scrollToIndex(index, noAnimation, bottom) {
|
scrollToIndex(index, animate, bottom) {
|
||||||
const $item = this.$(`.PostStream-item[data-index=${index}]`);
|
const $item = this.$(`.PostStream-item[data-index=${index}]`);
|
||||||
|
|
||||||
return this.scrollToItem($item, noAnimation, true, bottom);
|
return this.scrollToItem($item, animate, true, bottom).then(() => {
|
||||||
|
if (index == this.stream.count() - 1) {
|
||||||
|
this.flashItem(this.$('.PostStream-item:last-child'));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scroll down to the given post.
|
* Scroll down to the given post.
|
||||||
*
|
*
|
||||||
* @param {jQuery} $item
|
* @param {jQuery} $item
|
||||||
* @param {Boolean} noAnimation
|
* @param {Boolean} animate
|
||||||
* @param {Boolean} force Whether or not to force scrolling to the item, even
|
* @param {Boolean} force Whether or not to force scrolling to the item, even
|
||||||
* if it is already in the viewport.
|
* if it is already in the viewport.
|
||||||
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
|
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
|
||||||
* at the given index, instead of the top of it.
|
* at the given index, instead of the top of it.
|
||||||
* @return {jQuery.Deferred}
|
* @return {jQuery.Deferred}
|
||||||
*/
|
*/
|
||||||
scrollToItem($item, noAnimation, force, bottom) {
|
scrollToItem($item, animate, force, bottom) {
|
||||||
const $container = $('html, body').stop(true);
|
const $container = $('html, body').stop(true);
|
||||||
|
|
||||||
if ($item.length) {
|
if ($item.length) {
|
||||||
@@ -571,7 +199,7 @@ class PostStream extends Component {
|
|||||||
if (force || itemTop < scrollTop || itemBottom > scrollBottom) {
|
if (force || itemTop < scrollTop || itemBottom > scrollBottom) {
|
||||||
const top = bottom ? 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 (noAnimation) {
|
if (!animate) {
|
||||||
$container.scrollTop(top);
|
$container.scrollTop(top);
|
||||||
} else if (top !== scrollTop) {
|
} else if (top !== scrollTop) {
|
||||||
$container.animate({ scrollTop: top }, 'fast');
|
$container.animate({ scrollTop: top }, 'fast');
|
||||||
@@ -590,24 +218,4 @@ class PostStream extends Component {
|
|||||||
flashItem($item) {
|
flashItem($item) {
|
||||||
$item.addClass('flash').one('animationend webkitAnimationEnd', () => $item.removeClass('flash'));
|
$item.addClass('flash').one('animationend webkitAnimationEnd', () => $item.removeClass('flash'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resume the stream's ability to auto-load posts on scroll.
|
|
||||||
*/
|
|
||||||
unpause() {
|
|
||||||
this.paused = false;
|
|
||||||
this.scrollListener.update();
|
|
||||||
this.trigger('unpaused');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The number of posts to load per page.
|
|
||||||
*
|
|
||||||
* @type {Integer}
|
|
||||||
*/
|
|
||||||
PostStream.loadCount = 20;
|
|
||||||
|
|
||||||
Object.assign(PostStream.prototype, evented);
|
|
||||||
|
|
||||||
export default PostStream;
|
|
||||||
|
@@ -10,41 +10,22 @@ import formatNumber from '../../common/utils/formatNumber';
|
|||||||
*
|
*
|
||||||
* ### Props
|
* ### Props
|
||||||
*
|
*
|
||||||
* - `stream`
|
* - `discussion`
|
||||||
* - `className`
|
* - `className`
|
||||||
|
* - `onNavigate`
|
||||||
|
* - `count`
|
||||||
|
* - `paused`
|
||||||
|
* - `index`
|
||||||
|
* - `visible`
|
||||||
|
* - `description`
|
||||||
*/
|
*/
|
||||||
export default class PostStreamScrubber extends Component {
|
export default class PostStreamScrubber extends Component {
|
||||||
init() {
|
init() {
|
||||||
this.handlers = {};
|
this.handlers = {};
|
||||||
|
|
||||||
/**
|
|
||||||
* The index of the post that is currently at the top of the viewport.
|
|
||||||
*
|
|
||||||
* @type {Number}
|
|
||||||
*/
|
|
||||||
this.index = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The number of posts that are currently visible in the viewport.
|
|
||||||
*
|
|
||||||
* @type {Number}
|
|
||||||
*/
|
|
||||||
this.visible = 1;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The description to render on the scrubber.
|
|
||||||
*
|
|
||||||
* @type {String}
|
|
||||||
*/
|
|
||||||
this.description = '';
|
|
||||||
|
|
||||||
// When the post stream begins loading posts at a certain index, we want our
|
|
||||||
// scrubber scrollbar to jump to that position.
|
|
||||||
this.props.stream.on('unpaused', (this.handlers.streamWasUnpaused = this.streamWasUnpaused.bind(this)));
|
|
||||||
|
|
||||||
// Define a handler to update the state of the scrollbar to reflect the
|
// Define a handler to update the state of the scrollbar to reflect the
|
||||||
// current scroll position of the page.
|
// current scroll position of the page.
|
||||||
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
|
this.scrollListener = new ScrollListener(this.renderScrollbar.bind(this, { fromScroll: true, forceHeightChange: true }));
|
||||||
|
|
||||||
// Create a subtree retainer that will always cache the subtree after the
|
// Create a subtree retainer that will always cache the subtree after the
|
||||||
// initial draw. We render parts of the scrubber using this because we
|
// initial draw. We render parts of the scrubber using this because we
|
||||||
@@ -55,12 +36,12 @@ export default class PostStreamScrubber extends Component {
|
|||||||
|
|
||||||
view() {
|
view() {
|
||||||
const retain = this.subtree.retain();
|
const retain = this.subtree.retain();
|
||||||
const count = this.count();
|
const { count, index, visible } = this.props;
|
||||||
const unreadCount = this.props.stream.discussion.unreadCount();
|
const unreadCount = this.props.discussion.unreadCount();
|
||||||
const unreadPercent = count ? Math.min(count - this.index, unreadCount) / count : 0;
|
const unreadPercent = count ? Math.min(count - this.props.index, unreadCount) / count : 0;
|
||||||
|
|
||||||
const viewing = app.translator.transChoice('core.forum.post_scrubber.viewing_text', count, {
|
const viewing = app.translator.transChoice('core.forum.post_scrubber.viewing_text', count, {
|
||||||
index: <span className="Scrubber-index">{retain || formatNumber(Math.min(Math.ceil(this.index + this.visible), count))}</span>,
|
index: <span className="Scrubber-index">{retain || formatNumber(Math.min(Math.ceil(index + visible), count))}</span>,
|
||||||
count: <span className="Scrubber-count">{formatNumber(count)}</span>,
|
count: <span className="Scrubber-count">{formatNumber(count)}</span>,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,7 +79,7 @@ export default class PostStreamScrubber extends Component {
|
|||||||
<div className="Scrubber-bar" />
|
<div className="Scrubber-bar" />
|
||||||
<div className="Scrubber-info">
|
<div className="Scrubber-info">
|
||||||
<strong>{viewing}</strong>
|
<strong>{viewing}</strong>
|
||||||
<span className="Scrubber-description">{retain || this.description}</span>
|
<span className="Scrubber-description">{retain || this.props.description}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="Scrubber-after" />
|
<div className="Scrubber-after" />
|
||||||
@@ -121,35 +102,14 @@ export default class PostStreamScrubber extends Component {
|
|||||||
* Go to the first post in the discussion.
|
* Go to the first post in the discussion.
|
||||||
*/
|
*/
|
||||||
goToFirst() {
|
goToFirst() {
|
||||||
this.props.stream.goToFirst();
|
this.navigateTo(0);
|
||||||
this.index = 0;
|
|
||||||
this.renderScrollbar(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Go to the last post in the discussion.
|
* Go to the last post in the discussion.
|
||||||
*/
|
*/
|
||||||
goToLast() {
|
goToLast() {
|
||||||
this.props.stream.goToLast();
|
this.navigateTo(this.props.count - 1);
|
||||||
this.index = this.count();
|
|
||||||
this.renderScrollbar(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the number of posts in the discussion.
|
|
||||||
*
|
|
||||||
* @return {Integer}
|
|
||||||
*/
|
|
||||||
count() {
|
|
||||||
return this.props.stream.count();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When the stream is unpaused, update the scrubber to reflect its position.
|
|
||||||
*/
|
|
||||||
streamWasUnpaused() {
|
|
||||||
this.update(window.pageYOffset);
|
|
||||||
this.renderScrollbar(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -159,87 +119,7 @@ export default class PostStreamScrubber extends Component {
|
|||||||
* @return {Boolean}
|
* @return {Boolean}
|
||||||
*/
|
*/
|
||||||
disabled() {
|
disabled() {
|
||||||
return this.visible >= this.count();
|
return this.props.visible >= this.props.count;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When the page is scrolled, update the scrollbar to reflect the visible
|
|
||||||
* posts.
|
|
||||||
*
|
|
||||||
* @param {Integer} top
|
|
||||||
*/
|
|
||||||
onscroll(top) {
|
|
||||||
const stream = this.props.stream;
|
|
||||||
|
|
||||||
if (stream.paused || !stream.$()) return;
|
|
||||||
|
|
||||||
this.update(top);
|
|
||||||
this.renderScrollbar();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the index/visible/description properties according to the window's
|
|
||||||
* current scroll position.
|
|
||||||
*
|
|
||||||
* @param {Integer} scrollTop
|
|
||||||
*/
|
|
||||||
update(scrollTop) {
|
|
||||||
const stream = this.props.stream;
|
|
||||||
|
|
||||||
const marginTop = stream.getMarginTop();
|
|
||||||
const viewportTop = scrollTop + marginTop;
|
|
||||||
const viewportHeight = $(window).height() - marginTop;
|
|
||||||
|
|
||||||
// Before looping through all of the posts, we reset the scrollbar
|
|
||||||
// properties to a 'default' state. These values reflect what would be
|
|
||||||
// seen if the browser were scrolled right up to the top of the page,
|
|
||||||
// and the viewport had a height of 0.
|
|
||||||
const $items = stream.$('> .PostStream-item[data-index]');
|
|
||||||
let index = $items.first().data('index') || 0;
|
|
||||||
let visible = 0;
|
|
||||||
let period = '';
|
|
||||||
|
|
||||||
// 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
|
|
||||||
// been loaded yet.
|
|
||||||
$items.each(function () {
|
|
||||||
const $this = $(this);
|
|
||||||
const top = $this.offset().top;
|
|
||||||
const height = $this.outerHeight(true);
|
|
||||||
|
|
||||||
// If this item is above the top of the viewport, skip to the next
|
|
||||||
// one. If it's below the bottom of the viewport, break out of the
|
|
||||||
// loop.
|
|
||||||
if (top + height < viewportTop) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (top > viewportTop + viewportHeight) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Work out how many pixels of this item are visible inside the viewport.
|
|
||||||
// Then add the proportion of this item's total height to the index.
|
|
||||||
const visibleTop = Math.max(0, viewportTop - top);
|
|
||||||
const visibleBottom = Math.min(height, viewportTop + viewportHeight - top);
|
|
||||||
const visiblePost = visibleBottom - visibleTop;
|
|
||||||
|
|
||||||
if (top <= viewportTop) {
|
|
||||||
index = parseFloat($this.data('index')) + visibleTop / height;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visiblePost > 0) {
|
|
||||||
visible += visiblePost / height;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If this item has a time associated with it, then set the
|
|
||||||
// scrollbar's current period to a formatted version of this time.
|
|
||||||
const time = $this.data('time');
|
|
||||||
if (time) period = time;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.index = index;
|
|
||||||
this.visible = visible;
|
|
||||||
this.description = period ? moment(period).format('MMMM YYYY') : '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
config(isInitialized, context) {
|
config(isInitialized, context) {
|
||||||
@@ -272,6 +152,7 @@ export default class PostStreamScrubber extends Component {
|
|||||||
this.dragging = false;
|
this.dragging = false;
|
||||||
this.mouseStart = 0;
|
this.mouseStart = 0;
|
||||||
this.indexStart = 0;
|
this.indexStart = 0;
|
||||||
|
this.dragIndex = null;
|
||||||
|
|
||||||
this.$('.Scrubber-handle')
|
this.$('.Scrubber-handle')
|
||||||
.css('cursor', 'move')
|
.css('cursor', 'move')
|
||||||
@@ -292,8 +173,6 @@ export default class PostStreamScrubber extends Component {
|
|||||||
ondestroy() {
|
ondestroy() {
|
||||||
this.scrollListener.stop();
|
this.scrollListener.stop();
|
||||||
|
|
||||||
this.props.stream.off('unpaused', this.handlers.streamWasUnpaused);
|
|
||||||
|
|
||||||
$(window).off('resize', this.handlers.onresize);
|
$(window).off('resize', this.handlers.onresize);
|
||||||
|
|
||||||
$(document).off('mousemove touchmove', this.handlers.onmousemove).off('mouseup touchend', this.handlers.onmouseup);
|
$(document).off('mousemove touchmove', this.handlers.onmousemove).off('mouseup touchend', this.handlers.onmouseup);
|
||||||
@@ -305,31 +184,43 @@ export default class PostStreamScrubber extends Component {
|
|||||||
*
|
*
|
||||||
* @param {Boolean} animate
|
* @param {Boolean} animate
|
||||||
*/
|
*/
|
||||||
renderScrollbar(animate) {
|
renderScrollbar(options = {}) {
|
||||||
|
const { count, visible, description, paused } = this.props;
|
||||||
const percentPerPost = this.percentPerPost();
|
const percentPerPost = this.percentPerPost();
|
||||||
const index = this.index;
|
|
||||||
const count = this.count();
|
const index = this.dragIndex || this.props.index;
|
||||||
const visible = this.visible || 1;
|
|
||||||
|
|
||||||
const $scrubber = this.$();
|
const $scrubber = this.$();
|
||||||
$scrubber.find('.Scrubber-index').text(formatNumber(Math.min(Math.ceil(index + visible), count)));
|
$scrubber.find('.Scrubber-index').text(formatNumber(Math.min(Math.ceil(index + visible), count)));
|
||||||
$scrubber.find('.Scrubber-description').text(this.description);
|
$scrubber.find('.Scrubber-description').text(description);
|
||||||
$scrubber.toggleClass('disabled', this.disabled());
|
$scrubber.toggleClass('disabled', this.disabled());
|
||||||
|
|
||||||
const heights = {};
|
const heights = {};
|
||||||
heights.before = Math.max(0, percentPerPost.index * Math.min(index, count - visible));
|
heights.before = Math.max(0, percentPerPost.index * Math.min(index - 1, count - visible));
|
||||||
heights.handle = Math.min(100 - heights.before, percentPerPost.visible * visible);
|
heights.handle = Math.min(100 - heights.before, percentPerPost.visible * visible);
|
||||||
heights.after = 100 - heights.before - heights.handle;
|
heights.after = 100 - heights.before - heights.handle;
|
||||||
|
|
||||||
const func = animate ? 'animate' : 'css';
|
// If the stream is paused, don't change height on scroll, as the viewport is being scrolled by the JS
|
||||||
|
// If a height change animation is already in progress, don't adjust height unless overridden
|
||||||
|
if ((options.fromScroll && paused) || (this.adjustingHeight && !options.forceHeightChange)) return;
|
||||||
|
|
||||||
|
const func = options.animate ? 'animate' : 'css';
|
||||||
|
this.adjustingHeight = true;
|
||||||
|
const animationPromises = [];
|
||||||
for (const part in heights) {
|
for (const part in heights) {
|
||||||
const $part = $scrubber.find(`.Scrubber-${part}`);
|
const $part = $scrubber.find(`.Scrubber-${part}`);
|
||||||
$part.stop(true, true)[func]({ height: heights[part] + '%' }, 'fast');
|
animationPromises.push(
|
||||||
|
$part
|
||||||
|
.stop(true, true)
|
||||||
|
[func]({ height: heights[part] + '%' }, 'fast')
|
||||||
|
.promise()
|
||||||
|
);
|
||||||
|
|
||||||
// jQuery likes to put overflow:hidden, but because the scrollbar handle
|
// jQuery likes to put overflow:hidden, but because the scrollbar handle
|
||||||
// has a negative margin-left, we need to override.
|
// has a negative margin-left, we need to override.
|
||||||
if (func === 'animate') $part.css('overflow', 'visible');
|
if (func === 'animate') $part.css('overflow', 'visible');
|
||||||
}
|
}
|
||||||
|
Promise.all(animationPromises).then(() => (this.adjustingHeight = false));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -343,8 +234,8 @@ export default class PostStreamScrubber extends Component {
|
|||||||
* scrubber.
|
* scrubber.
|
||||||
*/
|
*/
|
||||||
percentPerPost() {
|
percentPerPost() {
|
||||||
const count = this.count() || 1;
|
const count = this.props.count || 1;
|
||||||
const visible = this.visible || 1;
|
const visible = this.props.visible || 1;
|
||||||
|
|
||||||
// To stop the handle of the scrollbar from getting too small when there
|
// To stop the handle of the scrollbar from getting too small when there
|
||||||
// are many posts, we define a minimum percentage height for the handle
|
// are many posts, we define a minimum percentage height for the handle
|
||||||
@@ -381,11 +272,13 @@ export default class PostStreamScrubber extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onmousedown(e) {
|
onmousedown(e) {
|
||||||
|
e.redraw = false;
|
||||||
this.mouseStart = e.clientY || e.originalEvent.touches[0].clientY;
|
this.mouseStart = e.clientY || e.originalEvent.touches[0].clientY;
|
||||||
this.indexStart = this.index;
|
this.indexStart = this.props.index;
|
||||||
this.dragging = true;
|
this.dragging = true;
|
||||||
this.props.stream.paused = true;
|
this.dragIndex = null;
|
||||||
$('body').css('cursor', 'move');
|
$('body').css('cursor', 'move');
|
||||||
|
this.$().toggleClass('dragging', this.dragging);
|
||||||
}
|
}
|
||||||
|
|
||||||
onmousemove(e) {
|
onmousemove(e) {
|
||||||
@@ -398,13 +291,14 @@ export default class PostStreamScrubber extends Component {
|
|||||||
const deltaPixels = (e.clientY || e.originalEvent.touches[0].clientY) - this.mouseStart;
|
const deltaPixels = (e.clientY || e.originalEvent.touches[0].clientY) - this.mouseStart;
|
||||||
const deltaPercent = (deltaPixels / this.$('.Scrubber-scrollbar').outerHeight()) * 100;
|
const deltaPercent = (deltaPixels / this.$('.Scrubber-scrollbar').outerHeight()) * 100;
|
||||||
const deltaIndex = deltaPercent / this.percentPerPost().index || 0;
|
const deltaIndex = deltaPercent / this.percentPerPost().index || 0;
|
||||||
const newIndex = Math.min(this.indexStart + deltaIndex, this.count() - 1);
|
const newIndex = Math.min(this.indexStart + deltaIndex, this.props.count - 1);
|
||||||
|
|
||||||
this.index = Math.max(0, newIndex);
|
this.dragIndex = Math.max(0, newIndex);
|
||||||
this.renderScrollbar();
|
this.renderScrollbar();
|
||||||
}
|
}
|
||||||
|
|
||||||
onmouseup() {
|
onmouseup() {
|
||||||
|
this.$().toggleClass('dragging', this.dragging);
|
||||||
if (!this.dragging) return;
|
if (!this.dragging) return;
|
||||||
|
|
||||||
this.mouseStart = 0;
|
this.mouseStart = 0;
|
||||||
@@ -416,9 +310,9 @@ export default class PostStreamScrubber extends Component {
|
|||||||
|
|
||||||
// If the index we've landed on is in a gap, then tell the stream-
|
// If the index we've landed on is in a gap, then tell the stream-
|
||||||
// content that we want to load those posts.
|
// content that we want to load those posts.
|
||||||
const intIndex = Math.floor(this.index);
|
this.navigateTo(this.dragIndex);
|
||||||
this.props.stream.goToIndex(intIndex);
|
|
||||||
this.renderScrollbar(true);
|
this.dragIndex = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
onclick(e) {
|
onclick(e) {
|
||||||
@@ -438,11 +332,21 @@ export default class PostStreamScrubber extends Component {
|
|||||||
// 3. Now we can convert the percentage into an index, and tell the stream-
|
// 3. Now we can convert the percentage into an index, and tell the stream-
|
||||||
// content component to jump to that index.
|
// content component to jump to that index.
|
||||||
let offsetIndex = offsetPercent / this.percentPerPost().index;
|
let offsetIndex = offsetPercent / this.percentPerPost().index;
|
||||||
offsetIndex = Math.max(0, Math.min(this.count() - 1, offsetIndex));
|
offsetIndex = Math.max(0, Math.min(this.props.count - 1, offsetIndex));
|
||||||
this.props.stream.goToIndex(Math.floor(offsetIndex));
|
|
||||||
this.index = offsetIndex;
|
this.navigateTo(offsetIndex);
|
||||||
this.renderScrollbar(true);
|
|
||||||
|
|
||||||
this.$().removeClass('open');
|
this.$().removeClass('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger post stream navigation, but also animate the scrollbar according
|
||||||
|
* to the expected result.
|
||||||
|
*
|
||||||
|
* @param {number} index
|
||||||
|
*/
|
||||||
|
navigateTo(index) {
|
||||||
|
this.props.onNavigate(Math.floor(index));
|
||||||
|
this.renderScrollbar({ animate: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -13,15 +13,6 @@ import listItems from '../../common/helpers/listItems';
|
|||||||
* - `post`
|
* - `post`
|
||||||
*/
|
*/
|
||||||
export default class PostUser extends Component {
|
export default class PostUser extends Component {
|
||||||
init() {
|
|
||||||
/**
|
|
||||||
* Whether or not the user hover card is visible.
|
|
||||||
*
|
|
||||||
* @type {Boolean}
|
|
||||||
*/
|
|
||||||
this.cardVisible = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
const post = this.props.post;
|
const post = this.props.post;
|
||||||
const user = post.user();
|
const user = post.user();
|
||||||
@@ -38,7 +29,7 @@ export default class PostUser extends Component {
|
|||||||
|
|
||||||
let card = '';
|
let card = '';
|
||||||
|
|
||||||
if (!post.isHidden() && this.cardVisible) {
|
if (!post.isHidden() && this.props.cardVisible) {
|
||||||
card = UserCard.component({
|
card = UserCard.component({
|
||||||
user,
|
user,
|
||||||
className: 'UserCard--popover',
|
className: 'UserCard--popover',
|
||||||
@@ -81,9 +72,7 @@ export default class PostUser extends Component {
|
|||||||
* Show the user card.
|
* Show the user card.
|
||||||
*/
|
*/
|
||||||
showCard() {
|
showCard() {
|
||||||
this.cardVisible = true;
|
this.props.oncardshow();
|
||||||
|
|
||||||
m.redraw();
|
|
||||||
|
|
||||||
setTimeout(() => this.$('.UserCard').addClass('in'));
|
setTimeout(() => this.$('.UserCard').addClass('in'));
|
||||||
}
|
}
|
||||||
@@ -95,8 +84,7 @@ export default class PostUser extends Component {
|
|||||||
this.$('.UserCard')
|
this.$('.UserCard')
|
||||||
.removeClass('in')
|
.removeClass('in')
|
||||||
.one('transitionend webkitTransitionEnd oTransitionEnd', () => {
|
.one('transitionend webkitTransitionEnd oTransitionEnd', () => {
|
||||||
this.cardVisible = false;
|
this.props.oncardhide();
|
||||||
m.redraw();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -57,7 +57,7 @@ export default class RenameDiscussionModal extends Modal {
|
|||||||
.save({ title })
|
.save({ title })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (app.viewingDiscussion(this.discussion)) {
|
if (app.viewingDiscussion(this.discussion)) {
|
||||||
app.current.stream.update();
|
app.current.get('stream').update();
|
||||||
}
|
}
|
||||||
m.redraw();
|
m.redraw();
|
||||||
this.hide();
|
this.hide();
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
import ComposerBody from './ComposerBody';
|
import ComposerBody from './ComposerBody';
|
||||||
import Alert from '../../common/components/Alert';
|
|
||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
import icon from '../../common/helpers/icon';
|
import icon from '../../common/helpers/icon';
|
||||||
import extractText from '../../common/utils/extractText';
|
import extractText from '../../common/utils/extractText';
|
||||||
@@ -21,16 +20,6 @@ function minimizeComposerIfFullScreen(e) {
|
|||||||
* - `discussion`
|
* - `discussion`
|
||||||
*/
|
*/
|
||||||
export default class ReplyComposer extends ComposerBody {
|
export default class ReplyComposer extends ComposerBody {
|
||||||
init() {
|
|
||||||
super.init();
|
|
||||||
|
|
||||||
this.editor.props.preview = (e) => {
|
|
||||||
minimizeComposerIfFullScreen(e);
|
|
||||||
|
|
||||||
m.route(app.route.discussion(this.props.discussion, 'reply'));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static initProps(props) {
|
static initProps(props) {
|
||||||
super.initProps(props);
|
super.initProps(props);
|
||||||
|
|
||||||
@@ -62,6 +51,15 @@ export default class ReplyComposer extends ComposerBody {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jump to the preview when triggered by the text editor.
|
||||||
|
*/
|
||||||
|
jumpToPreview(e) {
|
||||||
|
minimizeComposerIfFullScreen(e);
|
||||||
|
|
||||||
|
m.route(app.route.discussion(this.props.discussion, 'reply'));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the data to submit to the server when the reply is saved.
|
* Get the data to submit to the server when the reply is saved.
|
||||||
*
|
*
|
||||||
@@ -69,7 +67,7 @@ export default class ReplyComposer extends ComposerBody {
|
|||||||
*/
|
*/
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
content: this.content(),
|
content: this.composer.fields.content(),
|
||||||
relationships: { discussion: this.props.discussion },
|
relationships: { discussion: this.props.discussion },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -89,7 +87,8 @@ export default class ReplyComposer extends ComposerBody {
|
|||||||
// If we're currently viewing the discussion which this reply was made
|
// If we're currently viewing the discussion which this reply was made
|
||||||
// in, then we can update the post stream and scroll to the post.
|
// in, then we can update the post stream and scroll to the post.
|
||||||
if (app.viewingDiscussion(discussion)) {
|
if (app.viewingDiscussion(discussion)) {
|
||||||
app.current.stream.update().then(() => app.current.stream.goToNumber(post.number()));
|
const stream = app.current.get('stream');
|
||||||
|
stream.update().then(() => stream.goToNumber(post.number()));
|
||||||
} else {
|
} else {
|
||||||
// Otherwise, we'll create an alert message to inform the user that
|
// Otherwise, we'll create an alert message to inform the user that
|
||||||
// their reply has been posted, containing a button which will
|
// their reply has been posted, containing a button which will
|
||||||
@@ -103,16 +102,14 @@ export default class ReplyComposer extends ComposerBody {
|
|||||||
app.alerts.dismiss(alert);
|
app.alerts.dismiss(alert);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
app.alerts.show(
|
alert = app.alerts.show({
|
||||||
(alert = new Alert({
|
|
||||||
type: 'success',
|
type: 'success',
|
||||||
children: app.translator.trans('core.forum.composer_reply.posted_message'),
|
children: app.translator.trans('core.forum.composer_reply.posted_message'),
|
||||||
controls: [viewButton],
|
controls: [viewButton],
|
||||||
}))
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.composer.hide();
|
this.composer.hide();
|
||||||
}, this.loaded.bind(this));
|
}, this.loaded.bind(this));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -15,7 +15,7 @@ import DiscussionControls from '../utils/DiscussionControls';
|
|||||||
*/
|
*/
|
||||||
export default class ReplyPlaceholder extends Component {
|
export default class ReplyPlaceholder extends Component {
|
||||||
view() {
|
view() {
|
||||||
if (app.composingReplyTo(this.props.discussion)) {
|
if (app.composer.composingReplyTo(this.props.discussion)) {
|
||||||
return (
|
return (
|
||||||
<article className="Post CommentPost editing">
|
<article className="Post CommentPost editing">
|
||||||
<header className="Post-header">
|
<header className="Post-header">
|
||||||
@@ -53,9 +53,9 @@ export default class ReplyPlaceholder extends Component {
|
|||||||
const updateInterval = setInterval(() => {
|
const updateInterval = setInterval(() => {
|
||||||
// Since we're polling, the composer may have been closed in the meantime,
|
// Since we're polling, the composer may have been closed in the meantime,
|
||||||
// so we bail in that case.
|
// so we bail in that case.
|
||||||
if (!app.composer.component) return;
|
if (!app.composer.isVisible()) return;
|
||||||
|
|
||||||
const content = app.composer.component.content();
|
const content = app.composer.fields.content();
|
||||||
|
|
||||||
if (preview === content) return;
|
if (preview === content) return;
|
||||||
|
|
||||||
|
@@ -12,19 +12,17 @@ import UsersSearchSource from './UsersSearchSource';
|
|||||||
* The `Search` component displays a menu of as-you-type results from a variety
|
* The `Search` component displays a menu of as-you-type results from a variety
|
||||||
* of sources.
|
* of sources.
|
||||||
*
|
*
|
||||||
* The search box will be 'activated' if the app's current controller implements
|
* The search box will be 'activated' if the app's search state's
|
||||||
* a `searching` method that returns a truthy value. If this is the case, an 'x'
|
* getInitialSearch() value is a truthy value. If this is the case, an 'x'
|
||||||
* button will be shown next to the search field, and clicking it will call the
|
* button will be shown next to the search field, and clicking it will clear the search.
|
||||||
* `clearSearch` method on the controller.
|
*
|
||||||
|
* PROPS:
|
||||||
|
*
|
||||||
|
* - state: SearchState instance.
|
||||||
*/
|
*/
|
||||||
export default class Search extends Component {
|
export default class Search extends Component {
|
||||||
init() {
|
init() {
|
||||||
/**
|
this.state = this.props.state;
|
||||||
* The value of the search input.
|
|
||||||
*
|
|
||||||
* @type {Function}
|
|
||||||
*/
|
|
||||||
this.value = m.prop('');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether or not the search input has focus.
|
* Whether or not the search input has focus.
|
||||||
@@ -47,13 +45,6 @@ export default class Search extends Component {
|
|||||||
*/
|
*/
|
||||||
this.loadingSources = 0;
|
this.loadingSources = 0;
|
||||||
|
|
||||||
/**
|
|
||||||
* A list of queries that have been searched for.
|
|
||||||
*
|
|
||||||
* @type {Array}
|
|
||||||
*/
|
|
||||||
this.searched = [];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The index of the currently-selected <li> in the results list. This can be
|
* The index of the currently-selected <li> in the results list. This can be
|
||||||
* a unique string (to account for the fact that an item's position may jump
|
* a unique string (to account for the fact that an item's position may jump
|
||||||
@@ -66,13 +57,7 @@ export default class Search extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
const currentSearch = this.getCurrentSearch();
|
const currentSearch = this.state.getInitialSearch();
|
||||||
|
|
||||||
// Initialize search input value in the view rather than the constructor so
|
|
||||||
// that we have access to app.current.
|
|
||||||
if (typeof this.value() === 'undefined') {
|
|
||||||
this.value(currentSearch || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize search sources in the view rather than the constructor so
|
// Initialize search sources in the view rather than the constructor so
|
||||||
// that we have access to app.forum.
|
// that we have access to app.forum.
|
||||||
@@ -88,7 +73,7 @@ export default class Search extends Component {
|
|||||||
className={
|
className={
|
||||||
'Search ' +
|
'Search ' +
|
||||||
classList({
|
classList({
|
||||||
open: this.value() && this.hasFocus,
|
open: this.state.getValue() && this.hasFocus,
|
||||||
focused: this.hasFocus,
|
focused: this.hasFocus,
|
||||||
active: !!currentSearch,
|
active: !!currentSearch,
|
||||||
loading: !!this.loadingSources,
|
loading: !!this.loadingSources,
|
||||||
@@ -100,8 +85,8 @@ export default class Search extends Component {
|
|||||||
className="FormControl"
|
className="FormControl"
|
||||||
type="search"
|
type="search"
|
||||||
placeholder={extractText(app.translator.trans('core.forum.header.search_placeholder'))}
|
placeholder={extractText(app.translator.trans('core.forum.header.search_placeholder'))}
|
||||||
value={this.value()}
|
value={this.state.getValue()}
|
||||||
oninput={m.withAttr('value', this.value)}
|
oninput={m.withAttr('value', this.state.setValue.bind(this.state))}
|
||||||
onfocus={() => (this.hasFocus = true)}
|
onfocus={() => (this.hasFocus = true)}
|
||||||
onblur={() => (this.hasFocus = false)}
|
onblur={() => (this.hasFocus = false)}
|
||||||
/>
|
/>
|
||||||
@@ -116,7 +101,7 @@ export default class Search extends Component {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ul className="Dropdown-menu Search-results">
|
<ul className="Dropdown-menu Search-results">
|
||||||
{this.value() && this.hasFocus ? this.sources.map((source) => source.view(this.value())) : ''}
|
{this.state.getValue() && this.hasFocus ? this.sources.map((source) => source.view(this.state.getValue())) : ''}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -129,6 +114,7 @@ export default class Search extends Component {
|
|||||||
if (isInitialized) return;
|
if (isInitialized) return;
|
||||||
|
|
||||||
const search = this;
|
const search = this;
|
||||||
|
const state = this.state;
|
||||||
|
|
||||||
this.$('.Search-results')
|
this.$('.Search-results')
|
||||||
.on('mousedown', (e) => e.preventDefault())
|
.on('mousedown', (e) => e.preventDefault())
|
||||||
@@ -158,7 +144,7 @@ export default class Search extends Component {
|
|||||||
|
|
||||||
clearTimeout(search.searchTimeout);
|
clearTimeout(search.searchTimeout);
|
||||||
search.searchTimeout = setTimeout(() => {
|
search.searchTimeout = setTimeout(() => {
|
||||||
if (search.searched.indexOf(query) !== -1) return;
|
if (state.isCached(query)) return;
|
||||||
|
|
||||||
if (query.length >= 3) {
|
if (query.length >= 3) {
|
||||||
search.sources.map((source) => {
|
search.sources.map((source) => {
|
||||||
@@ -173,7 +159,7 @@ export default class Search extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
search.searched.push(query);
|
state.cache(query);
|
||||||
m.redraw();
|
m.redraw();
|
||||||
}, 250);
|
}, 250);
|
||||||
})
|
})
|
||||||
@@ -185,15 +171,6 @@ export default class Search extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the active search in the app's current controller.
|
|
||||||
*
|
|
||||||
* @return {String}
|
|
||||||
*/
|
|
||||||
getCurrentSearch() {
|
|
||||||
return app.current && typeof app.current.searching === 'function' && app.current.searching();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate to the currently selected search result and close the list.
|
* Navigate to the currently selected search result and close the list.
|
||||||
*/
|
*/
|
||||||
@@ -201,7 +178,7 @@ export default class Search extends Component {
|
|||||||
clearTimeout(this.searchTimeout);
|
clearTimeout(this.searchTimeout);
|
||||||
this.loadingSources = 0;
|
this.loadingSources = 0;
|
||||||
|
|
||||||
if (this.value()) {
|
if (this.state.getValue()) {
|
||||||
m.route(this.getItem(this.index).find('a').attr('href'));
|
m.route(this.getItem(this.index).find('a').attr('href'));
|
||||||
} else {
|
} else {
|
||||||
this.clear();
|
this.clear();
|
||||||
@@ -211,16 +188,10 @@ export default class Search extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear the search input and the current controller's active search.
|
* Clear the search
|
||||||
*/
|
*/
|
||||||
clear() {
|
clear() {
|
||||||
this.value('');
|
this.state.clear();
|
||||||
|
|
||||||
if (this.getCurrentSearch()) {
|
|
||||||
app.current.clearSearch();
|
|
||||||
} else {
|
|
||||||
m.redraw();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -2,8 +2,8 @@
|
|||||||
* The `SearchSource` interface defines a section of search results in the
|
* The `SearchSource` interface defines a section of search results in the
|
||||||
* search dropdown.
|
* search dropdown.
|
||||||
*
|
*
|
||||||
* Search sources should be registered with the `Search` component instance
|
* Search sources should be registered with the `Search` component class
|
||||||
* (app.search) by extending the `sourceItems` method. When the user types a
|
* by extending the `sourceItems` method. When the user types a
|
||||||
* query, each search source will be prompted to load search results via the
|
* query, each search source will be prompted to load search results via the
|
||||||
* `search` method. When the dropdown is redrawn, it will be constructed by
|
* `search` method. When the dropdown is redrawn, it will be constructed by
|
||||||
* putting together the output from the `view` method of each source.
|
* putting together the output from the `view` method of each source.
|
||||||
|
@@ -79,7 +79,7 @@ export default class SettingsPage extends UserPage {
|
|||||||
Button.component({
|
Button.component({
|
||||||
children: app.translator.trans('core.forum.settings.change_password_button'),
|
children: app.translator.trans('core.forum.settings.change_password_button'),
|
||||||
className: 'Button',
|
className: 'Button',
|
||||||
onclick: () => app.modal.show(new ChangePasswordModal()),
|
onclick: () => app.modal.show(ChangePasswordModal),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ export default class SettingsPage extends UserPage {
|
|||||||
Button.component({
|
Button.component({
|
||||||
children: app.translator.trans('core.forum.settings.change_email_button'),
|
children: app.translator.trans('core.forum.settings.change_email_button'),
|
||||||
className: 'Button',
|
className: 'Button',
|
||||||
onclick: () => app.modal.show(new ChangeEmailModal()),
|
onclick: () => app.modal.show(ChangeEmailModal),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -109,6 +109,8 @@ export default class SettingsPage extends UserPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @deprecated beta 14, remove in beta 15.
|
||||||
|
*
|
||||||
* Generate a callback that will save a value to the given preference.
|
* Generate a callback that will save a value to the given preference.
|
||||||
*
|
*
|
||||||
* @param {String} key
|
* @param {String} key
|
||||||
@@ -116,11 +118,11 @@ export default class SettingsPage extends UserPage {
|
|||||||
*/
|
*/
|
||||||
preferenceSaver(key) {
|
preferenceSaver(key) {
|
||||||
return (value, component) => {
|
return (value, component) => {
|
||||||
if (component) component.loading = true;
|
if (component) component.props.loading = true;
|
||||||
m.redraw();
|
m.redraw();
|
||||||
|
|
||||||
this.user.savePreferences({ [key]: value }).then(() => {
|
this.user.savePreferences({ [key]: value }).then(() => {
|
||||||
if (component) component.loading = false;
|
if (component) component.props.loading = false;
|
||||||
m.redraw();
|
m.redraw();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -139,10 +141,15 @@ export default class SettingsPage extends UserPage {
|
|||||||
Switch.component({
|
Switch.component({
|
||||||
children: app.translator.trans('core.forum.settings.privacy_disclose_online_label'),
|
children: app.translator.trans('core.forum.settings.privacy_disclose_online_label'),
|
||||||
state: this.user.preferences().discloseOnline,
|
state: this.user.preferences().discloseOnline,
|
||||||
onchange: (value, component) => {
|
onchange: (value) => {
|
||||||
this.user.pushAttributes({ lastSeenAt: null });
|
this.discloseOnlineLoading = true;
|
||||||
this.preferenceSaver('discloseOnline')(value, component);
|
|
||||||
|
this.user.savePreferences({ discloseOnline: value }).then(() => {
|
||||||
|
this.discloseOnlineLoading = false;
|
||||||
|
m.redraw();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
loading: this.discloseOnlineLoading,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@@ -145,7 +145,7 @@ export default class SignUpModal extends Modal {
|
|||||||
password: this.password(),
|
password: this.password(),
|
||||||
};
|
};
|
||||||
|
|
||||||
app.modal.show(new LogInModal(props));
|
app.modal.show(LogInModal, props);
|
||||||
}
|
}
|
||||||
|
|
||||||
onready() {
|
onready() {
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import Component from '../../common/Component';
|
import Component from '../../common/Component';
|
||||||
import ItemList from '../../common/utils/ItemList';
|
import ItemList from '../../common/utils/ItemList';
|
||||||
|
import SuperTextarea from '../../common/utils/SuperTextarea';
|
||||||
import listItems from '../../common/helpers/listItems';
|
import listItems from '../../common/helpers/listItems';
|
||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
|
|
||||||
@@ -9,10 +10,12 @@ import Button from '../../common/components/Button';
|
|||||||
*
|
*
|
||||||
* ### Props
|
* ### Props
|
||||||
*
|
*
|
||||||
|
* - `composer`
|
||||||
* - `submitLabel`
|
* - `submitLabel`
|
||||||
* - `value`
|
* - `value`
|
||||||
* - `placeholder`
|
* - `placeholder`
|
||||||
* - `disabled`
|
* - `disabled`
|
||||||
|
* - `preview`
|
||||||
*/
|
*/
|
||||||
export default class TextEditor extends Component {
|
export default class TextEditor extends Component {
|
||||||
init() {
|
init() {
|
||||||
@@ -21,7 +24,7 @@ export default class TextEditor extends Component {
|
|||||||
*
|
*
|
||||||
* @type {String}
|
* @type {String}
|
||||||
*/
|
*/
|
||||||
this.value = m.prop(this.props.value || '');
|
this.value = this.props.value || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
@@ -33,7 +36,7 @@ export default class TextEditor extends Component {
|
|||||||
oninput={m.withAttr('value', this.oninput.bind(this))}
|
oninput={m.withAttr('value', this.oninput.bind(this))}
|
||||||
placeholder={this.props.placeholder || ''}
|
placeholder={this.props.placeholder || ''}
|
||||||
disabled={!!this.props.disabled}
|
disabled={!!this.props.disabled}
|
||||||
value={this.value()}
|
value={this.value}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ul className="TextEditor-controls Composer-footer">
|
<ul className="TextEditor-controls Composer-footer">
|
||||||
@@ -47,7 +50,7 @@ export default class TextEditor extends Component {
|
|||||||
/**
|
/**
|
||||||
* Configure the textarea element.
|
* Configure the textarea element.
|
||||||
*
|
*
|
||||||
* @param {DOMElement} element
|
* @param {HTMLTextAreaElement} element
|
||||||
* @param {Boolean} isInitialized
|
* @param {Boolean} isInitialized
|
||||||
*/
|
*/
|
||||||
configTextarea(element, isInitialized) {
|
configTextarea(element, isInitialized) {
|
||||||
@@ -60,6 +63,8 @@ export default class TextEditor extends Component {
|
|||||||
|
|
||||||
$(element).bind('keydown', 'meta+return', handler);
|
$(element).bind('keydown', 'meta+return', handler);
|
||||||
$(element).bind('keydown', 'ctrl+return', handler);
|
$(element).bind('keydown', 'ctrl+return', handler);
|
||||||
|
|
||||||
|
this.props.composer.editor = new SuperTextarea(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,73 +111,15 @@ export default class TextEditor extends Component {
|
|||||||
return new ItemList();
|
return new ItemList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the value of the text editor.
|
|
||||||
*
|
|
||||||
* @param {String} value
|
|
||||||
*/
|
|
||||||
setValue(value) {
|
|
||||||
this.$('textarea').val(value).trigger('input');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the selected range of the textarea.
|
|
||||||
*
|
|
||||||
* @param {Integer} start
|
|
||||||
* @param {Integer} end
|
|
||||||
*/
|
|
||||||
setSelectionRange(start, end) {
|
|
||||||
const $textarea = this.$('textarea');
|
|
||||||
|
|
||||||
if (!$textarea.length) return;
|
|
||||||
|
|
||||||
$textarea[0].setSelectionRange(start, end);
|
|
||||||
$textarea.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the selected range of the textarea.
|
|
||||||
*
|
|
||||||
* @return {Array}
|
|
||||||
*/
|
|
||||||
getSelectionRange() {
|
|
||||||
const $textarea = this.$('textarea');
|
|
||||||
|
|
||||||
if (!$textarea.length) return [0, 0];
|
|
||||||
|
|
||||||
return [$textarea[0].selectionStart, $textarea[0].selectionEnd];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Insert content into the textarea at the position of the cursor.
|
|
||||||
*
|
|
||||||
* @param {String} insert
|
|
||||||
*/
|
|
||||||
insertAtCursor(insert) {
|
|
||||||
const textarea = this.$('textarea')[0];
|
|
||||||
const value = this.value();
|
|
||||||
const index = textarea ? textarea.selectionStart : value.length;
|
|
||||||
|
|
||||||
this.setValue(value.slice(0, index) + insert + value.slice(index));
|
|
||||||
|
|
||||||
// Move the textarea cursor to the end of the content we just inserted.
|
|
||||||
if (textarea) {
|
|
||||||
const pos = index + insert.length;
|
|
||||||
this.setSelectionRange(pos, pos);
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle input into the textarea.
|
* Handle input into the textarea.
|
||||||
*
|
*
|
||||||
* @param {String} value
|
* @param {String} value
|
||||||
*/
|
*/
|
||||||
oninput(value) {
|
oninput(value) {
|
||||||
this.value(value);
|
this.value = value;
|
||||||
|
|
||||||
this.props.onchange(this.value());
|
this.props.onchange(this.value);
|
||||||
|
|
||||||
m.redraw.strategy('none');
|
m.redraw.strategy('none');
|
||||||
}
|
}
|
||||||
@@ -181,6 +128,6 @@ export default class TextEditor extends Component {
|
|||||||
* Handle the submit button being clicked.
|
* Handle the submit button being clicked.
|
||||||
*/
|
*/
|
||||||
onsubmit() {
|
onsubmit() {
|
||||||
this.props.onsubmit(this.value());
|
this.props.onsubmit(this.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import Page from './Page';
|
import Page from '../../common/components/Page';
|
||||||
import ItemList from '../../common/utils/ItemList';
|
import ItemList from '../../common/utils/ItemList';
|
||||||
import affixSidebar from '../utils/affixSidebar';
|
import affixSidebar from '../utils/affixSidebar';
|
||||||
import UserCard from './UserCard';
|
import UserCard from './UserCard';
|
||||||
@@ -71,6 +71,8 @@ export default class UserPage extends Page {
|
|||||||
show(user) {
|
show(user) {
|
||||||
this.user = user;
|
this.user = user;
|
||||||
|
|
||||||
|
app.current.set('user', user);
|
||||||
|
|
||||||
app.setTitle(user.displayName());
|
app.setTitle(user.displayName());
|
||||||
|
|
||||||
m.redraw();
|
m.redraw();
|
||||||
|
285
js/src/forum/states/ComposerState.js
Normal file
285
js/src/forum/states/ComposerState.js
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import subclassOf from '../../common/utils/subclassOf';
|
||||||
|
import ReplyComposer from '../components/ReplyComposer';
|
||||||
|
|
||||||
|
class ComposerState {
|
||||||
|
constructor() {
|
||||||
|
/**
|
||||||
|
* The composer's current position.
|
||||||
|
*
|
||||||
|
* @type {ComposerState.Position}
|
||||||
|
*/
|
||||||
|
this.position = ComposerState.Position.HIDDEN;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The composer's intended height, which can be modified by the user
|
||||||
|
* (by dragging the composer handle).
|
||||||
|
*
|
||||||
|
* @type {Integer}
|
||||||
|
*/
|
||||||
|
this.height = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dynamic component being shown inside the composer.
|
||||||
|
*
|
||||||
|
* @type {Object}
|
||||||
|
*/
|
||||||
|
this.body = { attrs: {} };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reference to the text editor that allows text manipulation.
|
||||||
|
*
|
||||||
|
* @type {SuperTextArea|null}
|
||||||
|
*/
|
||||||
|
this.editor = null;
|
||||||
|
|
||||||
|
this.clear();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated BC layer, remove in Beta 15.
|
||||||
|
*/
|
||||||
|
this.component = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a content component into the composer.
|
||||||
|
*
|
||||||
|
* @param {ComposerBody} componentClass
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
load(componentClass, attrs) {
|
||||||
|
const body = { componentClass, attrs };
|
||||||
|
|
||||||
|
if (this.preventExit()) return;
|
||||||
|
|
||||||
|
// If we load a similar component into the composer, then Mithril will be
|
||||||
|
// able to diff the old/new contents and some DOM-related state from the
|
||||||
|
// old composer will remain. To prevent this from happening, we clear the
|
||||||
|
// component and force a redraw, so that the new component will be working
|
||||||
|
// on a blank slate.
|
||||||
|
if (this.isVisible()) {
|
||||||
|
this.clear();
|
||||||
|
m.redraw(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.body = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the composer's content component.
|
||||||
|
*/
|
||||||
|
clear() {
|
||||||
|
this.position = ComposerState.Position.HIDDEN;
|
||||||
|
this.body = { attrs: {} };
|
||||||
|
this.editor = null;
|
||||||
|
this.onExit = null;
|
||||||
|
|
||||||
|
this.fields = {
|
||||||
|
content: m.prop(''),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated BC layer, remove in Beta 15.
|
||||||
|
*/
|
||||||
|
this.content = this.fields.content;
|
||||||
|
this.value = this.fields.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the composer.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
show() {
|
||||||
|
if (this.position === ComposerState.Position.NORMAL || this.position === ComposerState.Position.FULLSCREEN) return;
|
||||||
|
|
||||||
|
this.position = ComposerState.Position.NORMAL;
|
||||||
|
m.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the composer.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
hide() {
|
||||||
|
this.clear();
|
||||||
|
m.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm with the user so they don't lose their content, then close the
|
||||||
|
* composer.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
close() {
|
||||||
|
if (this.preventExit()) return;
|
||||||
|
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimize the composer. Has no effect if the composer is hidden.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
minimize() {
|
||||||
|
if (!this.isVisible()) return;
|
||||||
|
|
||||||
|
this.position = ComposerState.Position.MINIMIZED;
|
||||||
|
m.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take the composer into fullscreen mode. Has no effect if the composer is
|
||||||
|
* hidden.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
fullScreen() {
|
||||||
|
if (!this.isVisible()) return;
|
||||||
|
|
||||||
|
this.position = ComposerState.Position.FULLSCREEN;
|
||||||
|
m.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exit fullscreen mode.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
exitFullScreen() {
|
||||||
|
if (this.position !== ComposerState.Position.FULLSCREEN) return;
|
||||||
|
|
||||||
|
this.position = ComposerState.Position.NORMAL;
|
||||||
|
m.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the body matches the given component class and data.
|
||||||
|
*
|
||||||
|
* @param {object} type The component class to check against. Subclasses are
|
||||||
|
* accepted as well.
|
||||||
|
* @param {object} data
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
bodyMatches(type, data = {}) {
|
||||||
|
// Fail early when the body is of a different type
|
||||||
|
if (!subclassOf(this.body.componentClass, type)) return false;
|
||||||
|
|
||||||
|
// Now that the type is known to be correct, we loop through the provided
|
||||||
|
// data to see whether it matches the data in the attributes for the body.
|
||||||
|
return Object.keys(data).every((key) => this.body.attrs[key] === data[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether or not the Composer is visible.
|
||||||
|
*
|
||||||
|
* True when the composer is displayed on the screen and has a body component.
|
||||||
|
* It could be open in "normal" or full-screen mode, or even minimized.
|
||||||
|
*
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isVisible() {
|
||||||
|
return this.position !== ComposerState.Position.HIDDEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether or not the Composer is covering the screen.
|
||||||
|
*
|
||||||
|
* This will be true if the Composer is in full-screen mode on desktop,
|
||||||
|
* or if we are on a mobile device, where we always consider the composer as full-screen..
|
||||||
|
*
|
||||||
|
* @return {Boolean}
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
isFullScreen() {
|
||||||
|
return this.position === ComposerState.Position.FULLSCREEN || app.screen() === 'phone';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether or not the user is currently composing a reply to a
|
||||||
|
* discussion.
|
||||||
|
*
|
||||||
|
* @param {Discussion} discussion
|
||||||
|
* @return {Boolean}
|
||||||
|
*/
|
||||||
|
composingReplyTo(discussion) {
|
||||||
|
return this.isVisible() && this.bodyMatches(ReplyComposer, { discussion });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm with the user that they want to close the composer and lose their
|
||||||
|
* content.
|
||||||
|
*
|
||||||
|
* @return {Boolean} Whether or not the exit was cancelled.
|
||||||
|
*/
|
||||||
|
preventExit() {
|
||||||
|
if (!this.isVisible()) return;
|
||||||
|
if (!this.onExit) return;
|
||||||
|
|
||||||
|
if (this.onExit.callback()) {
|
||||||
|
return !confirm(this.onExit.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure when / what to ask the user before closing the composer.
|
||||||
|
*
|
||||||
|
* The provided callback will be used to determine whether asking for
|
||||||
|
* confirmation is necessary. If the callback returns true at the time of
|
||||||
|
* closing, the provided text will be shown in a standard confirmation dialog.
|
||||||
|
*
|
||||||
|
* @param {Function} callback
|
||||||
|
* @param {String} message
|
||||||
|
*/
|
||||||
|
preventClosingWhen(callback, message) {
|
||||||
|
this.onExit = { callback, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum height of the Composer.
|
||||||
|
* @returns {Integer}
|
||||||
|
*/
|
||||||
|
minimumHeight() {
|
||||||
|
return 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maxmimum height of the Composer.
|
||||||
|
* @returns {Integer}
|
||||||
|
*/
|
||||||
|
maximumHeight() {
|
||||||
|
return $(window).height() - $('#header').outerHeight();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed the composer's current height, based on the intended height, and
|
||||||
|
* the composer's current state. This will be applied to the composer's
|
||||||
|
* content's DOM element.
|
||||||
|
* @returns {Integer|String}
|
||||||
|
*/
|
||||||
|
computedHeight() {
|
||||||
|
// If the composer is minimized, then we don't want to set a height; we'll
|
||||||
|
// let the CSS decide how high it is. If it's fullscreen, then we need to
|
||||||
|
// make it as high as the window.
|
||||||
|
if (this.position === ComposerState.Position.MINIMIZED) {
|
||||||
|
return '';
|
||||||
|
} else if (this.position === ComposerState.Position.FULLSCREEN) {
|
||||||
|
return $(window).height();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, if it's normal or hidden, then we use the intended height.
|
||||||
|
// We don't let the composer get too small or too big, though.
|
||||||
|
return Math.max(this.minimumHeight(), Math.min(this.height, this.maximumHeight()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ComposerState.Position = {
|
||||||
|
HIDDEN: 'hidden',
|
||||||
|
NORMAL: 'normal',
|
||||||
|
MINIMIZED: 'minimized',
|
||||||
|
FULLSCREEN: 'fullScreen',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ComposerState;
|
190
js/src/forum/states/DiscussionListState.js
Normal file
190
js/src/forum/states/DiscussionListState.js
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
export default class DiscussionListState {
|
||||||
|
constructor(params = {}, app = window.app) {
|
||||||
|
this.params = params;
|
||||||
|
|
||||||
|
this.app = app;
|
||||||
|
|
||||||
|
this.discussions = [];
|
||||||
|
|
||||||
|
this.moreResults = false;
|
||||||
|
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the parameters that should be passed in the API request to get
|
||||||
|
* discussion results.
|
||||||
|
*
|
||||||
|
* @api
|
||||||
|
*/
|
||||||
|
requestParams() {
|
||||||
|
const params = { include: ['user', 'lastPostedUser'], filter: {} };
|
||||||
|
|
||||||
|
params.sort = this.sortMap()[this.params.sort];
|
||||||
|
|
||||||
|
if (this.params.q) {
|
||||||
|
params.filter.q = this.params.q;
|
||||||
|
|
||||||
|
params.include.push('mostRelevantPost', 'mostRelevantPost.user');
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a map of sort keys (which appear in the URL, and are used for
|
||||||
|
* translation) to the API sort value that they represent.
|
||||||
|
*/
|
||||||
|
sortMap() {
|
||||||
|
const map = {};
|
||||||
|
|
||||||
|
if (this.params.q) {
|
||||||
|
map.relevance = '';
|
||||||
|
}
|
||||||
|
map.latest = '-lastPostedAt';
|
||||||
|
map.top = '-commentCount';
|
||||||
|
map.newest = '-createdAt';
|
||||||
|
map.oldest = 'createdAt';
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the search parameters.
|
||||||
|
*/
|
||||||
|
getParams() {
|
||||||
|
return this.params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cached discussions.
|
||||||
|
*/
|
||||||
|
clear() {
|
||||||
|
this.discussions = [];
|
||||||
|
m.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If there are no cached discussions or the new params differ from the
|
||||||
|
* old ones, update params and refresh the discussion list from the database.
|
||||||
|
*/
|
||||||
|
refreshParams(newParams) {
|
||||||
|
if (!this.hasDiscussions() || Object.keys(newParams).some((key) => this.getParams()[key] !== newParams[key])) {
|
||||||
|
this.params = newParams;
|
||||||
|
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear and reload the discussion list.
|
||||||
|
*/
|
||||||
|
refresh({ clear = true } = {}) {
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
if (clear) {
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.loadResults().then(
|
||||||
|
(results) => {
|
||||||
|
this.parseResults(results);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
this.loading = false;
|
||||||
|
m.redraw();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a new page of discussion results.
|
||||||
|
*
|
||||||
|
* @param offset The index to start the page at.
|
||||||
|
*/
|
||||||
|
loadResults(offset) {
|
||||||
|
const preloadedDiscussions = this.app.preloadedApiDocument();
|
||||||
|
|
||||||
|
if (preloadedDiscussions) {
|
||||||
|
return Promise.resolve(preloadedDiscussions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = this.requestParams();
|
||||||
|
params.page = { offset };
|
||||||
|
params.include = params.include.join(',');
|
||||||
|
|
||||||
|
return this.app.store.find('discussions', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the next page of discussion results.
|
||||||
|
*/
|
||||||
|
loadMore() {
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
this.loadResults(this.discussions.length).then(this.parseResults.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse results and append them to the discussion list.
|
||||||
|
*/
|
||||||
|
parseResults(results) {
|
||||||
|
this.discussions.push(...results);
|
||||||
|
|
||||||
|
this.loading = false;
|
||||||
|
this.moreResults = !!results.payload.links && !!results.payload.links.next;
|
||||||
|
|
||||||
|
m.redraw();
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a discussion from the list if it is present.
|
||||||
|
*/
|
||||||
|
removeDiscussion(discussion) {
|
||||||
|
const index = this.discussions.indexOf(discussion);
|
||||||
|
|
||||||
|
if (index !== -1) {
|
||||||
|
this.discussions.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
m.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a discussion to the top of the list.
|
||||||
|
*/
|
||||||
|
addDiscussion(discussion) {
|
||||||
|
this.discussions.unshift(discussion);
|
||||||
|
m.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Are there discussions stored in the discussion list state?
|
||||||
|
*/
|
||||||
|
hasDiscussions() {
|
||||||
|
return this.discussions.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Are discussions currently being loaded?
|
||||||
|
*/
|
||||||
|
isLoading() {
|
||||||
|
return this.loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In the last request, has the user searched for a discussion?
|
||||||
|
*/
|
||||||
|
isSearchResults() {
|
||||||
|
return !!this.params.q;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Have the search results come up empty?
|
||||||
|
*/
|
||||||
|
empty() {
|
||||||
|
return !this.hasDiscussions() && !this.isLoading();
|
||||||
|
}
|
||||||
|
}
|
95
js/src/forum/states/GlobalSearchState.js
Normal file
95
js/src/forum/states/GlobalSearchState.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import SearchState from './SearchState';
|
||||||
|
|
||||||
|
export default class GlobalSearchState extends SearchState {
|
||||||
|
constructor(cachedSearches = [], searchRoute = 'index') {
|
||||||
|
super(cachedSearches);
|
||||||
|
this.searchRoute = searchRoute;
|
||||||
|
}
|
||||||
|
|
||||||
|
getValue() {
|
||||||
|
if (this.value === undefined) {
|
||||||
|
this.value = this.getInitialSearch() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the search input and the current controller's active search.
|
||||||
|
*/
|
||||||
|
clear() {
|
||||||
|
super.clear();
|
||||||
|
|
||||||
|
if (this.getInitialSearch()) {
|
||||||
|
this.clearInitialSearch();
|
||||||
|
} else {
|
||||||
|
m.redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get URL parameters that stick between filter changes.
|
||||||
|
*
|
||||||
|
* @return {Object}
|
||||||
|
*/
|
||||||
|
stickyParams() {
|
||||||
|
return {
|
||||||
|
sort: m.route.param('sort'),
|
||||||
|
q: m.route.param('q'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get parameters to pass to the DiscussionList component.
|
||||||
|
*
|
||||||
|
* @return {Object}
|
||||||
|
*/
|
||||||
|
params() {
|
||||||
|
const params = this.stickyParams();
|
||||||
|
|
||||||
|
params.filter = m.route.param('filter');
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect to the index page using the given sort parameter.
|
||||||
|
*
|
||||||
|
* @param {String} sort
|
||||||
|
*/
|
||||||
|
changeSort(sort) {
|
||||||
|
const params = this.params();
|
||||||
|
|
||||||
|
if (sort === Object.keys(app.discussions.sortMap())[0]) {
|
||||||
|
delete params.sort;
|
||||||
|
} else {
|
||||||
|
params.sort = sort;
|
||||||
|
}
|
||||||
|
|
||||||
|
m.route(app.route(this.searchRoute, params));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the current search query, if any. This is implemented to activate
|
||||||
|
* the search box in the header.
|
||||||
|
*
|
||||||
|
* @see Search
|
||||||
|
* @return {String}
|
||||||
|
*/
|
||||||
|
getInitialSearch() {
|
||||||
|
return app.current.type.providesInitialSearch && this.params().q;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect to the index page without a search filter. This is called when the
|
||||||
|
* 'x' is clicked in the search box in the header.
|
||||||
|
*
|
||||||
|
* @see Search
|
||||||
|
*/
|
||||||
|
clearInitialSearch() {
|
||||||
|
const params = this.params();
|
||||||
|
delete params.q;
|
||||||
|
|
||||||
|
m.route(app.route(this.searchRoute, params));
|
||||||
|
}
|
||||||
|
}
|
98
js/src/forum/states/NotificationListState.js
Normal file
98
js/src/forum/states/NotificationListState.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
export default class NotificationListState {
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app;
|
||||||
|
|
||||||
|
this.notificationPages = [];
|
||||||
|
|
||||||
|
this.loading = false;
|
||||||
|
|
||||||
|
this.moreResults = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.notificationPages = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getNotificationPages() {
|
||||||
|
return this.notificationPages;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading() {
|
||||||
|
return this.loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMoreResults() {
|
||||||
|
return this.moreResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load notifications into the application's cache if they haven't already
|
||||||
|
* been loaded.
|
||||||
|
*/
|
||||||
|
load() {
|
||||||
|
if (this.app.session.user.newNotificationCount()) {
|
||||||
|
this.notificationPages = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.notificationPages.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.app.session.user.pushAttributes({ newNotificationCount: 0 });
|
||||||
|
|
||||||
|
this.loadMore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the next page of notification results.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
loadMore() {
|
||||||
|
this.loading = true;
|
||||||
|
m.redraw();
|
||||||
|
|
||||||
|
const params = this.notificationPages.length > 0 ? { page: { offset: this.notificationPages.length * 10 } } : null;
|
||||||
|
|
||||||
|
return this.app.store
|
||||||
|
.find('notifications', params)
|
||||||
|
.then(this.parseResults.bind(this))
|
||||||
|
.catch(() => {})
|
||||||
|
.then(() => {
|
||||||
|
this.loading = false;
|
||||||
|
m.redraw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse results and append them to the notification list.
|
||||||
|
*
|
||||||
|
* @param {Notification[]} results
|
||||||
|
* @return {Notification[]}
|
||||||
|
*/
|
||||||
|
parseResults(results) {
|
||||||
|
if (results.length) this.notificationPages.push(results);
|
||||||
|
|
||||||
|
this.moreResults = !!results.payload.links.next;
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark all of the notifications as read.
|
||||||
|
*/
|
||||||
|
markAllAsRead() {
|
||||||
|
if (this.notificationPages.length === 0) return;
|
||||||
|
|
||||||
|
this.app.session.user.pushAttributes({ unreadNotificationCount: 0 });
|
||||||
|
|
||||||
|
this.notificationPages.forEach((notifications) => {
|
||||||
|
notifications.forEach((notification) => notification.pushAttributes({ isRead: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.request({
|
||||||
|
url: this.app.forum.attribute('apiUrl') + '/notifications/read',
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
329
js/src/forum/states/PostStreamState.js
Normal file
329
js/src/forum/states/PostStreamState.js
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import anchorScroll from '../../common/utils/anchorScroll';
|
||||||
|
|
||||||
|
class PostStreamState {
|
||||||
|
constructor(discussion, includedPosts = []) {
|
||||||
|
/**
|
||||||
|
* The discussion to display the post stream for.
|
||||||
|
*
|
||||||
|
* @type {Discussion}
|
||||||
|
*/
|
||||||
|
this.discussion = discussion;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the infinite-scrolling auto-load functionality is
|
||||||
|
* disabled.
|
||||||
|
*
|
||||||
|
* @type {Boolean}
|
||||||
|
*/
|
||||||
|
this.paused = false;
|
||||||
|
|
||||||
|
this.loadPageTimeouts = {};
|
||||||
|
this.pagesLoading = 0;
|
||||||
|
|
||||||
|
this.show(includedPosts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the stream so that it loads and includes the latest posts in the
|
||||||
|
* discussion, if the end is being viewed.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
update() {
|
||||||
|
if (!this.viewingEnd()) return m.deferred().resolve().promise;
|
||||||
|
|
||||||
|
this.visibleEnd = this.count();
|
||||||
|
|
||||||
|
return this.loadRange(this.visibleStart, this.visibleEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and scroll up to the first post in the discussion.
|
||||||
|
*
|
||||||
|
* @return {Promise}
|
||||||
|
*/
|
||||||
|
goToFirst() {
|
||||||
|
return this.goToIndex(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and scroll down to the last post in the discussion.
|
||||||
|
*
|
||||||
|
* @return {Promise}
|
||||||
|
*/
|
||||||
|
goToLast() {
|
||||||
|
return this.goToIndex(this.count() - 1, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and scroll to a post with a certain number.
|
||||||
|
*
|
||||||
|
* @param {number|String} number The post number to go to. If 'reply', go to
|
||||||
|
* the last post and scroll the reply preview into view.
|
||||||
|
* @param {Boolean} noAnimation
|
||||||
|
* @return {Promise}
|
||||||
|
*/
|
||||||
|
goToNumber(number, noAnimation = false) {
|
||||||
|
// 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') {
|
||||||
|
return this.goToLast();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.paused = true;
|
||||||
|
|
||||||
|
this.targetPost = { number };
|
||||||
|
this.noAnimationScroll = noAnimation;
|
||||||
|
|
||||||
|
// In this case, the redraw is only called after the response has been loaded
|
||||||
|
// because we need to know the indices of the post range before we can
|
||||||
|
// start scrolling to items. Calling redraw early causes issues.
|
||||||
|
// Since this is only used for external navigation to the post stream, the delay
|
||||||
|
// before the stream is moved is not an issue.
|
||||||
|
return this.loadNearNumber(number).then(() => {
|
||||||
|
this.paused = false;
|
||||||
|
m.redraw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and scroll to a certain index within the discussion.
|
||||||
|
*
|
||||||
|
* @param {number} index
|
||||||
|
* @param {Boolean} noAnimation
|
||||||
|
* @return {Promise}
|
||||||
|
*/
|
||||||
|
goToIndex(index, noAnimation = false) {
|
||||||
|
this.paused = true;
|
||||||
|
|
||||||
|
const promise = this.loadNearIndex(index);
|
||||||
|
|
||||||
|
this.targetPost = { index };
|
||||||
|
this.noAnimationScroll = noAnimation;
|
||||||
|
this.index = index;
|
||||||
|
|
||||||
|
m.redraw();
|
||||||
|
|
||||||
|
return promise.then(() => (this.paused = false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the stream and load posts near a certain number. Returns a promise.
|
||||||
|
* If the post with the given number is already loaded, the promise will be
|
||||||
|
* resolved immediately.
|
||||||
|
*
|
||||||
|
* @param {number} number
|
||||||
|
* @return {Promise}
|
||||||
|
*/
|
||||||
|
loadNearNumber(number) {
|
||||||
|
if (this.posts().some((post) => post && Number(post.number()) === Number(number))) {
|
||||||
|
return m.deferred().resolve().promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reset();
|
||||||
|
|
||||||
|
return app.store
|
||||||
|
.find('posts', {
|
||||||
|
filter: { discussion: this.discussion.id() },
|
||||||
|
page: { near: number },
|
||||||
|
})
|
||||||
|
.then(this.show.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the stream and load posts near a certain index. A page of posts
|
||||||
|
* surrounding the given index will be loaded. Returns a promise. If the given
|
||||||
|
* index is already loaded, the promise will be resolved immediately.
|
||||||
|
*
|
||||||
|
* @param {number} index
|
||||||
|
* @return {Promise}
|
||||||
|
*/
|
||||||
|
loadNearIndex(index) {
|
||||||
|
if (index >= this.visibleStart && index <= this.visibleEnd) {
|
||||||
|
return m.deferred().resolve().promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = this.sanitizeIndex(index - this.constructor.loadCount / 2);
|
||||||
|
const end = start + this.constructor.loadCount;
|
||||||
|
|
||||||
|
this.reset(start, end);
|
||||||
|
|
||||||
|
return this.loadRange(start, end).then(this.show.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the next page of posts.
|
||||||
|
*/
|
||||||
|
loadNext() {
|
||||||
|
const start = this.visibleEnd;
|
||||||
|
const end = (this.visibleEnd = this.sanitizeIndex(this.visibleEnd + this.constructor.loadCount));
|
||||||
|
|
||||||
|
// Unload the posts which are two pages back from the page we're currently
|
||||||
|
// loading.
|
||||||
|
const twoPagesAway = start - this.constructor.loadCount * 2;
|
||||||
|
if (twoPagesAway > this.visibleStart && twoPagesAway >= 0) {
|
||||||
|
this.visibleStart = twoPagesAway + this.constructor.loadCount + 1;
|
||||||
|
|
||||||
|
if (this.loadPageTimeouts[twoPagesAway]) {
|
||||||
|
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
|
||||||
|
this.loadPageTimeouts[twoPagesAway] = null;
|
||||||
|
this.pagesLoading--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadPage(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the previous page of posts.
|
||||||
|
*/
|
||||||
|
loadPrevious() {
|
||||||
|
const end = this.visibleStart;
|
||||||
|
const start = (this.visibleStart = this.sanitizeIndex(this.visibleStart - this.constructor.loadCount));
|
||||||
|
|
||||||
|
// Unload the posts which are two pages back from the page we're currently
|
||||||
|
// loading.
|
||||||
|
const twoPagesAway = start + this.constructor.loadCount * 2;
|
||||||
|
if (twoPagesAway < this.visibleEnd && twoPagesAway <= this.count()) {
|
||||||
|
this.visibleEnd = twoPagesAway;
|
||||||
|
|
||||||
|
if (this.loadPageTimeouts[twoPagesAway]) {
|
||||||
|
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
|
||||||
|
this.loadPageTimeouts[twoPagesAway] = null;
|
||||||
|
this.pagesLoading--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadPage(start, end, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a page of posts into the stream and redraw.
|
||||||
|
*
|
||||||
|
* @param {number} start
|
||||||
|
* @param {number} end
|
||||||
|
* @param {Boolean} backwards
|
||||||
|
*/
|
||||||
|
loadPage(start, end, backwards = false) {
|
||||||
|
m.redraw();
|
||||||
|
|
||||||
|
this.loadPageTimeouts[start] = setTimeout(
|
||||||
|
() => {
|
||||||
|
this.loadRange(start, end).then(() => {
|
||||||
|
if (start >= this.visibleStart && end <= this.visibleEnd) {
|
||||||
|
const anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
|
||||||
|
anchorScroll(`.PostStream-item[data-index="${anchorIndex}"]`, () => m.redraw(true));
|
||||||
|
}
|
||||||
|
this.pagesLoading--;
|
||||||
|
});
|
||||||
|
this.loadPageTimeouts[start] = null;
|
||||||
|
},
|
||||||
|
this.pagesLoading ? 1000 : 0
|
||||||
|
);
|
||||||
|
|
||||||
|
this.pagesLoading++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and inject the specified range of posts into the stream, without
|
||||||
|
* clearing it.
|
||||||
|
*
|
||||||
|
* @param {number} start
|
||||||
|
* @param {number} end
|
||||||
|
* @return {Promise}
|
||||||
|
*/
|
||||||
|
loadRange(start, end) {
|
||||||
|
const loadIds = [];
|
||||||
|
const loaded = [];
|
||||||
|
|
||||||
|
this.discussion
|
||||||
|
.postIds()
|
||||||
|
.slice(start, end)
|
||||||
|
.forEach((id) => {
|
||||||
|
const post = app.store.getById('posts', id);
|
||||||
|
|
||||||
|
if (post && post.discussion() && typeof post.canEdit() !== 'undefined') {
|
||||||
|
loaded.push(post);
|
||||||
|
} else {
|
||||||
|
loadIds.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return loadIds.length ? app.store.find('posts', loadIds) : m.deferred().resolve(loaded).promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up the stream with the given array of posts.
|
||||||
|
*
|
||||||
|
* @param {Post[]} posts
|
||||||
|
*/
|
||||||
|
show(posts) {
|
||||||
|
this.visibleStart = posts.length ? this.discussion.postIds().indexOf(posts[0].id()) : 0;
|
||||||
|
this.visibleEnd = this.sanitizeIndex(this.visibleStart + posts.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the stream so that a specific range of posts is displayed. If a range
|
||||||
|
* is not specified, the first page of posts will be displayed.
|
||||||
|
*
|
||||||
|
* @param {number} [start]
|
||||||
|
* @param {number} [end]
|
||||||
|
*/
|
||||||
|
reset(start, end) {
|
||||||
|
this.visibleStart = start || 0;
|
||||||
|
this.visibleEnd = this.sanitizeIndex(end || this.constructor.loadCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the visible page of posts.
|
||||||
|
*
|
||||||
|
* @return {Post[]}
|
||||||
|
*/
|
||||||
|
posts() {
|
||||||
|
return this.discussion
|
||||||
|
.postIds()
|
||||||
|
.slice(this.visibleStart, this.visibleEnd)
|
||||||
|
.map((id) => {
|
||||||
|
const post = app.store.getById('posts', id);
|
||||||
|
|
||||||
|
return post && post.discussion() && typeof post.canEdit() !== 'undefined' ? post : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the total number of posts in the discussion.
|
||||||
|
*
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
count() {
|
||||||
|
return this.discussion.postIds().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Are we currently viewing the end of the discussion?
|
||||||
|
*
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
viewingEnd() {
|
||||||
|
return this.visibleEnd === this.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make sure that the given index is not outside of the possible range of
|
||||||
|
* indexes in the discussion.
|
||||||
|
*
|
||||||
|
* @param {number} index
|
||||||
|
*/
|
||||||
|
sanitizeIndex(index) {
|
||||||
|
return Math.max(0, Math.min(this.count(), Math.floor(index)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of posts to load per page.
|
||||||
|
*
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
PostStreamState.loadCount = 20;
|
||||||
|
|
||||||
|
export default PostStreamState;
|
35
js/src/forum/states/SearchState.js
Normal file
35
js/src/forum/states/SearchState.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export default class SearchState {
|
||||||
|
constructor(cachedSearches = []) {
|
||||||
|
this.cachedSearches = cachedSearches;
|
||||||
|
}
|
||||||
|
|
||||||
|
getValue() {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the search value.
|
||||||
|
*/
|
||||||
|
clear() {
|
||||||
|
this.setValue('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark that we have already searched for this query so that we don't
|
||||||
|
* have to ping the endpoint again.
|
||||||
|
*/
|
||||||
|
cache(query) {
|
||||||
|
this.cachedSearches.push(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this query has been searched before.
|
||||||
|
*/
|
||||||
|
isCached(query) {
|
||||||
|
return this.cachedSearches.indexOf(query) !== -1;
|
||||||
|
}
|
||||||
|
}
|
@@ -167,28 +167,26 @@ export default {
|
|||||||
|
|
||||||
if (app.session.user) {
|
if (app.session.user) {
|
||||||
if (this.canReply()) {
|
if (this.canReply()) {
|
||||||
let component = app.composer.component;
|
if (!app.composer.composingReplyTo(this) || forceRefresh) {
|
||||||
if (!app.composingReplyTo(this) || forceRefresh) {
|
app.composer.load(ReplyComposer, {
|
||||||
component = new ReplyComposer({
|
|
||||||
user: app.session.user,
|
user: app.session.user,
|
||||||
discussion: this,
|
discussion: this,
|
||||||
});
|
});
|
||||||
app.composer.load(component);
|
|
||||||
}
|
}
|
||||||
app.composer.show();
|
app.composer.show();
|
||||||
|
|
||||||
if (goToLast && app.viewingDiscussion(this) && !app.composer.isFullScreen()) {
|
if (goToLast && app.viewingDiscussion(this) && !app.composer.isFullScreen()) {
|
||||||
app.current.stream.goToNumber('reply');
|
app.current.get('stream').goToNumber('reply');
|
||||||
}
|
}
|
||||||
|
|
||||||
deferred.resolve(component);
|
deferred.resolve(app.composer);
|
||||||
} else {
|
} else {
|
||||||
deferred.reject();
|
deferred.reject();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
deferred.reject();
|
deferred.reject();
|
||||||
|
|
||||||
app.modal.show(new LogInModal());
|
app.modal.show(LogInModal);
|
||||||
}
|
}
|
||||||
|
|
||||||
return deferred.promise;
|
return deferred.promise;
|
||||||
@@ -229,13 +227,7 @@ export default {
|
|||||||
app.history.back();
|
app.history.back();
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.delete().then(() => {
|
return this.delete().then(() => app.discussions.removeDiscussion(this));
|
||||||
// If there is a discussion list in the cache, remove this discussion.
|
|
||||||
if (app.cache.discussionList) {
|
|
||||||
app.cache.discussionList.removeDiscussion(this);
|
|
||||||
m.redraw();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -245,11 +237,9 @@ export default {
|
|||||||
* @return {Promise}
|
* @return {Promise}
|
||||||
*/
|
*/
|
||||||
renameAction() {
|
renameAction() {
|
||||||
return app.modal.show(
|
return app.modal.show(RenameDiscussionModal, {
|
||||||
new RenameDiscussionModal({
|
|
||||||
currentTitle: this.title(),
|
currentTitle: this.title(),
|
||||||
discussion: this,
|
discussion: this,
|
||||||
})
|
});
|
||||||
);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@@ -130,12 +130,10 @@ export default {
|
|||||||
editAction() {
|
editAction() {
|
||||||
const deferred = m.deferred();
|
const deferred = m.deferred();
|
||||||
|
|
||||||
const component = new EditPostComposer({ post: this });
|
app.composer.load(EditPostComposer, { post: this });
|
||||||
|
|
||||||
app.composer.load(component);
|
|
||||||
app.composer.show();
|
app.composer.show();
|
||||||
|
|
||||||
deferred.resolve(component);
|
deferred.resolve(app.composer);
|
||||||
|
|
||||||
return deferred.promise;
|
return deferred.promise;
|
||||||
},
|
},
|
||||||
@@ -181,10 +179,7 @@ export default {
|
|||||||
// If this was the last post in the discussion, then we will assume that
|
// If this was the last post in the discussion, then we will assume that
|
||||||
// the whole discussion was deleted too.
|
// the whole discussion was deleted too.
|
||||||
if (!discussion.postIds().length) {
|
if (!discussion.postIds().length) {
|
||||||
// If there is a discussion list in the cache, remove this discussion.
|
app.discussions.removeDiscussion(discussion);
|
||||||
if (app.cache.discussionList) {
|
|
||||||
app.cache.discussionList.removeDiscussion(discussion);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (app.viewingDiscussion(discussion)) {
|
if (app.viewingDiscussion(discussion)) {
|
||||||
app.history.back();
|
app.history.back();
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
import Alert from '../../common/components/Alert';
|
|
||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
import Separator from '../../common/components/Separator';
|
import Separator from '../../common/components/Separator';
|
||||||
import EditUserModal from '../components/EditUserModal';
|
import EditUserModal from '../components/EditUserModal';
|
||||||
@@ -112,7 +111,7 @@ export default {
|
|||||||
.delete()
|
.delete()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.showDeletionAlert(user, 'success');
|
this.showDeletionAlert(user, 'success');
|
||||||
if (app.current instanceof UserPage && app.current.user === user) {
|
if (app.current.matches(UserPage, { user })) {
|
||||||
app.history.back();
|
app.history.back();
|
||||||
} else {
|
} else {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@@ -134,12 +133,10 @@ export default {
|
|||||||
error: 'core.forum.user_controls.delete_error_message',
|
error: 'core.forum.user_controls.delete_error_message',
|
||||||
}[type];
|
}[type];
|
||||||
|
|
||||||
app.alerts.show(
|
app.alerts.show({
|
||||||
new Alert({
|
|
||||||
type,
|
type,
|
||||||
children: app.translator.trans(message, { username, email }),
|
children: app.translator.trans(message, { username, email }),
|
||||||
})
|
});
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,6 +145,6 @@ export default {
|
|||||||
* @param {User} user
|
* @param {User} user
|
||||||
*/
|
*/
|
||||||
editAction(user) {
|
editAction(user) {
|
||||||
app.modal.show(new EditUserModal({ user }));
|
app.modal.show(EditUserModal, { user });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@@ -1,12 +1,16 @@
|
|||||||
const config = require('flarum-webpack-config');
|
const config = require('flarum-webpack-config');
|
||||||
const webpack = require('webpack');
|
|
||||||
const merge = require('webpack-merge');
|
const merge = require('webpack-merge');
|
||||||
|
|
||||||
module.exports = merge(config(), {
|
module.exports = merge(config(), {
|
||||||
output: {
|
output: {
|
||||||
library: 'flarum.core'
|
library: 'flarum.core'
|
||||||
},
|
},
|
||||||
plugins: [
|
|
||||||
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)
|
// temporary TS configuration
|
||||||
]
|
resolve: {
|
||||||
|
extensions: ['.ts', '.tsx', '.js', '.json'],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
module.exports['module'].rules[0].test = /\.(tsx?|js)$/;
|
||||||
|
module.exports['module'].rules[0].use.options.presets.push('@babel/preset-typescript');
|
||||||
|
@@ -11,7 +11,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.Widget {
|
.DashboardWidget {
|
||||||
background: @body-bg;
|
background: @body-bg;
|
||||||
color: @text-color;
|
color: @text-color;
|
||||||
border-radius: @border-radius;
|
border-radius: @border-radius;
|
||||||
|
@@ -236,16 +236,12 @@
|
|||||||
.App-header {
|
.App-header {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
height: @header-height;
|
height: @header-height;
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: @zindex-header;
|
z-index: @zindex-header;
|
||||||
|
|
||||||
.affix & {
|
|
||||||
position: fixed;
|
|
||||||
}
|
|
||||||
|
|
||||||
& when (@config-colored-header = true) {
|
& when (@config-colored-header = true) {
|
||||||
.light-contents(@header-color, @header-control-bg, @header-control-color);
|
.light-contents(@header-color, @header-control-bg, @header-control-color);
|
||||||
}
|
}
|
||||||
|
@@ -1,3 +1,14 @@
|
|||||||
|
// Store the current responsive screen mode in a CSS variable, to make it
|
||||||
|
// available to the JS code.
|
||||||
|
:root {
|
||||||
|
--flarum-screen: none;
|
||||||
|
|
||||||
|
@media @phone { --flarum-screen: phone }
|
||||||
|
@media @tablet { --flarum-screen: tablet }
|
||||||
|
@media @desktop { --flarum-screen: desktop }
|
||||||
|
@media @desktop-hd { --flarum-screen: desktop-hd }
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
&,
|
&,
|
||||||
&:before,
|
&:before,
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
|
|
||||||
.NotificationList {
|
.NotificationList {
|
||||||
|
overflow: hidden;
|
||||||
& .loading-indicator {
|
& .loading-indicator {
|
||||||
height: 100px;
|
height: 100px;
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
.NotificationsDropdown {
|
.NotificationsDropdown {
|
||||||
.Dropdown-menu {
|
.Dropdown-menu {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.NotificationList-content {
|
.NotificationList-content {
|
||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
|
@@ -288,6 +288,7 @@
|
|||||||
margin-top: -5px;
|
margin-top: -5px;
|
||||||
float: right;
|
float: right;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
.transition(opacity 0.2s);
|
.transition(opacity 0.2s);
|
||||||
|
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user