1
0
mirror of https://github.com/flarum/core.git synced 2025-08-13 20:04:24 +02:00

Compare commits

...

139 Commits

Author SHA1 Message Date
Franz Liedke
fc5eddb99d Default parameter value 2020-08-02 09:25:36 +02:00
Franz Liedke
bad8115a4a Remove obsolete properties in post stream state 2020-08-02 08:01:55 +02:00
Franz Liedke
1fc76acf06 Stop injecting post stream state into scrubber 2020-08-02 07:56:58 +02:00
Franz Liedke
527d93120a Shrink the diff, e.g. by moving methods around 2020-08-01 02:59:55 +02:00
Franz Liedke
53582ab999 WIP: Re-work stream, scrubber and state
Mostly, this tries to move common logic up to the DiscussionPage as the
lowest common ancestor of these components sharing certain state.

It's still messy, though. :-/
2020-08-01 02:47:36 +02:00
Franz Liedke
6f6a09d7c4 Start decoupling scrolling from state 2020-07-31 23:23:32 +02:00
Franz Liedke
e8394e4a1d Unify pausing 2020-07-31 16:42:42 +02:00
Franz Liedke
e455e6c431 Restore old implementation of goToLast()
It's not clear whether this was intentionally omitted.
2020-07-31 16:13:16 +02:00
Franz Liedke
a044c642f6 Add default parameter value
This is actually relied on already by not passing the parameter in other
methods.
2020-07-31 16:12:17 +02:00
Franz Liedke
01384139ef Encapsulate a bit more logic in the state 2020-07-31 15:42:01 +02:00
Franz Liedke
57f5ad4893 Move method to previous position 2020-07-31 13:27:26 +02:00
Franz Liedke
8b69b24272 Fix docblocks 2020-07-31 13:21:45 +02:00
Franz Liedke
09c722e522 Remove unused prop 2020-07-31 12:14:00 +02:00
Franz Liedke
3ce94757fc Rename props 2020-07-31 12:13:25 +02:00
Franz Liedke
aae6f24356 Fix docblock 2020-07-31 12:06:33 +02:00
Franz Liedke
1a2f9527fd Revert formatting changes 2020-07-31 11:32:54 +02:00
Alexander Skvortsov
8c362bf7c7 Don't save index twice in post stream post-load 2020-07-31 11:18:12 +02:00
Alexander Skvortsov
f99f79e3c0 De-propify visible 2020-07-31 11:18:12 +02:00
Alexander Skvortsov
bbd8136695 A bit more cleanup 2020-07-31 11:17:46 +02:00
Alexander Skvortsov
1d8662088f A bit more cleanup and bugfixes 2020-07-31 11:17:45 +02:00
Alexander Skvortsov
a850f4a6fb Restore old scrubber index calculation system 2020-07-31 11:17:45 +02:00
Alexander Skvortsov
af55a13c61 A bit more cleanup, UI bugfixes 2020-07-31 11:17:45 +02:00
Alexander Skvortsov
92b62e7ab6 Minor cleanup of PostStreamState methods 2020-07-31 11:17:44 +02:00
Alexander Skvortsov
5ef4de75d1 Fix date not showing up properly 2020-07-31 11:17:44 +02:00
Alexander Skvortsov
88e6be9d0e When scrolling to first post, scroll all the way to top, simplify scrollToItem promise structure 2020-07-31 11:17:44 +02:00
Alexander Skvortsov
228c7b883d move index calculation back out of show 2020-07-31 11:17:44 +02:00
Alexander Skvortsov
cdcf64852e Restore scrubber behavior 2020-07-31 11:17:43 +02:00
Alexander Skvortsov
d20650fb42 Use date of the post in index 2020-07-31 11:17:43 +02:00
Alexander Skvortsov
875a1f70c1 Fix date, index calculation on reload 2020-07-31 11:17:42 +02:00
Alexander Skvortsov
ef206495cd Try calculating index before redraw to avoid calling redraw immediately after scroll 2020-07-31 11:17:42 +02:00
Alexander Skvortsov
2360745237 Fix jumping around on page reload 2020-07-31 11:17:42 +02:00
Alexander Skvortsov
cc10eaadd2 Get rid of separate system for scrollToLast 2020-07-31 11:17:42 +02:00
Alexander Skvortsov
c98c0b027f Fix missing method call 2020-07-31 11:17:41 +02:00
Alexander Skvortsov
73507f403a Don't use anchorScroll on goToNumber, instead scrolling directly to item 2020-07-31 11:17:41 +02:00
Alexander Skvortsov
d3fb5ee77c Handle scroll to end as a special case of scroll to index to ensure that we get completely to the bottom and flash the bottom element 2020-07-31 11:17:41 +02:00
Alexander Skvortsov
479e5a8cf6 in goToNumber, only redraw when the response has been returned. 2020-07-31 11:17:40 +02:00
Alexander Skvortsov
4bce030115 Use same logic as in updateScrubber to calculate current post number 2020-07-31 11:17:40 +02:00
Alexander Skvortsov
9f2540dbe3 Properly bind loadNext button to the state 2020-07-31 11:17:40 +02:00
Alexander Skvortsov
aa15db6f44 Ensure consistent index in scrubber, rework current post index calculation logic. 2020-07-31 11:17:39 +02:00
Alexander Skvortsov
0c63be527b Pass in a selector string to anchorScroll instead of a DOM element. Because the DOM element gets destroyed on redraw, it's offset height is interpreted as 0 which throws off our position in the stream. 2020-07-31 11:17:39 +02:00
Alexander Skvortsov
9db2f78939 Incorporate math floor in sanitizeIndex, use that for scrubber index display. 2020-07-31 11:17:39 +02:00
Alexander Skvortsov
9572863648 Add anchorScroll with redraw after loadPromise loads in scrollToItem 2020-07-31 11:17:39 +02:00
Alexander Skvortsov
ac1eef7578 Remove unused anchorScroll import 2020-07-31 11:17:38 +02:00
Alexander Skvortsov
514165c3af Anchor scroll after loading posts 2020-07-31 11:17:38 +02:00
Alexander Skvortsov
e84960dcd1 Cleanup 2020-07-31 11:17:38 +02:00
Alexander Skvortsov
f8d1c7a317 Add redraws after posts have been loaded from the API 2020-07-31 11:17:37 +02:00
Alexander Skvortsov
ba82969a58 Remove unnecessary redraw 2020-07-31 11:17:37 +02:00
Alexander Skvortsov
b2917c8716 Add more console logs 2020-07-31 11:17:12 +02:00
Alexander Skvortsov
c150c097c1 Add some more debugging flags 2020-07-31 11:17:11 +02:00
Alexander Skvortsov
beab8ce39c Update scrubber after scrollToItem 2020-07-31 11:17:11 +02:00
Alexander Skvortsov
1360723c3f Separate updateScrubber into separate method from onscroll 2020-07-31 11:17:11 +02:00
Alexander Skvortsov
5cdfeaf9a5 Move scrollPromise log into scrollToItem 2020-07-31 11:17:10 +02:00
Alexander Skvortsov
6e1d385268 Code cleanup, added a bunch of debug console logging 2020-07-31 11:17:10 +02:00
Alexander Skvortsov
193f3b040d Slightly improve scrubber label accuracy on click 2020-07-31 11:17:10 +02:00
Alexander Skvortsov
74cb4f9007 Get rid of post stream events. Initial load is still buggy 2020-07-31 11:17:09 +02:00
Alexander Skvortsov
eb24e628fa Get rid of js-PostStream 2020-07-31 11:17:09 +02:00
Alexander Skvortsov
c03feceb9f Fix goToLast 2020-07-31 11:17:09 +02:00
Alexander Skvortsov
51008bc65d Use scrollToIndex to contain scrollToLast 2020-07-31 11:17:09 +02:00
Alexander Skvortsov
9a357f5d19 Add scrubber height change transition css, don't apply when dragging 2020-07-31 11:17:08 +02:00
Alexander Skvortsov
9c63c54868 Simplify paused logic 2020-07-31 11:17:08 +02:00
Alexander Skvortsov
5427b35c6d Large simplifications of PostStreamScrubber 2020-07-31 11:17:08 +02:00
Franz Liedke
2ec49db6df Pass discussion as prop to stream components
- Law of demeter (no need to access discussion through the state)
- Less public API surface of the state object
2020-07-31 11:17:07 +02:00
Franz Liedke
062dc8f57f Don't call protected method outside of state
In addition, this again avoids writing a state property from
outside the state class.

I am not 100% sure whether this extra sanitization is necessary,
but it seems to be the only place where it is not applied when
changing the value of `visibleEnd` (and not safeguarded otherwise),
so I erred on the safe side.
2020-07-31 11:17:07 +02:00
Franz Liedke
8a9e50d192 Encapsulate viewingEnd() in state
...instead of calculating this derived value outside the state class.
2020-07-31 11:17:07 +02:00
Franz Liedke
6c087da65f Remove obsolete event handler
The event is not triggered anymore.
This is now handled through the `positionHandler` prop.
2020-07-31 11:17:06 +02:00
Franz Liedke
6bcecd623b Revert inlining method, rename the method instead 2020-07-31 11:17:06 +02:00
Alexander Skvortsov
614bb0d71e Moved refresh method of discussionpage into init, as its not used externally (and using it would be bad practice), fixing up PostStream 2020-07-31 11:17:06 +02:00
Alexander Skvortsov
cff9b327a9 Remove event from PostState, pass handler in via props, 2020-07-31 11:17:06 +02:00
Alexander Skvortsov
7af8e35a6e Extract PostStream state 2020-07-31 11:17:03 +02:00
dependabot[bot]
f9c9b5d5e4 Bump elliptic from 6.5.2 to 6.5.3 in /js (#2251)
Bumps [elliptic](https://github.com/indutny/elliptic) from 6.5.2 to 6.5.3.
- [Release notes](https://github.com/indutny/elliptic/releases)
- [Commits](https://github.com/indutny/elliptic/compare/v6.5.2...v6.5.3)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-07-31 01:08:39 +02:00
Alexander Skvortsov
8a73cc522e Fix optional parameters in url generator (#2246)
* Fix route collection getting wrong path when optional parameters present, add unit tests
2020-07-28 20:51:14 -04:00
Franz Liedke
db83003eb5 Apply fixes from StyleCI
[ci skip] [skip ci]
2020-07-27 19:42:23 +00:00
Franz Liedke
4dc4dc624e Merge pull request #2243 from flarum/fl/2055-l6-translator
Upgrade to Laravel 6, finally!
2020-07-27 21:42:01 +02:00
flarum-bot
ad42058a8a Bundled output for commit 5e465f6051 [skip ci] 2020-07-24 22:18:35 +00:00
Alexander Skvortsov
5e465f6051 Extract Composer state (#2161)
Like previous "state PRs", this moves app-wide logic relating to
our "composer" widget to its own "state" class, which can be
referenced and called from all parts of the app. This lets us
avoid storing component instances, which we cannot do any longer
once we update to Mithril v2.

This was not as trivial as some of the other state changes, as we
tried to separate DOM effects (e.g. animations) from actual state
changes (e.g. minimizing or opening the composer).

New features:

- A new `app.screen()` method returns the current responsive screen
  mode. This lets us check what breakpoint we're on in JS land  
  without hardcoding / duplicating the actual breakpoints from CSS.
- A new `SuperTextarea` util exposes useful methods for directly
  interacting with and manipulating the text contents of e.g. our
  post editor.
- A new `ConfirmDocumentUnload` wrapper component encapsulates the
  logic for asking the user for confirmation when trying to close
  the browser window or navigating to another page. This is used in
  the composer to prevent accidentally losing unsaved post content.

There is still potential for future cleanups, but we finally want   
to unblock the Mithril update, so these will have to wait:

- Composer height change logic is very DOM-based, so should maybe
  not sit in the state.
- I would love to experiment with using composition rather than
  inheritance for the `ComposerBody` subclasses.
2020-07-25 00:17:25 +02:00
flarum-bot
62a2e8463d Bundled output for commit 0098c64ebf [skip ci] 2020-07-24 21:53:31 +00:00
Franz Liedke
0098c64ebf Fix an irrelevant export name :P 2020-07-24 23:51:44 +02:00
Franz Liedke
2b5939d538 Simplify a few unnecessary Arr::get() calls 2020-07-24 22:56:31 +02:00
Alexander Skvortsov
2431df5602 Revert "Fixes wrong IP address when using a reverse proxy (#2236)" (#2242)
This reverts commit 451a557532 pending further discussion of https://github.com/flarum/core/pull/2236#issuecomment-663645583
2020-07-24 14:19:10 -04:00
flarum-bot
264ff67304 Bundled output for commit c08a56e9d8 [skip ci] 2020-07-24 17:03:04 +00:00
Alexander Skvortsov
c08a56e9d8 Notifications Dropdown: Remove init method that doesn't do anything (cleanup) 2020-07-24 13:01:45 -04:00
Alexander Skvortsov
4ee6d6fd88 Revert "Inject Url Generator and Translator Interface into notification mailer (#2169)"
This was actually already present and functional, so adding additional code for it
is unnecessary.

This reverts commit e627616750.
2020-07-24 12:44:59 -04:00
Franz Liedke
9c09fe8465 Update to Laravel 6, finally!
Fixes #2055.
2020-07-24 17:34:40 +02:00
Franz Liedke
b46d5e67a3 Make Translator compatible with Laravel 6
It's contract will change in Laravel 6. We extend from Symfony's
translator, but need to be compatible with that from Laravel in
order to use its validation package.

References:
- https://laravel.com/docs/6.x/upgrade#trans-and-trans-choice
- 8557dc56b1 (diff-88bc04a1548d09aa6250d902d1ac2b4c)
2020-07-24 17:32:50 +02:00
Franz Liedke
7fd23ff950 Inject Symfony translator contract, not Laravel's
The Laravel changes with v6, and our translator is primarily an
implementation of the Symfony contract.
2020-07-24 17:31:46 +02:00
Franz Liedke
e4077ab4ad Replace a few forgotten obsolete helpers
- Apparently, I forgot that `array_flatten` comes from Laravel. :)
- When I did this previously, I did not search the views directory.
2020-07-24 17:28:56 +02:00
Franz Liedke
3b39c212e0 Explicitly bundle Carbon library
We have used this transitive dependency (via illuminate/support)
for a while, so let's make this explicit.

Incidentally, we now also explicitly require version 2.x - the
previous 1.x branch will no longer be supported after the
upcoming upgrade to Laravel 6.

Refs #2055.
2020-07-24 16:46:33 +02:00
Franz Liedke
bca833d3f1 Remove Mandrill mail driver
This is in preparation for the upcoming upgrade to Laravel 6,
which dropped this driver.

Refs #2055.
2020-07-24 16:39:28 +02:00
Jake Esser
451a557532 Fixes wrong IP address when using a reverse proxy (#2236)
Added reverse proxy support to preserve forwarded IPs
2020-07-22 08:55:44 -04:00
Alexander Skvortsov
eaac78650f Deprecate AssertPermissionTrait (#2044) 2020-07-17 15:16:15 +02:00
Franz Liedke
2b3dec2be1 Fix deprecation and removal date 2020-07-17 12:19:48 +02:00
Alexander Skvortsov
37ebeb5705 User Extender (prepareGroups functionality) (#2110) 2020-07-17 12:18:35 +02:00
Franz Liedke
71abac0323 Rename view extender
As discussed in my initial review, it seems unlikely that we need
the ability to remove (or otherwise modify) namespaces again.
Therefore, it seems more consistent with other extenders to go
for a "View" extender with a "namespace" method.

Sorry for the back and forth. ;)

Refs #1891, #2134.
2020-07-17 12:05:49 +02:00
Franz Liedke
7e3d71a0a0 View extender: Do not resolve factory
Not all requests need this factory, so there is no need to
instantiate one and load the required files.

Refs #1891, #2134.
2020-07-17 12:05:38 +02:00
Alexander Skvortsov
b5e891df30 View Extender (add namespace) (#2134) 2020-07-17 11:59:00 +02:00
Alexander Skvortsov
3117d2ad7a Use lifecycle interface for frontend extender (#2211) 2020-07-17 11:49:52 +02:00
dependabot[bot]
1ce0b926b6 Bump lodash from 4.17.15 to 4.17.19 in /js (#2235)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.15 to 4.17.19.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.15...4.17.19)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-07-17 10:47:28 +02:00
flarum-bot
24b16f9d7c Bundled output for commit bd40353bcc [skip ci] 2020-07-10 13:42:33 +00:00
Franz Liedke
bd40353bcc Merge pull request #2207 from flarum/ds/typescript-conversion
Convert several files in `common/utils` to TypeScript
2020-07-10 15:41:14 +02:00
David Sevilla Martin
455327cca1 convert: common/utils/stringToColor 2020-07-10 14:13:33 +02:00
David Sevilla Martin
20baa93ca7 convert: common/utils/string 2020-07-10 14:13:33 +02:00
David Sevilla Martin
4f34e326ef convert: common/utils/RequestError 2020-07-10 14:13:33 +02:00
David Sevilla Martin
521cefbc2d convert: common/utils/liveHumanTimes
This file isn't used anywhere. We should be calling it at some point. It has existed for 5 years.

Renamed function because it makes more sense for name to match file name (not that it matters when building)
2020-07-10 14:13:32 +02:00
David Sevilla Martin
dc738d68dc convert: common/utils/abbreviateNumber 2020-07-10 14:13:32 +02:00
David Sevilla Martin
286af7084b convert: common/utils/extract 2020-07-10 14:13:31 +02:00
David Sevilla Martin
4869baea74 convert: common/utils/ItemList 2020-07-10 14:13:31 +02:00
David Sevilla Martin
24a48310ff convert: common/utils/humanTime 2020-07-10 14:05:09 +02:00
David Sevilla Martin
bdb759c558 convert: common/utils/formatNumber 2020-07-10 14:05:07 +02:00
Matt Kilgore
36eb5cc5fb Add port on Url to BaseUrl Test (#2226)
Added Urls with ports to the BaseUrl Test
2020-07-10 12:17:12 +02:00
David Sevilla Martín
d189272473 Initial TypeScript infrastructure (#2206)
This allows us to get started with converting all Flarum JavaScript code to TypeScript.
In addition, we will have time to experiment to find the best Webpack configuration before integrating into flarum-webpack-config.

See flarum/flarum-webpack-config#3.
2020-07-03 14:47:44 +02:00
flarum-bot
7d48c24dda Bundled output for commit 5786f1a10b [skip ci] 2020-07-03 05:17:34 +00:00
Alexander Skvortsov
5786f1a10b Fix discussions user page (#2225)
* Fixed up discussions user page, improve discussion list state signature
2020-07-03 01:16:08 -04:00
flarum-bot
b4421e1cce Bundled output for commit 359b4ab5a3 [skip ci] 2020-07-02 22:33:46 +00:00
Clark Winkelmann
359b4ab5a3 Fix user card issue by reverting to original behavior (#2224)
* Fix user card issue by reverting to original behavior
2020-07-02 18:32:41 -04:00
Alexander Skvortsov
8a686911ff Don't create user bio column on new installations (#2215) 2020-07-01 17:31:52 -04:00
Alexander Skvortsov
0b5a9a2fe6 Make scrubber handle have transparent background (#2222) 2020-07-01 17:07:13 -04:00
flarum-bot
50a9f7ce86 Bundled output for commit 8dd5420405 [skip ci] 2020-07-01 00:34:15 +00:00
David Sevilla Martín
8dd5420405 Switch from 'moment' to 'dayjs' (#2219)
* Switch from 'moment' to 'dayjs'

* Use humanize code from duration plugin (without actual plugin) for time lapsed events
2020-06-30 20:33:00 -04:00
flarum-bot
640cc0989b Bundled output for commit 44376cef61 [skip ci] 2020-07-01 00:00:24 +00:00
Alexander Skvortsov
44376cef61 Extract ModalManagerState from ModalManager (#2162) 2020-06-30 19:59:16 -04:00
flarum-bot
4f181c84fc Bundled output for commit ea9d601338 [skip ci] 2020-06-30 22:08:06 +00:00
Alexander Skvortsov
ea9d601338 Extract AlertManagerState from AlertManager (#2163) 2020-06-30 18:06:59 -04:00
Alexander Skvortsov
aaebd3581f Fix: Use proper variable for display name drivers in user extender 2020-06-29 19:32:08 -04:00
flarum-bot
e2c416903e Bundled output for commit e81159249f [skip ci] 2020-06-28 17:45:26 +00:00
Alexander Skvortsov
e81159249f Add check to register state of '0' as false for checkboxes (#2210)
* Add check to register state of '0' as false for checkboxes
* Add comment explaining state === '0'
2020-06-28 13:44:14 -04:00
flarum-bot
d93cf4a574 Bundled output for commit a33fbbf814 [skip ci] 2020-06-27 18:20:09 +00:00
Alexander Skvortsov
a33fbbf814 Add index page title, add mechanism to clear title from defaultRoute. (#2047)
* Add "All Descriptions title to index

* Added system to clear custom title if we're on the default route
2020-06-27 14:18:49 -04:00
flarum-bot
0c645a6c15 Bundled output for commit b44b79eba9 [skip ci] 2020-06-26 16:25:45 +00:00
Franz Liedke
b44b79eba9 Fix typo and update outdated doc block 2020-06-26 18:23:56 +02:00
flarum-bot
93398b738b Bundled output for commit 7816b61bfb [skip ci] 2020-06-26 14:08:35 +00:00
Franz Liedke
7816b61bfb Remove documentation for obsolete component prop 2020-06-26 16:06:56 +02:00
Franz Liedke
7dc3a194c3 Expose a method for clearing notification list
Needed for pusher extension.

Refs #2185.
2020-06-26 15:10:41 +02:00
flarum-bot
cea7824b57 Bundled output for commit 088eb0c4f2 [skip ci] 2020-06-26 12:32:40 +00:00
Franz Liedke
088eb0c4f2 Move DiscussionListState to correct folder 2020-06-26 12:52:33 +02:00
Franz Liedke
2ba67b021f Expose state classes via compat
This way, they can be extended by extensions.
2020-06-26 12:50:43 +02:00
flarum-bot
92791a253d Bundled output for commit 138c784a50 [skip ci] 2020-06-24 00:51:55 +00:00
David Sevilla Martín
138c784a50 Call liveHumanTimes() to update ago times every 10s (#2208)
This file has existed for 5 years, yet it was never used.
2020-06-23 20:50:57 -04:00
flarum-bot
bb567e5278 Bundled output for commit cf4f2f283e [skip ci] 2020-06-20 14:19:53 +00:00
w-4
cf4f2f283e Fix discussion unreadCount could be higher than commentCount (#2195)
* Fix discussion unreadCount being higher than commentCount if posts have been deleted
2020-06-20 10:18:26 -04:00
140 changed files with 2410 additions and 1697 deletions

View File

@@ -41,21 +41,21 @@
"dflydev/fig-cookies": "^2.0.1",
"doctrine/dbal": "^2.7",
"franzl/whoops-middleware": "^0.4.0",
"illuminate/bus": "5.8.*",
"illuminate/cache": "5.8.*",
"illuminate/config": "5.8.*",
"illuminate/container": "5.8.*",
"illuminate/contracts": "5.8.*",
"illuminate/database": "5.8.*",
"illuminate/events": "5.8.*",
"illuminate/filesystem": "5.8.*",
"illuminate/hashing": "5.8.*",
"illuminate/mail": "5.8.*",
"illuminate/queue": "5.8.*",
"illuminate/session": "5.8.*",
"illuminate/support": "5.8.*",
"illuminate/validation": "5.8.*",
"illuminate/view": "5.8.*",
"illuminate/bus": "^6.0",
"illuminate/cache": "^6.0",
"illuminate/config": "^6.0",
"illuminate/container": "^6.0",
"illuminate/contracts": "^6.0",
"illuminate/database": "^6.0",
"illuminate/events": "^6.0",
"illuminate/filesystem": "^6.0",
"illuminate/hashing": "^6.0",
"illuminate/mail": "^6.0",
"illuminate/queue": "^6.0",
"illuminate/session": "^6.0",
"illuminate/support": "^6.0",
"illuminate/validation": "^6.0",
"illuminate/view": "^6.0",
"intervention/image": "^2.5.0",
"laminas/laminas-diactoros": "^1.8.4",
"laminas/laminas-httphandlerrunner": "^1.0",
@@ -66,6 +66,7 @@
"middlewares/base-path-router": "^0.2.1",
"middlewares/request-handler": "^1.2",
"monolog/monolog": "^1.16.0",
"nesbot/carbon": "^2.0",
"nikic/fast-route": "^0.6",
"psr/http-message": "^1.0",
"psr/http-server-handler": "^1.0",

6
js/dist/admin.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

8
js/dist/forum.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

211
js/package-lock.json generated
View File

@@ -289,6 +289,11 @@
"@babel/types": "^7.0.0"
}
},
"@babel/helper-validator-identifier": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.3.tgz",
"integrity": "sha512-bU8JvtlYpJSBPuj1VUmKpFGaDZuLxASky3LhaKj3bmpSTY6VWooSM8msk+Z0CZoErFye2tlABF6yDkT3FOPAXw=="
},
"@babel/helper-wrap-function": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.2.0.tgz",
@@ -445,6 +450,21 @@
"@babel/helper-plugin-utils": "^7.0.0"
}
},
"@babel/plugin-syntax-typescript": {
"version": "7.10.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.10.1.tgz",
"integrity": "sha512-X/d8glkrAtra7CaQGMiGs/OGa6XgUzqPcBXCIGFCpCqnfGlT0Wfbzo/B89xHhnInTaItPK8LALblVXcUOEh95Q==",
"requires": {
"@babel/helper-plugin-utils": "^7.10.1"
},
"dependencies": {
"@babel/helper-plugin-utils": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.3.tgz",
"integrity": "sha512-j/+j8NAWUTxOtx4LKHybpSClxHoq6I91DQ/mKgAXn5oNUPIUiGppjPIX3TDtJWPrdfP9Kfl7e4fgVMiQR9VE/g=="
}
}
},
"@babel/plugin-transform-arrow-functions": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.2.0.tgz",
@@ -740,6 +760,159 @@
"@babel/helper-plugin-utils": "^7.0.0"
}
},
"@babel/plugin-transform-typescript": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.10.3.tgz",
"integrity": "sha512-qU9Lu7oQyh3PGMQncNjQm8RWkzw6LqsWZQlZPQMgrGt6s3YiBIaQ+3CQV/FA/icGS5XlSWZGwo/l8ErTyelS0Q==",
"requires": {
"@babel/helper-create-class-features-plugin": "^7.10.3",
"@babel/helper-plugin-utils": "^7.10.3",
"@babel/plugin-syntax-typescript": "^7.10.1"
},
"dependencies": {
"@babel/code-frame": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.3.tgz",
"integrity": "sha512-fDx9eNW0qz0WkUeqL6tXEXzVlPh6Y5aCDEZesl0xBGA8ndRukX91Uk44ZqnkECp01NAZUdCAl+aiQNGi0k88Eg==",
"requires": {
"@babel/highlight": "^7.10.3"
}
},
"@babel/generator": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.10.3.tgz",
"integrity": "sha512-drt8MUHbEqRzNR0xnF8nMehbY11b1SDkRw03PSNH/3Rb2Z35oxkddVSi3rcaak0YJQ86PCuE7Qx1jSFhbLNBMA==",
"requires": {
"@babel/types": "^7.10.3",
"jsesc": "^2.5.1",
"lodash": "^4.17.13",
"source-map": "^0.5.0"
}
},
"@babel/helper-create-class-features-plugin": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.10.3.tgz",
"integrity": "sha512-iRT9VwqtdFmv7UheJWthGc/h2s7MqoweBF9RUj77NFZsg9VfISvBTum3k6coAhJ8RWv2tj3yUjA03HxPd0vfpQ==",
"requires": {
"@babel/helper-function-name": "^7.10.3",
"@babel/helper-member-expression-to-functions": "^7.10.3",
"@babel/helper-optimise-call-expression": "^7.10.3",
"@babel/helper-plugin-utils": "^7.10.3",
"@babel/helper-replace-supers": "^7.10.1",
"@babel/helper-split-export-declaration": "^7.10.1"
}
},
"@babel/helper-function-name": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.10.3.tgz",
"integrity": "sha512-FvSj2aiOd8zbeqijjgqdMDSyxsGHaMt5Tr0XjQsGKHD3/1FP3wksjnLAWzxw7lvXiej8W1Jt47SKTZ6upQNiRw==",
"requires": {
"@babel/helper-get-function-arity": "^7.10.3",
"@babel/template": "^7.10.3",
"@babel/types": "^7.10.3"
}
},
"@babel/helper-get-function-arity": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.3.tgz",
"integrity": "sha512-iUD/gFsR+M6uiy69JA6fzM5seno8oE85IYZdbVVEuQaZlEzMO2MXblh+KSPJgsZAUx0EEbWXU0yJaW7C9CdAVg==",
"requires": {
"@babel/types": "^7.10.3"
}
},
"@babel/helper-member-expression-to-functions": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.10.3.tgz",
"integrity": "sha512-q7+37c4EPLSjNb2NmWOjNwj0+BOyYlssuQ58kHEWk1Z78K5i8vTUsteq78HMieRPQSl/NtpQyJfdjt3qZ5V2vw==",
"requires": {
"@babel/types": "^7.10.3"
}
},
"@babel/helper-optimise-call-expression": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.3.tgz",
"integrity": "sha512-kT2R3VBH/cnSz+yChKpaKRJQJWxdGoc6SjioRId2wkeV3bK0wLLioFpJROrX0U4xr/NmxSSAWT/9Ih5snwIIzg==",
"requires": {
"@babel/types": "^7.10.3"
}
},
"@babel/helper-plugin-utils": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.3.tgz",
"integrity": "sha512-j/+j8NAWUTxOtx4LKHybpSClxHoq6I91DQ/mKgAXn5oNUPIUiGppjPIX3TDtJWPrdfP9Kfl7e4fgVMiQR9VE/g=="
},
"@babel/helper-replace-supers": {
"version": "7.10.1",
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.10.1.tgz",
"integrity": "sha512-SOwJzEfpuQwInzzQJGjGaiG578UYmyi2Xw668klPWV5n07B73S0a9btjLk/52Mlcxa+5AdIYqws1KyXRfMoB7A==",
"requires": {
"@babel/helper-member-expression-to-functions": "^7.10.1",
"@babel/helper-optimise-call-expression": "^7.10.1",
"@babel/traverse": "^7.10.1",
"@babel/types": "^7.10.1"
}
},
"@babel/helper-split-export-declaration": {
"version": "7.10.1",
"resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.10.1.tgz",
"integrity": "sha512-UQ1LVBPrYdbchNhLwj6fetj46BcFwfS4NllJo/1aJsT+1dLTEnXJL0qHqtY7gPzF8S2fXBJamf1biAXV3X077g==",
"requires": {
"@babel/types": "^7.10.1"
}
},
"@babel/highlight": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.3.tgz",
"integrity": "sha512-Ih9B/u7AtgEnySE2L2F0Xm0GaM729XqqLfHkalTsbjXGyqmf/6M0Cu0WpvqueUlW+xk88BHw9Nkpj49naU+vWw==",
"requires": {
"@babel/helper-validator-identifier": "^7.10.3",
"chalk": "^2.0.0",
"js-tokens": "^4.0.0"
}
},
"@babel/parser": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.10.3.tgz",
"integrity": "sha512-oJtNJCMFdIMwXGmx+KxuaD7i3b8uS7TTFYW/FNG2BT8m+fmGHoiPYoH0Pe3gya07WuFmM5FCDIr1x0irkD/hyA=="
},
"@babel/template": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.10.3.tgz",
"integrity": "sha512-5BjI4gdtD+9fHZUsaxPHPNpwa+xRkDO7c7JbhYn2afvrkDu5SfAAbi9AIMXw2xEhO/BR35TqiW97IqNvCo/GqA==",
"requires": {
"@babel/code-frame": "^7.10.3",
"@babel/parser": "^7.10.3",
"@babel/types": "^7.10.3"
}
},
"@babel/traverse": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.10.3.tgz",
"integrity": "sha512-qO6623eBFhuPm0TmmrUFMT1FulCmsSeJuVGhiLodk2raUDFhhTECLd9E9jC4LBIWziqt4wgF6KuXE4d+Jz9yug==",
"requires": {
"@babel/code-frame": "^7.10.3",
"@babel/generator": "^7.10.3",
"@babel/helper-function-name": "^7.10.3",
"@babel/helper-split-export-declaration": "^7.10.1",
"@babel/parser": "^7.10.3",
"@babel/types": "^7.10.3",
"debug": "^4.1.0",
"globals": "^11.1.0",
"lodash": "^4.17.13"
}
},
"@babel/types": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.10.3.tgz",
"integrity": "sha512-nZxaJhBXBQ8HVoIcGsf9qWep3Oh3jCENK54V4mRF7qaJabVsAYdbTtmSD8WmAp1R6ytPiu5apMwSXyxB1WlaBA==",
"requires": {
"@babel/helper-validator-identifier": "^7.10.3",
"lodash": "^4.17.13",
"to-fast-properties": "^2.0.0"
}
}
}
},
"@babel/plugin-transform-unicode-regex": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.2.0.tgz",
@@ -812,6 +985,22 @@
"@babel/plugin-transform-react-jsx-source": "^7.0.0"
}
},
"@babel/preset-typescript": {
"version": "7.10.1",
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.10.1.tgz",
"integrity": "sha512-m6GV3y1ShiqxnyQj10600ZVOFrSSAa8HQ3qIUk2r+gcGtHTIRw0dJnFLt1WNXpKjtVw7yw1DAPU/6ma2ZvgJuA==",
"requires": {
"@babel/helper-plugin-utils": "^7.10.1",
"@babel/plugin-transform-typescript": "^7.10.1"
},
"dependencies": {
"@babel/helper-plugin-utils": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.3.tgz",
"integrity": "sha512-j/+j8NAWUTxOtx4LKHybpSClxHoq6I91DQ/mKgAXn5oNUPIUiGppjPIX3TDtJWPrdfP9Kfl7e4fgVMiQR9VE/g=="
}
}
},
"@babel/runtime": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.1.5.tgz",
@@ -1834,6 +2023,11 @@
"resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz",
"integrity": "sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk="
},
"dayjs": {
"version": "1.8.28",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.8.28.tgz",
"integrity": "sha512-ccnYgKC0/hPSGXxj7Ju6AV/BP4HUkXC2u15mikXT5mX9YorEaoi1bEKOmAqdkJHN4EEkmAf97SpH66Try5Mbeg=="
},
"debug": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
@@ -1942,9 +2136,9 @@
"integrity": "sha512-De+lPAxEcpxvqPTyZAXELNpRZXABRxf+uL/rSykstQhzj/B0l1150G/ExIIxKc16lI89Hgz81J0BHAcbTqK49g=="
},
"elliptic": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz",
"integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==",
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz",
"integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==",
"requires": {
"bn.js": "^4.4.0",
"brorand": "^1.0.1",
@@ -3443,9 +3637,9 @@
}
},
"lodash": {
"version": "4.17.15",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
"version": "4.17.19",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz",
"integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ=="
},
"lodash-es": {
"version": "4.17.14",
@@ -3644,11 +3838,6 @@
"minimist": "^1.2.5"
}
},
"moment": {
"version": "2.22.2",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.22.2.tgz",
"integrity": "sha1-PCV/mDn8DpP/UxSWMiOeuQeD/2Y="
},
"move-concurrently": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",

View File

@@ -2,9 +2,11 @@
"private": true,
"name": "@flarum/core",
"dependencies": {
"@babel/preset-typescript": "^7.10.1",
"bootstrap": "^3.4.1",
"classnames": "^2.2.5",
"color-thief-browser": "^2.0.2",
"dayjs": "^1.8.28",
"expose-loader": "^0.7.5",
"flarum-webpack-config": "0.1.0-beta.10",
"jquery": "^3.4.1",
@@ -12,7 +14,6 @@
"lodash-es": "^4.17.14",
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",
"mithril": "^0.2.8",
"moment": "^2.22.2",
"punycode": "^2.1.1",
"spin.js": "^3.1.0",
"webpack": "^4.43.0",

View File

@@ -13,8 +13,8 @@ export default class AppearancePage extends Page {
this.primaryColor = m.prop(app.data.settings.theme_primary_color);
this.secondaryColor = m.prop(app.data.settings.theme_secondary_color);
this.darkMode = m.prop(app.data.settings.theme_dark_mode === '1');
this.coloredHeader = m.prop(app.data.settings.theme_colored_header === '1');
this.darkMode = m.prop(app.data.settings.theme_dark_mode);
this.coloredHeader = m.prop(app.data.settings.theme_colored_header);
}
view() {
@@ -82,7 +82,7 @@ export default class AppearancePage extends Page {
{Button.component({
className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_header_button'),
onclick: () => app.modal.show(new EditCustomHeaderModal()),
onclick: () => app.modal.show(EditCustomHeaderModal),
})}
</fieldset>
@@ -92,7 +92,7 @@ export default class AppearancePage extends Page {
{Button.component({
className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_footer_button'),
onclick: () => app.modal.show(new EditCustomFooterModal()),
onclick: () => app.modal.show(EditCustomFooterModal),
})}
</fieldset>
@@ -102,7 +102,7 @@ export default class AppearancePage extends Page {
{Button.component({
className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_css_button'),
onclick: () => app.modal.show(new EditCustomCssModal()),
onclick: () => app.modal.show(EditCustomCssModal),
})}
</fieldset>
</div>

View File

@@ -2,7 +2,6 @@ import Page from '../../common/components/Page';
import FieldSet from '../../common/components/FieldSet';
import Select from '../../common/components/Select';
import Button from '../../common/components/Button';
import Alert from '../../common/components/Alert';
import saveSettings from '../utils/saveSettings';
import ItemList from '../../common/utils/ItemList';
import Switch from '../../common/components/Switch';
@@ -186,7 +185,10 @@ export default class BasicsPage extends Page {
saveSettings(settings)
.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(() => {})
.then(() => {

View File

@@ -16,7 +16,7 @@ export default class ExtensionsPage extends Page {
children: app.translator.trans('core.admin.extensions.add_button'),
icon: 'fas fa-plus',
className: 'Button Button--primary',
onclick: () => app.modal.show(new AddExtensionModal()),
onclick: () => app.modal.show(AddExtensionModal),
})}
</div>
</div>
@@ -94,7 +94,7 @@ export default class ExtensionsPage extends Page {
})
.then(() => window.location.reload());
app.modal.show(new LoadingModal());
app.modal.show(LoadingModal);
},
})
);
@@ -123,6 +123,6 @@ export default class ExtensionsPage extends Page {
window.location.reload();
});
app.modal.show(new LoadingModal());
app.modal.show(LoadingModal);
}
}

View File

@@ -1,9 +1,10 @@
import Modal from '../../common/components/Modal';
export default class LoadingModal extends Modal {
isDismissible() {
return false;
}
/**
* @inheritdoc
*/
static isDismissible = false;
className() {
return 'LoadingModal Modal--small';

View File

@@ -179,9 +179,10 @@ export default class MailPage extends Page {
})
.then((response) => {
this.sendingTest = false;
app.alerts.show(
(this.testEmailSuccessAlert = new Alert({ type: 'success', children: app.translator.trans('core.admin.email.send_test_mail_success') }))
);
this.testEmailSuccessAlert = app.alerts.show({
type: 'success',
children: app.translator.trans('core.admin.email.send_test_mail_success'),
});
})
.catch((error) => {
this.sendingTest = false;
@@ -204,7 +205,10 @@ export default class MailPage extends Page {
saveSettings(settings)
.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(() => {})
.then(() => {

View File

@@ -15,7 +15,7 @@ export default class PermissionsPage extends Page {
.all('groups')
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
.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({
group,
className: 'Group-icon',
@@ -24,7 +24,7 @@ export default class PermissionsPage extends Page {
<span className="Group-name">{group.namePlural()}</span>
</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' })}
<span className="Group-name">{app.translator.trans('core.admin.permissions.new_group_button')}</span>
</button>

View File

@@ -46,7 +46,7 @@ export default class StatusWidget extends DashboardWidget {
}
handleClearCache(e) {
app.modal.show(new LoadingModal());
app.modal.show(LoadingModal);
app
.request({

View File

@@ -1,5 +1,4 @@
import ItemList from './utils/ItemList';
import Alert from './components/Alert';
import Button from './components/Button';
import ModalManager from './components/ModalManager';
import AlertManager from './components/AlertManager';
@@ -12,6 +11,7 @@ import Drawer from './utils/Drawer';
import mapRoutes from './utils/mapRoutes';
import RequestError from './utils/RequestError';
import ScrollListener from './utils/ScrollListener';
import liveHumanTimes from './utils/liveHumanTimes';
import { extend } from './extend';
import Forum from './models/Forum';
@@ -22,6 +22,8 @@ import Group from './models/Group';
import Notification from './models/Notification';
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
@@ -108,13 +110,13 @@ export default class Application {
booted = false;
/**
* An Alert that was shown as a result of an AJAX request error. If present,
* it will be dismissed on the next successful request.
* The key for an Alert that was shown as a result of an AJAX request error.
* If present, it will be dismissed on the next successful request.
*
* @type {null|Alert}
* @type {int}
* @private
*/
requestError = null;
requestErrorAlert = null;
/**
* The page the app is currently on.
@@ -138,6 +140,20 @@ export default class Application {
*/
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;
title = '';
@@ -173,8 +189,8 @@ export default class Application {
}
mount(basePath = '') {
this.modal = m.mount(document.getElementById('modal'), <ModalManager />);
this.alerts = m.mount(document.getElementById('alerts'), <AlertManager />);
m.mount(document.getElementById('modal'), <ModalManager state={this.modal} />);
m.mount(document.getElementById('alerts'), <AlertManager state={this.alerts} />);
this.drawer = new Drawer();
@@ -192,6 +208,8 @@ export default class Application {
$(() => {
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
});
liveHumanTimes();
}
/**
@@ -212,6 +230,16 @@ export default class Application {
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.
*
@@ -234,7 +262,10 @@ export default class Application {
}
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;
}
/**
@@ -307,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
// returned and show an alert containing its contents.
@@ -316,8 +347,6 @@ export default class Application {
m.request(options).then(
(response) => deferred.resolve(response),
(error) => {
this.requestError = error;
let children;
switch (error.status) {
@@ -351,7 +380,7 @@ export default class Application {
// 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',
children,
controls: isDebug && [
@@ -359,7 +388,7 @@ export default class Application {
Debug
</Button>,
],
});
};
try {
options.errorHandler(error);
@@ -375,7 +404,7 @@ export default class Application {
console.groupEnd();
}
this.alerts.show(error.alert);
this.requestErrorAlert = this.alerts.show(error.alert);
}
deferred.reject(error);
@@ -391,9 +420,9 @@ export default class Application {
* @private
*/
showDebug(error, formattedError) {
this.alerts.dismiss(this.requestError.alert);
this.alerts.dismiss(this.requestErrorAlert);
this.modal.show(new RequestErrorModal({ error, formattedError }));
this.modal.show(RequestErrorModal, { error, formattedError });
}
/**

View File

@@ -7,20 +7,16 @@ import Alert from './Alert';
*/
export default class AlertManager extends Component {
init() {
/**
* An array of Alert components which are currently showing.
*
* @type {Alert[]}
* @protected
*/
this.components = [];
this.state = this.props.state;
}
view() {
return (
<div className="AlertManager">
{this.components.map((component) => (
<div className="AlertManager-alert">{component}</div>
{Object.entries(this.state.getActiveAlerts()).map(([key, alert]) => (
<div className="AlertManager-alert">
{(alert.componentClass || Alert).component({ ...alert.attrs, ondismiss: this.state.dismiss.bind(this.state, key) })}
</div>
))}
</div>
);
@@ -32,46 +28,4 @@ export default class AlertManager extends Component {
// to be retained across route changes.
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();
}
}

View File

@@ -16,6 +16,9 @@ import icon from '../helpers/icon';
*/
export default class Checkbox extends Component {
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 || '');
if (this.props.loading) className += ' loading';
if (this.props.disabled) className += ' disabled';

View 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];
}
}

View File

@@ -9,24 +9,39 @@ import Button from './Button';
* @abstract
*/
export default class Modal extends Component {
/**
* Determine whether or not the modal should be dismissible via an 'x' button.
*/
static isDismissible = true;
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() {
if (this.alert) {
this.alert.props.dismissible = false;
if (this.alertAttrs) {
this.alertAttrs.dismissible = false;
}
return (
<div className={'Modal modal-dialog ' + this.className()}>
<div className="Modal-content">
{this.isDismissible() ? (
{this.constructor.isDismissible ? (
<div className="Modal-close App-backControl">
{Button.component({
icon: 'fas fa-times',
@@ -43,7 +58,7 @@ export default class Modal extends Component {
<h3 className="App-titleControl App-titleControl--text">{this.title()}</h3>
</div>
{alert ? <div className="Modal-alert">{this.alert}</div> : ''}
{this.alertAttrs ? <div className="Modal-alert">{Alert.component(this.alertAttrs)}</div> : ''}
{this.content()}
</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.
*
@@ -105,7 +111,7 @@ export default class Modal extends Component {
* Hide the modal.
*/
hide() {
app.modal.close();
this.props.onhide();
}
/**
@@ -123,7 +129,7 @@ export default class Modal extends Component {
* @param {RequestError} error
*/
onerror(error) {
this.alert = error.alert;
this.alertAttrs = error.alert;
m.redraw();

View File

@@ -1,5 +1,4 @@
import Component from '../Component';
import Modal from './Modal';
/**
* 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 {
init() {
this.showing = false;
this.component = null;
this.state = this.props.state;
}
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) {
@@ -24,29 +28,17 @@ export default class ModalManager extends Component {
// to be retained across route changes.
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));
}
/**
* Show a modal dialog.
*
* @param {Modal} component
* @public
*/
show(component) {
if (!(component instanceof Modal)) {
throw new Error('The ModalManager component can only show Modal components');
}
animateShow(readyCallback) {
const dismissible = !!this.state.modal.componentClass.isDismissible;
clearTimeout(this.hideTimeout);
this.showing = true;
this.component = component;
m.redraw(true);
const dismissible = !!this.component.isDismissible();
this.$()
.one('shown.bs.modal', readyCallback)
.modal({
backdrop: dismissible || 'static',
keyboard: dismissible,
@@ -54,50 +46,7 @@ export default class ModalManager extends Component {
.modal('show');
}
/**
* 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.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.$());
}
animateHide() {
this.$().modal('hide');
}
}

View File

@@ -1,4 +1,4 @@
export default class Routes {
export default class Model {
type;
attributes = [];
hasOnes = [];

View File

@@ -6,10 +6,10 @@
* @return {Object}
*/
export default function fullTime(time) {
const mo = moment(time);
const d = dayjs(time);
const datetime = mo.format();
const full = mo.format('LLLL');
const datetime = d.format();
const full = d.format('LLLL');
return (
<time pubdate datetime={datetime}>

View File

@@ -9,10 +9,10 @@ import humanTimeUtil from '../utils/humanTime';
* @return {Object}
*/
export default function humanTime(time) {
const mo = moment(time);
const d = dayjs(time);
const datetime = mo.format();
const full = mo.format('LLLL');
const datetime = d.format();
const full = d.format('LLLL');
const ago = humanTimeUtil(time);
return (

View File

@@ -1,6 +1,6 @@
import 'expose-loader?$!expose-loader?jQuery!jquery';
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 'bootstrap/js/affix';
import 'bootstrap/js/dropdown';
@@ -9,6 +9,12 @@ import 'bootstrap/js/tooltip';
import 'bootstrap/js/transition';
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';
patchMithril(window);

View File

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

View File

@@ -54,7 +54,7 @@ Object.assign(User.prototype, {
* @public
*/
isOnline() {
return this.lastSeenAt() > moment().subtract(5, 'minutes').toDate();
return dayjs().subtract(5, 'minutes').isBefore(this.lastSeenAt());
},
/**

View 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();
}
}

View 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();
});
}
}

View File

@@ -1,5 +1,9 @@
class Item {
constructor(content, priority) {
content: any;
priority: number;
key?: number;
constructor(content: any, priority?: number) {
this.content = content;
this.priority = priority;
}
@@ -10,23 +14,15 @@ class Item {
* by priority.
*/
export default class ItemList {
constructor() {
/**
* The items in the list.
*
* @type {Object}
* @public
*/
this.items = {};
}
/**
* The items in the list
*/
items: { [key: string]: Item } = {};
/**
* Check whether the list is empty.
*
* @returns {boolean}
* @public
*/
isEmpty() {
isEmpty(): boolean {
for (const i in this.items) {
if (this.items.hasOwnProperty(i)) {
return false;
@@ -38,36 +34,27 @@ export default class ItemList {
/**
* Check whether an item is present in the list.
*
* @param key
* @returns {boolean}
*/
has(key) {
has(key: string): boolean {
return !!this.items[key];
}
/**
* Get the content of an item.
*
* @param {String} key
* @return {*}
* @public
*/
get(key) {
get(key: string): any {
return this.items[key].content;
}
/**
* Add an item to the list.
*
* @param {String} key A unique key for the item.
* @param {*} content The item's content.
* @param {Integer} [priority] The priority of the item. Items with a higher
* @param key A unique key for the item.
* @param content The item's content.
* @param [priority] The priority of the item. Items with a higher
* 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);
return this;
@@ -75,14 +62,8 @@ export default class ItemList {
/**
* 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 (content !== null) {
this.items[key].content = content;
@@ -98,12 +79,8 @@ export default class ItemList {
/**
* Remove an item from the list.
*
* @param {String} key
* @return {ItemList}
* @public
*/
remove(key) {
remove(key: string): this {
delete this.items[key];
return this;
@@ -111,12 +88,8 @@ export default class ItemList {
/**
* 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) {
if (items.items.hasOwnProperty(i) && items.items[i] instanceof Item) {
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
* item's content will be assigned an `itemName` property equal to the item's
* unique key.
*
* @return {Array}
* @public
*/
toArray() {
const items = [];
toArray(): any[] {
const items: Item[] = [];
for (const i in this.items) {
if (this.items.hasOwnProperty(i) && this.items[i] instanceof Item) {

View File

@@ -1,5 +1,14 @@
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.responseText = responseText;
this.options = options;

View 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();
}
}

View File

@@ -4,11 +4,8 @@
* @example
* abbreviateNumber(1234);
* // "1.2K"
*
* @param {Integer} number
* @return {String}
*/
export default function abbreviateNumber(number) {
export default function abbreviateNumber(number: number): string {
// TODO: translation
if (number >= 1000000) {
return Math.floor(number / 1000000) + app.translator.trans('core.lib.number_suffix.mega_text');

View File

@@ -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;
}

View 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;
}

View File

@@ -5,10 +5,7 @@
* @example
* formatNumber(1234);
* // 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, ',');
}

View File

@@ -1,35 +1,32 @@
/**
* The `humanTime` utility converts a date to a localized, human-readable time-
* ago string.
*
* @param {Date} time
* @return {String}
*/
export default function humanTime(time) {
let m = moment(time);
const now = moment();
export default function humanTime(time: Date): string {
let d = dayjs(time);
const now = dayjs();
// 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
// current time. This will result in "just now" being shown instead.
if (m.isAfter(now)) {
m = now;
if (d.isAfter(now)) {
d = now;
}
const day = 864e5;
const diff = m.diff(moment());
let ago = null;
const diff = d.diff(dayjs());
let ago: string;
// 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.
if (diff < -30 * day) {
if (m.year() === moment().year()) {
ago = m.format('D MMM');
if (d.year() === dayjs().year()) {
ago = d.format('D MMM');
} else {
ago = m.format('ll');
ago = d.format('ll');
}
} else {
ago = m.fromNow();
ago = d.fromNow();
}
return ago;

View File

@@ -1,18 +1,18 @@
import humanTimeUtil from './humanTime';
import humanTime from './humanTime';
function updateHumanTimes() {
$('[data-humantime]').each(function () {
const $this = $(this);
const ago = humanTimeUtil($this.attr('datetime'));
const ago = humanTime($this.attr('datetime'));
$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.
*/
export default function humanTime() {
export default function liveHumanTimes() {
setInterval(updateHumanTimes, 10000);
}

View File

@@ -1,12 +1,7 @@
/**
* 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 ? '...' : '');
}
@@ -17,11 +12,8 @@ export function truncate(string, length, start = 0) {
* NOTE: This method does not use the comparably sophisticated transliteration
* mechanism that is employed in the backend. Therefore, it should only be used
* 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
.toLowerCase()
.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
* 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 &nbsp;').replace(/<img\b[^>]*>/gi, ' ');
const dom = $('<div/>').html(html);
@@ -55,10 +44,7 @@ getPlainContent.removeSelectors = ['blockquote', 'script'];
/**
* 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);
}

View File

@@ -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 g;
let b;
@@ -51,11 +53,8 @@ function hsvToRgb(h, s, v) {
/**
* 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;
// Convert the username into a number based on the ASCII value of each

View File

@@ -1,6 +1,5 @@
import History from './utils/History';
import Pane from './utils/Pane';
import ReplyComposer from './components/ReplyComposer';
import DiscussionPage from './components/DiscussionPage';
import SignUpModal from './components/SignUpModal';
import HeaderPrimary from './components/HeaderPrimary';
@@ -15,7 +14,8 @@ import Application from '../common/Application';
import Navigation from '../common/components/Navigation';
import NotificationListState from './states/NotificationListState';
import GlobalSearchState from './states/GlobalSearchState';
import DiscussionListState from './state/DiscussionListState';
import DiscussionListState from './states/DiscussionListState';
import ComposerState from './states/ComposerState';
export default class ForumApplication extends Application {
/**
@@ -73,6 +73,11 @@ export default class ForumApplication extends Application {
*/
search = new GlobalSearchState();
/*
* An object which controls the state of the composer.
*/
composer = new ComposerState();
constructor() {
super();
@@ -84,7 +89,7 @@ export default class ForumApplication extends Application {
*
* @type {DiscussionListState}
*/
this.discussions = new DiscussionListState({ forumApp: this });
this.discussions = new DiscussionListState({}, this);
/**
* @deprecated beta 14, remove in beta 15.
@@ -114,9 +119,9 @@ export default class ForumApplication extends Application {
m.mount(document.getElementById('header-navigation'), Navigation.component());
m.mount(document.getElementById('header-primary'), HeaderPrimary.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.composer = m.mount(document.getElementById('composer'), Composer.component());
m.route.mode = 'pathname';
super.mount(this.forum.attribute('basePath'));
@@ -138,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.
*
@@ -180,8 +170,7 @@ export default class ForumApplication extends Application {
if (payload.loggedIn) {
window.location.reload();
} else {
const modal = new SignUpModal(payload);
this.modal.show(modal);
this.modal.show(SignUpModal, payload);
}
}
}

View File

@@ -9,6 +9,10 @@ import DiscussionControls from './utils/DiscussionControls';
import alertEmailConfirmation from './utils/alertEmailConfirmation';
import UserControls from './utils/UserControls';
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 LogInModal from './components/LogInModal';
import ComposerBody from './components/ComposerBody';
@@ -77,6 +81,10 @@ export default Object.assign(compat, {
'utils/alertEmailConfirmation': alertEmailConfirmation,
'utils/UserControls': UserControls,
'utils/Pane': Pane,
'states/DiscussionListState': DiscussionListState,
'states/GlobalSearchState': GlobalSearchState,
'states/NotificationListState': NotificationListState,
'states/SearchState': SearchState,
'components/DiscussionPage': DiscussionPage,
'components/LogInModal': LogInModal,
'components/ComposerBody': ComposerBody,

View File

@@ -122,7 +122,7 @@ export default class ChangeEmailModal extends Modal {
onerror(error) {
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);

View File

@@ -31,7 +31,18 @@ export default class CommentPost extends Post {
*/
this.revealContent = false;
this.subtree.check(() => this.isEditing());
/**
* Whether or not the user hover card inside of PostUser is visible.
* 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.cardVisible,
() => this.isEditing()
);
}
content() {
@@ -66,7 +77,7 @@ export default class CommentPost extends Post {
}
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() {
@@ -94,7 +105,7 @@ export default class CommentPost extends Post {
// body with a preview.
let preview;
const updatePreview = () => {
const content = app.composer.component.content();
const content = app.composer.fields.content();
if (preview === content) return;
@@ -124,7 +135,22 @@ export default class CommentPost extends Post {
const items = new ItemList();
const post = this.props.post;
items.add('user', PostUser.component({ post }), 100);
items.add(
'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()) {

View File

@@ -3,28 +3,21 @@ import ItemList from '../../common/utils/ItemList';
import ComposerButton from './ComposerButton';
import listItems from '../../common/helpers/listItems';
import classList from '../../common/utils/classList';
import ComposerState from '../states/ComposerState';
/**
* 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
* `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`.
*/
class Composer extends Component {
export default class Composer extends Component {
init() {
/**
* The composer's current position.
* The composer's "state".
*
* @type {Composer.PositionEnum}
* @type {ComposerState}
*/
this.position = Composer.PositionEnum.HIDDEN;
/**
* The composer's intended height, which can be modified by the user
* (by dragging the composer handle).
*
* @type {Integer}
*/
this.height = null;
this.state = this.props.state;
/**
* Whether or not the composer currently has focus.
@@ -32,39 +25,45 @@ class Composer extends Component {
* @type {Boolean}
*/
this.active = false;
// Store the initial position so that we can trigger animations correctly.
this.prevPosition = this.state.position;
}
view() {
const body = this.state.body;
const classes = {
normal: this.position === Composer.PositionEnum.NORMAL,
minimized: this.position === Composer.PositionEnum.MINIMIZED,
fullScreen: this.position === Composer.PositionEnum.FULLSCREEN,
normal: this.state.position === ComposerState.Position.NORMAL,
minimized: this.state.position === ComposerState.Position.MINIMIZED,
fullScreen: this.state.position === ComposerState.Position.FULLSCREEN,
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
// it shouldn't let the user interact with it. Set up a handler so that if
// 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;
// Set up a handler so that clicks on the content will show the composer.
const showIfMinimized = this.state.position === ComposerState.Position.MINIMIZED ? this.state.show.bind(this.state) : undefined;
return (
<div className={'Composer ' + classList(classes)}>
<div className="Composer-handle" config={this.configHandle.bind(this)} />
<ul className="Composer-controls">{listItems(this.controlItems().toArray())}</ul>
<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>
);
}
config(isInitialized, context) {
// 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.
this.updateHeight();
if (this.state.position === this.prevPosition) {
// 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.
this.updateHeight();
} else {
this.animatePositionChange();
this.prevPosition = this.state.position;
}
if (isInitialized) return;
@@ -73,7 +72,7 @@ class Composer extends Component {
context.retain = true;
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
// 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.
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 = {};
$(window)
@@ -166,13 +158,20 @@ class Composer extends Component {
$('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
* setting the height of the composer's root element, and adjusting the height
* of any flexible elements inside the composer's body.
*/
updateHeight() {
const height = this.computedHeight();
const height = this.state.computedHeight();
const $flexible = this.$('.Composer-flexible');
this.$().height(height);
@@ -193,109 +192,59 @@ class Composer extends Component {
*/
updateBodyPadding() {
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 });
}
/**
* 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 the Composer is positioned absolutely as on mobile devices.
*
* @return {Boolean}
* @public
* Trigger the right animation depending on the desired new position.
*/
isFullScreen() {
return this.position === Composer.PositionEnum.FULLSCREEN || this.$().css('position') === 'absolute';
}
animatePositionChange() {
// When exiting full-screen mode: focus content
if (this.prevPosition === ComposerState.Position.FULLSCREEN) {
this.focus();
return;
}
/**
* 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.component) {
const preventExit = this.component.preventExit();
if (preventExit) {
return !confirm(preventExit);
}
switch (this.state.position) {
case ComposerState.Position.HIDDEN:
return this.hide();
case ComposerState.Position.MINIMIZED:
return this.minimize();
case ComposerState.Position.FULLSCREEN:
return this.focus();
case ComposerState.Position.NORMAL:
return this.show();
}
}
/**
* Load a content component into the composer.
*
* @param {Component} component
* @public
* Animate the Composer into the new position by changing the height.
*/
load(component) {
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;
animateHeightChange() {
const $composer = this.$().stop(true);
const oldHeight = $composer.outerHeight();
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();
this.updateHeight();
const newHeight = $composer.outerHeight();
if (oldPosition === Composer.PositionEnum.HIDDEN) {
if (this.prevPosition === ComposerState.Position.HIDDEN) {
$composer.css({ bottom: -newHeight, height: newHeight });
} else {
$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();
$(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() {
if (this.position === Composer.PositionEnum.NORMAL || this.position === Composer.PositionEnum.FULLSCREEN) {
return;
}
this.animateHeightChange().then(() => this.focus());
this.animateToPosition(Composer.PositionEnum.NORMAL);
if (this.isFullScreen()) {
if (app.screen() === 'phone') {
this.$().css('top', $(window).scrollTop());
this.showBackdrop();
this.component.focus();
}
}
/**
* Close the composer.
* Animate closing the composer.
*
* @public
* @private
*/
hide() {
const $composer = this.$();
// Animate the composer sliding down off the bottom edge of the viewport.
// Only when the animation is completed, update the Composer state flag and
// other elements on the page.
// Only when the animation is completed, update other elements on the page.
$composer.stop(true).animate({ bottom: -$composer.height() }, 'fast', () => {
this.position = Composer.PositionEnum.HIDDEN;
this.clear();
m.redraw();
$composer.hide();
this.hideBackdrop();
this.updateBodyPadding();
@@ -354,60 +293,17 @@ class Composer extends Component {
}
/**
* Confirm with the user so they don't lose their content, then close the
* composer.
* Shrink the composer until only its title is visible.
*
* @public
*/
close() {
if (!this.preventExit()) {
this.hide();
}
}
/**
* Minimize the composer. Has no effect if the composer is hidden.
*
* @public
* @private
*/
minimize() {
if (this.position === Composer.PositionEnum.HIDDEN) return;
this.animateToPosition(Composer.PositionEnum.MINIMIZED);
this.animateHeightChange();
this.$().css('top', 'auto');
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.
*
@@ -416,23 +312,23 @@ class Composer extends Component {
controlItems() {
const items = new ItemList();
if (this.position === Composer.PositionEnum.FULLSCREEN) {
if (this.state.position === ComposerState.Position.FULLSCREEN) {
items.add(
'exitFullScreen',
ComposerButton.component({
icon: 'fas fa-compress',
title: app.translator.trans('core.forum.composer.exit_full_screen_tooltip'),
onclick: this.exitFullScreen.bind(this),
onclick: this.state.exitFullScreen.bind(this.state),
})
);
} else {
if (this.position !== Composer.PositionEnum.MINIMIZED) {
if (this.state.position !== ComposerState.Position.MINIMIZED) {
items.add(
'minimize',
ComposerButton.component({
icon: 'fas fa-minus minimize',
title: app.translator.trans('core.forum.composer.minimize_tooltip'),
onclick: this.minimize.bind(this),
onclick: this.state.minimize.bind(this.state),
itemClassName: 'App-backControl',
})
);
@@ -442,7 +338,7 @@ class Composer extends Component {
ComposerButton.component({
icon: 'fas fa-expand',
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({
icon: 'fas fa-times',
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.
*/
initializeHeight() {
this.height = localStorage.getItem('composerHeight');
this.state.height = localStorage.getItem('composerHeight');
if (!this.height) {
this.height = this.defaultHeight();
if (!this.state.height) {
this.state.height = this.defaultHeight();
}
}
@@ -479,60 +375,14 @@ class Composer extends Component {
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.
* @param {Integer} height
*/
changeHeight(height) {
this.height = height;
this.state.height = height;
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;

View File

@@ -1,5 +1,6 @@
import Component from '../../common/Component';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import ConfirmDocumentUnload from '../../common/components/ConfirmDocumentUnload';
import TextEditor from './TextEditor';
import avatar from '../../common/helpers/avatar';
import listItems from '../../common/helpers/listItems';
@@ -12,6 +13,7 @@ import ItemList from '../../common/utils/ItemList';
*
* ### Props
*
* - `composer`
* - `originalContent`
* - `submitLabel`
* - `placeholder`
@@ -23,6 +25,8 @@ import ItemList from '../../common/utils/ItemList';
*/
export default class ComposerBody extends Component {
init() {
this.composer = this.props.composer;
/**
* Whether or not the component is loading.
*
@@ -30,60 +34,57 @@ export default class ComposerBody extends Component {
*/
this.loading = false;
/**
* The content of the text editor.
*
* @type {Function}
*/
this.content = m.prop(this.props.originalContent);
// Let the composer state know to ask for confirmation under certain
// circumstances, if the body supports / requires it and has a corresponding
// confirmation question to ask.
if (this.props.confirmExit) {
this.composer.preventClosingWhen(() => this.hasChanges(), this.props.confirmExit);
}
this.composer.fields.content(this.props.originalContent || '');
/**
* The text editor component instance.
*
* @type {TextEditor}
* @deprecated BC layer, remove in Beta 15.
*/
this.editor = new TextEditor({
submitLabel: this.props.submitLabel,
placeholder: this.props.placeholder,
onchange: this.content,
onsubmit: this.onsubmit.bind(this),
value: this.content(),
});
this.content = this.composer.fields.content;
this.editor = this.composer;
}
view() {
// If the component is loading, we should disable the text editor.
this.editor.props.disabled = this.loading;
return (
<div className={'ComposerBody ' + (this.props.className || '')}>
{avatar(this.props.user, { className: 'ComposerBody-avatar' })}
<div className="ComposerBody-content">
<ul className="ComposerBody-header">{listItems(this.headerItems().toArray())}</ul>
<div className="ComposerBody-editor">{this.editor.render()}</div>
<ConfirmDocumentUnload when={this.hasChanges.bind(this)}>
<div className={'ComposerBody ' + (this.props.className || '')}>
{avatar(this.props.user, { className: 'ComposerBody-avatar' })}
<div className="ComposerBody-content">
<ul className="ComposerBody-header">{listItems(this.headerItems().toArray())}</ul>
<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>
{LoadingIndicator.component({ className: 'ComposerBody-loading' + (this.loading ? ' active' : '') })}
</div>
{LoadingIndicator.component({ className: 'ComposerBody-loading' + (this.loading ? ' active' : '') })}
</div>
</ConfirmDocumentUnload>
);
}
/**
* Draw focus to the text editor.
*/
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.
* Check if there is any unsaved data.
*
* @return {String}
*/
preventExit() {
const content = this.content();
hasChanges() {
const content = this.composer.fields.content();
return content && content !== this.props.originalContent && this.props.confirmExit;
return content && content !== this.props.originalContent;
}
/**

View File

@@ -16,12 +16,14 @@ export default class DiscussionComposer extends ComposerBody {
init() {
super.init();
this.composer.fields.title = this.composer.fields.title || m.prop('');
/**
* The value of the title input.
*
* @type {Function}
*/
this.title = m.prop('');
this.title = this.composer.fields.title;
}
static initProps(props) {
@@ -66,14 +68,14 @@ export default class DiscussionComposer extends ComposerBody {
if (e.which === 13) {
// Return
e.preventDefault();
this.editor.setSelectionRange(0, 0);
this.composer.editor.moveCursorTo(0);
}
m.redraw.strategy('none');
}
preventExit() {
return (this.title() || this.content()) && this.props.confirmExit;
hasChanges() {
return this.title() || this.composer.fields.content();
}
/**
@@ -84,7 +86,7 @@ export default class DiscussionComposer extends ComposerBody {
data() {
return {
title: this.title(),
content: this.content(),
content: this.composer.fields.content(),
};
}
@@ -97,7 +99,7 @@ export default class DiscussionComposer extends ComposerBody {
.createRecord('discussions')
.save(data)
.then((discussion) => {
app.composer.hide();
this.composer.hide();
app.discussions.refresh();
m.route(app.route.discussion(discussion));
}, this.loaded.bind(this));

View File

@@ -9,8 +9,6 @@ import Placeholder from '../../common/components/Placeholder';
*
* ### Props
*
* - `params` A map of parameters used to construct a refined parameter object
* to send along in the API request to get discussion results.
* - `state` A DiscussionListState object that represents the discussion lists's state.
*/
export default class DiscussionList extends Component {

View File

@@ -8,6 +8,8 @@ import SplitDropdown from '../../common/components/SplitDropdown';
import listItems from '../../common/helpers/listItems';
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
@@ -27,11 +29,13 @@ export default class DiscussionPage extends Page {
/**
* 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
// hide it by default). Also, if we've just come from another discussion
@@ -79,7 +83,7 @@ export default class DiscussionPage extends Page {
// we'll just close it.
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();
} else {
app.composer.minimize();
@@ -107,7 +111,13 @@ export default class DiscussionPage extends Page {
<nav className="DiscussionPage-nav">
<ul>{listItems(this.sidebarItems().toArray())}</ul>
</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>,
]
: LoadingIndicator.component({ className: 'LoadingIndicator--block' })}
@@ -116,21 +126,24 @@ export default class DiscussionPage extends Page {
);
}
config(...args) {
super.config(...args);
config(isInitialized, context) {
super.config(isInitialized, context);
if (this.discussion) {
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() {
this.near = m.route.param('near') || 0;
this.discussion = null;
load() {
const preloadedDiscussion = app.preloadedApiDocument();
if (preloadedDiscussion) {
// We must wrap this in a setTimeout because if we are mounting this
@@ -197,12 +210,13 @@ export default class DiscussionPage extends Page {
// 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
// the specific post that was routed to.
this.stream = new PostStream({ discussion, includedPosts });
this.stream.on('positionChanged', this.positionChanged.bind(this));
this.stream = new PostStreamState(discussion, includedPosts);
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();
}
/**
@@ -268,8 +282,12 @@ export default class DiscussionPage extends Page {
items.add(
'scrubber',
PostStreamScrubber.component({
stream: this.stream,
discussion: this.discussion,
className: 'App-titleControl',
onNavigate: this.stream.goToIndex.bind(this.stream),
count: this.stream.count(),
paused: this.stream.paused,
...this.scrubberProps(),
}),
-100
);
@@ -277,6 +295,84 @@ export default class DiscussionPage extends Page {
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
* scrolls up or down), then we update the URL and mark the posts as read.
@@ -303,4 +399,73 @@ export default class DiscussionPage extends Page {
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);
}
}

View File

@@ -1,5 +1,6 @@
import UserPage from './UserPage';
import DiscussionList from './DiscussionList';
import DiscussionListState from '../states/DiscussionListState';
/**
* 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'));
}
show(user) {
super.show(user);
this.state = new DiscussionListState({
q: 'author:' + user.username(),
sort: 'newest',
});
this.state.refresh();
}
content() {
return (
<div className="DiscussionsUserPage">
{DiscussionList.component({
params: {
q: 'author:' + this.user.username(),
sort: 'newest',
},
})}
</div>
);
return <div className="DiscussionsUserPage">{DiscussionList.component({ state: this.state })}</div>;
}
}

View File

@@ -1,5 +1,4 @@
import ComposerBody from './ComposerBody';
import Alert from '../../common/components/Alert';
import Button from '../../common/components/Button';
import icon from '../../common/helpers/icon';
@@ -21,16 +20,6 @@ function minimizeComposerIfFullScreen(e) {
* - `post`
*/
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) {
super.initProps(props);
@@ -65,6 +54,15 @@ export default class EditPostComposer extends ComposerBody {
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.
*
@@ -72,7 +70,7 @@ export default class EditPostComposer extends ComposerBody {
*/
data() {
return {
content: this.content(),
content: this.composer.fields.content(),
};
}
@@ -87,7 +85,7 @@ export default class EditPostComposer extends ComposerBody {
// 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.stream.goToNumber(post.number());
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
@@ -101,16 +99,14 @@ export default class EditPostComposer extends ComposerBody {
app.alerts.dismiss(alert);
},
});
app.alerts.show(
(alert = new Alert({
type: 'success',
children: app.translator.trans('core.forum.composer_edit.edited_message'),
controls: [viewButton],
}))
);
alert = app.alerts.show({
type: 'success',
children: app.translator.trans('core.forum.composer_edit.edited_message'),
controls: [viewButton],
});
}
app.composer.hide();
this.composer.hide();
}, this.loaded.bind(this));
}
}

View File

@@ -104,7 +104,7 @@ export default class ForgotPasswordModal extends Modal {
onerror(error) {
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);

View File

@@ -77,7 +77,7 @@ export default class HeaderSecondary extends Component {
Button.component({
children: app.translator.trans('core.forum.header.sign_up_link'),
className: 'Button Button--link',
onclick: () => app.modal.show(new SignUpModal()),
onclick: () => app.modal.show(SignUpModal),
}),
10
);
@@ -88,7 +88,7 @@ export default class HeaderSecondary extends Component {
Button.component({
children: app.translator.trans('core.forum.header.log_in_link'),
className: 'Button Button--link',
onclick: () => app.modal.show(new LogInModal()),
onclick: () => app.modal.show(LogInModal),
}),
0
);

View File

@@ -79,7 +79,7 @@ export default class IndexPage extends Page {
extend(context, 'onunload', () => $('#app').css('min-height', ''));
app.setTitle('');
app.setTitle(app.translator.trans('core.forum.index.meta_title_text'));
app.setTitleCount(0);
// Work out the difference between the height of this hero and that of the
@@ -273,16 +273,14 @@ export default class IndexPage extends Page {
const deferred = m.deferred();
if (app.session.user) {
const component = new DiscussionComposer({ user: app.session.user });
app.composer.load(component);
app.composer.load(DiscussionComposer, { user: app.session.user });
app.composer.show();
deferred.resolve(component);
deferred.resolve(app.composer);
} else {
deferred.reject();
app.modal.show(new LogInModal());
app.modal.show(LogInModal);
}
return deferred.promise;

View File

@@ -142,7 +142,7 @@ export default class LogInModal extends Modal {
const email = this.identification();
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();
props[identification.indexOf('@') !== -1 ? 'email' : 'username'] = identification;
app.modal.show(new SignUpModal(props));
app.modal.show(SignUpModal, props);
}
onready() {
@@ -179,7 +179,7 @@ export default class LogInModal extends Modal {
onerror(error) {
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);

View File

@@ -13,10 +13,6 @@ export default class NotificationsDropdown extends Dropdown {
super.initProps(props);
}
init() {
super.init();
}
getButton() {
const newNotifications = this.getNewCount();
const vdom = super.getButton();

View File

@@ -1,8 +1,5 @@
import Component from '../../common/Component';
import ScrollListener from '../../common/utils/ScrollListener';
import PostLoading from './LoadingPost';
import anchorScroll from '../../common/utils/anchorScroll';
import evented from '../../common/utils/evented';
import ReplyPlaceholder from './ReplyPlaceholder';
import Button from '../../common/components/Button';
@@ -13,9 +10,10 @@ import Button from '../../common/components/Button';
* ### Props
*
* - `discussion`
* - `includedPosts`
* - `stream`
* - `targetPost`
*/
class PostStream extends Component {
export default class PostStream extends Component {
init() {
/**
* The discussion to display the post stream for.
@@ -25,171 +23,11 @@ class PostStream extends Component {
this.discussion = this.props.discussion;
/**
* Whether or not the infinite-scrolling auto-load functionality is
* disabled.
* The shared state of the post stream.
*
* @type {Boolean}
* @type {PostStreamState}
*/
this.paused = false;
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;
});
this.stream = this.props.stream;
}
view() {
@@ -200,15 +38,13 @@ class PostStream extends Component {
let lastTime;
this.visibleEnd = this.sanitizeIndex(this.visibleEnd);
this.viewingEnd = this.visibleEnd === this.count();
const posts = this.posts();
const viewingEnd = this.stream.viewingEnd();
const posts = this.stream.posts();
const postIds = this.discussion.postIds();
const items = posts.map((post, i) => {
let content;
const attrs = { 'data-index': this.visibleStart + i };
const attrs = { 'data-index': this.stream.visibleStart + i };
if (post) {
const time = post.createdAt();
@@ -230,7 +66,7 @@ class PostStream extends Component {
if (dt > 1000 * 60 * 60 * 24 * 4) {
content = [
<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>,
content,
];
@@ -238,7 +74,7 @@ class PostStream extends Component {
lastTime = time;
} else {
attrs.key = 'post' + postIds[this.visibleStart + i];
attrs.key = 'post' + postIds[this.stream.visibleStart + i];
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(
<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')}
</Button>
</div>
@@ -262,7 +98,7 @@ class PostStream extends Component {
// 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.
if (this.viewingEnd && (!app.session.user || this.discussion.canReply())) {
if (viewingEnd && (!app.session.user || this.discussion.canReply())) {
items.push(
<div className="PostStream-item" key="reply">
{ReplyPlaceholder.component({ discussion: this.discussion })}
@@ -274,237 +110,25 @@ class PostStream extends Component {
}
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:
// https://github.com/lhorie/mithril.js/issues/637
setTimeout(() => this.scrollListener.start());
const oldTarget = this.prevTarget;
const newTarget = this.props.targetPost;
context.onunload = () => {
this.scrollListener.stop();
clearTimeout(this.calculatePositionTimeout);
};
}
/**
* 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 {Integer} top
*/
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 (oldTarget) {
if ('number' in oldTarget && oldTarget.number === newTarget.number) return;
if ('index' in oldTarget && oldTarget.index === newTarget.index) return;
}
if (this.visibleEnd < this.count()) {
const $item = this.$('.PostStream-item[data-index=' + (this.visibleEnd - 1) + ']');
if ($item.length && $item.offset().top + $item.outerHeight(true) < viewportTop + viewportHeight + loadAheadDistance) {
this.loadNext();
}
if ('number' in newTarget) {
this.scrollToNumber(newTarget.number, this.stream.noAnimationScroll);
} else if ('index' in newTarget) {
const backwards = newTarget.index === this.stream.count() - 1;
this.scrollToIndex(newTarget.index, this.stream.noAnimationScroll, backwards);
}
// 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);
}
this.prevTarget = newTarget;
}
/**
@@ -521,42 +145,46 @@ class PostStream extends Component {
* Scroll down to a certain post by number and 'flash' it.
*
* @param {Integer} number
* @param {Boolean} noAnimation
* @param {Boolean} animate
* @return {jQuery.Deferred}
*/
scrollToNumber(number, noAnimation) {
scrollToNumber(number, animate) {
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.
*
* @param {Integer} index
* @param {Boolean} noAnimation
* @param {Boolean} animate
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
* at the given index, instead of the top of it.
* @return {jQuery.Deferred}
*/
scrollToIndex(index, noAnimation, bottom) {
scrollToIndex(index, animate, bottom) {
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.
*
* @param {jQuery} $item
* @param {Boolean} noAnimation
* @param {Boolean} animate
* @param {Boolean} force Whether or not to force scrolling to the item, even
* if it is already in the viewport.
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
* at the given index, instead of the top of it.
* @return {jQuery.Deferred}
*/
scrollToItem($item, noAnimation, force, bottom) {
scrollToItem($item, animate, force, bottom) {
const $container = $('html, body').stop(true);
if ($item.length) {
@@ -571,7 +199,7 @@ class PostStream extends Component {
if (force || itemTop < scrollTop || itemBottom > scrollBottom) {
const top = bottom ? itemBottom - $(window).height() + app.composer.computedHeight() : $item.is(':first-child') ? 0 : itemTop;
if (noAnimation) {
if (!animate) {
$container.scrollTop(top);
} else if (top !== scrollTop) {
$container.animate({ scrollTop: top }, 'fast');
@@ -590,24 +218,4 @@ class PostStream extends Component {
flashItem($item) {
$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;

View File

@@ -10,41 +10,22 @@ import formatNumber from '../../common/utils/formatNumber';
*
* ### Props
*
* - `stream`
* - `discussion`
* - `className`
* - `onNavigate`
* - `count`
* - `paused`
* - `index`
* - `visible`
* - `description`
*/
export default class PostStreamScrubber extends Component {
init() {
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
// 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
// initial draw. We render parts of the scrubber using this because we
@@ -55,12 +36,12 @@ export default class PostStreamScrubber extends Component {
view() {
const retain = this.subtree.retain();
const count = this.count();
const unreadCount = this.props.stream.discussion.unreadCount();
const unreadPercent = count ? Math.min(count - this.index, unreadCount) / count : 0;
const { count, index, visible } = this.props;
const unreadCount = this.props.discussion.unreadCount();
const unreadPercent = count ? Math.min(count - this.props.index, unreadCount) / count : 0;
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>,
});
@@ -98,7 +79,7 @@ export default class PostStreamScrubber extends Component {
<div className="Scrubber-bar" />
<div className="Scrubber-info">
<strong>{viewing}</strong>
<span className="Scrubber-description">{retain || this.description}</span>
<span className="Scrubber-description">{retain || this.props.description}</span>
</div>
</div>
<div className="Scrubber-after" />
@@ -121,35 +102,14 @@ export default class PostStreamScrubber extends Component {
* Go to the first post in the discussion.
*/
goToFirst() {
this.props.stream.goToFirst();
this.index = 0;
this.renderScrollbar(true);
this.navigateTo(0);
}
/**
* Go to the last post in the discussion.
*/
goToLast() {
this.props.stream.goToLast();
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);
this.navigateTo(this.props.count - 1);
}
/**
@@ -159,87 +119,7 @@ export default class PostStreamScrubber extends Component {
* @return {Boolean}
*/
disabled() {
return this.visible >= this.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') : '';
return this.props.visible >= this.props.count;
}
config(isInitialized, context) {
@@ -272,6 +152,7 @@ export default class PostStreamScrubber extends Component {
this.dragging = false;
this.mouseStart = 0;
this.indexStart = 0;
this.dragIndex = null;
this.$('.Scrubber-handle')
.css('cursor', 'move')
@@ -292,8 +173,6 @@ export default class PostStreamScrubber extends Component {
ondestroy() {
this.scrollListener.stop();
this.props.stream.off('unpaused', this.handlers.streamWasUnpaused);
$(window).off('resize', this.handlers.onresize);
$(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
*/
renderScrollbar(animate) {
renderScrollbar(options = {}) {
const { count, visible, description, paused } = this.props;
const percentPerPost = this.percentPerPost();
const index = this.index;
const count = this.count();
const visible = this.visible || 1;
const index = this.dragIndex || this.props.index;
const $scrubber = this.$();
$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());
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.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) {
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
// has a negative margin-left, we need to override.
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.
*/
percentPerPost() {
const count = this.count() || 1;
const visible = this.visible || 1;
const count = this.props.count || 1;
const visible = this.props.visible || 1;
// 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
@@ -381,11 +272,13 @@ export default class PostStreamScrubber extends Component {
}
onmousedown(e) {
e.redraw = false;
this.mouseStart = e.clientY || e.originalEvent.touches[0].clientY;
this.indexStart = this.index;
this.indexStart = this.props.index;
this.dragging = true;
this.props.stream.paused = true;
this.dragIndex = null;
$('body').css('cursor', 'move');
this.$().toggleClass('dragging', this.dragging);
}
onmousemove(e) {
@@ -398,13 +291,14 @@ export default class PostStreamScrubber extends Component {
const deltaPixels = (e.clientY || e.originalEvent.touches[0].clientY) - this.mouseStart;
const deltaPercent = (deltaPixels / this.$('.Scrubber-scrollbar').outerHeight()) * 100;
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();
}
onmouseup() {
this.$().toggleClass('dragging', this.dragging);
if (!this.dragging) return;
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-
// content that we want to load those posts.
const intIndex = Math.floor(this.index);
this.props.stream.goToIndex(intIndex);
this.renderScrollbar(true);
this.navigateTo(this.dragIndex);
this.dragIndex = null;
}
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-
// content component to jump to that index.
let offsetIndex = offsetPercent / this.percentPerPost().index;
offsetIndex = Math.max(0, Math.min(this.count() - 1, offsetIndex));
this.props.stream.goToIndex(Math.floor(offsetIndex));
this.index = offsetIndex;
this.renderScrollbar(true);
offsetIndex = Math.max(0, Math.min(this.props.count - 1, offsetIndex));
this.navigateTo(offsetIndex);
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 });
}
}

View File

@@ -29,7 +29,7 @@ export default class PostUser extends Component {
let card = '';
if (!post.isHidden()) {
if (!post.isHidden() && this.props.cardVisible) {
card = UserCard.component({
user,
className: 'UserCard--popover',
@@ -72,6 +72,8 @@ export default class PostUser extends Component {
* Show the user card.
*/
showCard() {
this.props.oncardshow();
setTimeout(() => this.$('.UserCard').addClass('in'));
}
@@ -79,6 +81,10 @@ export default class PostUser extends Component {
* Hide the user card.
*/
hideCard() {
this.$('.UserCard').removeClass('in');
this.$('.UserCard')
.removeClass('in')
.one('transitionend webkitTransitionEnd oTransitionEnd', () => {
this.props.oncardhide();
});
}
}

View File

@@ -1,5 +1,4 @@
import ComposerBody from './ComposerBody';
import Alert from '../../common/components/Alert';
import Button from '../../common/components/Button';
import icon from '../../common/helpers/icon';
import extractText from '../../common/utils/extractText';
@@ -21,16 +20,6 @@ function minimizeComposerIfFullScreen(e) {
* - `discussion`
*/
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) {
super.initProps(props);
@@ -62,6 +51,15 @@ export default class ReplyComposer extends ComposerBody {
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.
*
@@ -69,7 +67,7 @@ export default class ReplyComposer extends ComposerBody {
*/
data() {
return {
content: this.content(),
content: this.composer.fields.content(),
relationships: { discussion: this.props.discussion },
};
}
@@ -104,16 +102,14 @@ export default class ReplyComposer extends ComposerBody {
app.alerts.dismiss(alert);
},
});
app.alerts.show(
(alert = new Alert({
type: 'success',
children: app.translator.trans('core.forum.composer_reply.posted_message'),
controls: [viewButton],
}))
);
alert = app.alerts.show({
type: 'success',
children: app.translator.trans('core.forum.composer_reply.posted_message'),
controls: [viewButton],
});
}
app.composer.hide();
this.composer.hide();
}, this.loaded.bind(this));
}
}

View File

@@ -15,7 +15,7 @@ import DiscussionControls from '../utils/DiscussionControls';
*/
export default class ReplyPlaceholder extends Component {
view() {
if (app.composingReplyTo(this.props.discussion)) {
if (app.composer.composingReplyTo(this.props.discussion)) {
return (
<article className="Post CommentPost editing">
<header className="Post-header">
@@ -53,9 +53,9 @@ export default class ReplyPlaceholder extends Component {
const updateInterval = setInterval(() => {
// Since we're polling, the composer may have been closed in the meantime,
// 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;

View File

@@ -12,13 +12,13 @@ import UsersSearchSource from './UsersSearchSource';
* The `Search` component displays a menu of as-you-type results from a variety
* of sources.
*
* The search box will be 'activated' if the app's seach state's
* The search box will be 'activated' if the app's search state's
* getInitialSearch() value is a truthy value. If this is the case, an 'x'
* button will be shown next to the search field, and clicking it will clear the search.
*
* PROPS:
*
* - state: AlertState instance.
* - state: SearchState instance.
*/
export default class Search extends Component {
init() {

View File

@@ -79,7 +79,7 @@ export default class SettingsPage extends UserPage {
Button.component({
children: app.translator.trans('core.forum.settings.change_password_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({
children: app.translator.trans('core.forum.settings.change_email_button'),
className: 'Button',
onclick: () => app.modal.show(new ChangeEmailModal()),
onclick: () => app.modal.show(ChangeEmailModal),
})
);

View File

@@ -145,7 +145,7 @@ export default class SignUpModal extends Modal {
password: this.password(),
};
app.modal.show(new LogInModal(props));
app.modal.show(LogInModal, props);
}
onready() {

View File

@@ -1,5 +1,6 @@
import Component from '../../common/Component';
import ItemList from '../../common/utils/ItemList';
import SuperTextarea from '../../common/utils/SuperTextarea';
import listItems from '../../common/helpers/listItems';
import Button from '../../common/components/Button';
@@ -9,10 +10,12 @@ import Button from '../../common/components/Button';
*
* ### Props
*
* - `composer`
* - `submitLabel`
* - `value`
* - `placeholder`
* - `disabled`
* - `preview`
*/
export default class TextEditor extends Component {
init() {
@@ -21,7 +24,7 @@ export default class TextEditor extends Component {
*
* @type {String}
*/
this.value = m.prop(this.props.value || '');
this.value = this.props.value || '';
}
view() {
@@ -33,7 +36,7 @@ export default class TextEditor extends Component {
oninput={m.withAttr('value', this.oninput.bind(this))}
placeholder={this.props.placeholder || ''}
disabled={!!this.props.disabled}
value={this.value()}
value={this.value}
/>
<ul className="TextEditor-controls Composer-footer">
@@ -47,7 +50,7 @@ export default class TextEditor extends Component {
/**
* Configure the textarea element.
*
* @param {DOMElement} element
* @param {HTMLTextAreaElement} element
* @param {Boolean} isInitialized
*/
configTextarea(element, isInitialized) {
@@ -60,6 +63,8 @@ export default class TextEditor extends Component {
$(element).bind('keydown', 'meta+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();
}
/**
* 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.
*
* @param {String} value
*/
oninput(value) {
this.value(value);
this.value = value;
this.props.onchange(this.value());
this.props.onchange(this.value);
m.redraw.strategy('none');
}
@@ -181,6 +128,6 @@ export default class TextEditor extends Component {
* Handle the submit button being clicked.
*/
onsubmit() {
this.props.onsubmit(this.value());
this.props.onsubmit(this.value);
}
}

View 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;

View File

@@ -1,8 +1,8 @@
export default class DiscussionListState {
constructor({ params = {}, forumApp = app } = {}) {
constructor(params = {}, app = window.app) {
this.params = params;
this.app = forumApp;
this.app = app;
this.discussions = [];

View File

@@ -9,6 +9,10 @@ export default class NotificationListState {
this.moreResults = false;
}
clear() {
this.notificationPages = [];
}
getNotificationPages() {
return this.notificationPages;
}

View 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;

View File

@@ -167,13 +167,11 @@ export default {
if (app.session.user) {
if (this.canReply()) {
let component = app.composer.component;
if (!app.composingReplyTo(this) || forceRefresh) {
component = new ReplyComposer({
if (!app.composer.composingReplyTo(this) || forceRefresh) {
app.composer.load(ReplyComposer, {
user: app.session.user,
discussion: this,
});
app.composer.load(component);
}
app.composer.show();
@@ -181,14 +179,14 @@ export default {
app.current.get('stream').goToNumber('reply');
}
deferred.resolve(component);
deferred.resolve(app.composer);
} else {
deferred.reject();
}
} else {
deferred.reject();
app.modal.show(new LogInModal());
app.modal.show(LogInModal);
}
return deferred.promise;
@@ -239,11 +237,9 @@ export default {
* @return {Promise}
*/
renameAction() {
return app.modal.show(
new RenameDiscussionModal({
currentTitle: this.title(),
discussion: this,
})
);
return app.modal.show(RenameDiscussionModal, {
currentTitle: this.title(),
discussion: this,
});
},
};

View File

@@ -130,12 +130,10 @@ export default {
editAction() {
const deferred = m.deferred();
const component = new EditPostComposer({ post: this });
app.composer.load(component);
app.composer.load(EditPostComposer, { post: this });
app.composer.show();
deferred.resolve(component);
deferred.resolve(app.composer);
return deferred.promise;
},

View File

@@ -1,4 +1,3 @@
import Alert from '../../common/components/Alert';
import Button from '../../common/components/Button';
import Separator from '../../common/components/Separator';
import EditUserModal from '../components/EditUserModal';
@@ -134,12 +133,10 @@ export default {
error: 'core.forum.user_controls.delete_error_message',
}[type];
app.alerts.show(
new Alert({
type,
children: app.translator.trans(message, { username, email }),
})
);
app.alerts.show({
type,
children: app.translator.trans(message, { username, email }),
});
},
/**
@@ -148,6 +145,6 @@ export default {
* @param {User} user
*/
editAction(user) {
app.modal.show(new EditUserModal({ user }));
app.modal.show(EditUserModal, { user });
},
};

View File

@@ -1,12 +1,16 @@
const config = require('flarum-webpack-config');
const webpack = require('webpack');
const merge = require('webpack-merge');
module.exports = merge(config(), {
output: {
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');

View File

@@ -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,

View File

@@ -2,7 +2,7 @@
& a {
margin-left: -1px;
color: @muted-color;
& .fa {
font-size: 14px;
margin-right: 2px;
@@ -39,7 +39,7 @@
.Scrubber-handle {
position: relative;
z-index: 1;
background: @body-bg;
background: transparent;
width: 100%;
padding: 5px 0;
}

View File

@@ -18,7 +18,6 @@ return Migration::createTable(
$table->string('email', 150)->unique();
$table->boolean('is_activated')->default(0);
$table->string('password', 100);
$table->text('bio')->nullable();
$table->string('avatar_path', 100)->nullable();
$table->binary('preferences')->nullable();
$table->dateTime('join_time')->nullable();

View File

@@ -9,8 +9,6 @@
namespace Flarum\Admin;
use Flarum\Extension\Event\Disabled;
use Flarum\Extension\Event\Enabled;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\ErrorHandling\Registry;
use Flarum\Foundation\ErrorHandling\Reporter;
@@ -118,7 +116,7 @@ class AdminServiceProvider extends AbstractServiceProvider
$events = $this->app->make('events');
$events->listen(
[Enabled::class, Disabled::class, ClearingCache::class],
ClearingCache::class,
function () {
$recompile = new RecompileFrontendAssets(
$this->app->make('flarum.assets.admin'),

View File

@@ -9,7 +9,6 @@
namespace Flarum\Admin\Middleware;
use Flarum\User\AssertPermissionTrait;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface as Middleware;
@@ -17,11 +16,9 @@ use Psr\Http\Server\RequestHandlerInterface as Handler;
class RequireAdministrateAbility implements Middleware
{
use AssertPermissionTrait;
public function process(Request $request, Handler $handler): Response
{
$this->assertAdmin($request->getAttribute('actor'));
$request->getAttribute('actor')->assertAdmin();
return $handler->handle($request);
}

View File

@@ -10,7 +10,6 @@
namespace Flarum\Api\Controller;
use Flarum\Foundation\Console\CacheClearCommand;
use Flarum\User\AssertPermissionTrait;
use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\Console\Input\ArrayInput;
@@ -18,8 +17,6 @@ use Symfony\Component\Console\Output\NullOutput;
class ClearCacheController extends AbstractDeleteController
{
use AssertPermissionTrait;
/**
* @var CacheClearCommand
*/
@@ -38,7 +35,7 @@ class ClearCacheController extends AbstractDeleteController
*/
protected function delete(ServerRequestInterface $request)
{
$this->assertAdmin($request->getAttribute('actor'));
$request->getAttribute('actor')->assertAdmin();
$this->command->run(
new ArrayInput([]),

View File

@@ -10,15 +10,12 @@
namespace Flarum\Api\Controller;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\AssertPermissionTrait;
use Laminas\Diactoros\Response\EmptyResponse;
use League\Flysystem\FilesystemInterface;
use Psr\Http\Message\ServerRequestInterface;
class DeleteFaviconController extends AbstractDeleteController
{
use AssertPermissionTrait;
/**
* @var SettingsRepositoryInterface
*/
@@ -44,7 +41,7 @@ class DeleteFaviconController extends AbstractDeleteController
*/
protected function delete(ServerRequestInterface $request)
{
$this->assertAdmin($request->getAttribute('actor'));
$request->getAttribute('actor')->assertAdmin();
$path = $this->settings->get('favicon_path');

View File

@@ -10,15 +10,12 @@
namespace Flarum\Api\Controller;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\AssertPermissionTrait;
use Laminas\Diactoros\Response\EmptyResponse;
use League\Flysystem\FilesystemInterface;
use Psr\Http\Message\ServerRequestInterface;
class DeleteLogoController extends AbstractDeleteController
{
use AssertPermissionTrait;
/**
* @var SettingsRepositoryInterface
*/
@@ -44,7 +41,7 @@ class DeleteLogoController extends AbstractDeleteController
*/
protected function delete(ServerRequestInterface $request)
{
$this->assertAdmin($request->getAttribute('actor'));
$request->getAttribute('actor')->assertAdmin();
$path = $this->settings->get('logo_path');

View File

@@ -13,14 +13,11 @@ use Flarum\Api\Serializer\NotificationSerializer;
use Flarum\Discussion\Discussion;
use Flarum\Http\UrlGenerator;
use Flarum\Notification\NotificationRepository;
use Flarum\User\AssertPermissionTrait;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class ListNotificationsController extends AbstractListController
{
use AssertPermissionTrait;
/**
* {@inheritdoc}
*/
@@ -67,7 +64,7 @@ class ListNotificationsController extends AbstractListController
{
$actor = $request->getAttribute('actor');
$this->assertRegistered($actor);
$actor->assertRegistered();
$actor->markNotificationsAsRead()->save();

View File

@@ -12,7 +12,6 @@ namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\UserSerializer;
use Flarum\Http\UrlGenerator;
use Flarum\Search\SearchCriteria;
use Flarum\User\AssertPermissionTrait;
use Flarum\User\Search\UserSearcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
@@ -20,8 +19,6 @@ use Tobscure\JsonApi\Document;
class ListUsersController extends AbstractListController
{
use AssertPermissionTrait;
/**
* {@inheritdoc}
*/
@@ -70,7 +67,7 @@ class ListUsersController extends AbstractListController
{
$actor = $request->getAttribute('actor');
$this->assertCan($actor, 'viewUserList');
$actor->assertCan('viewUserList');
$query = Arr::get($this->extractFilter($request), 'q');
$sort = $this->extractSort($request);

View File

@@ -12,7 +12,6 @@ namespace Flarum\Api\Controller;
use Flarum\Http\UrlGenerator;
use Flarum\Mail\Job\SendRawEmailJob;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\AssertPermissionTrait;
use Flarum\User\EmailToken;
use Flarum\User\Exception\PermissionDeniedException;
use Illuminate\Contracts\Queue\Queue;
@@ -25,8 +24,6 @@ use Symfony\Component\Translation\TranslatorInterface;
class SendConfirmationEmailController implements RequestHandlerInterface
{
use AssertPermissionTrait;
/**
* @var SettingsRepositoryInterface
*/
@@ -69,7 +66,7 @@ class SendConfirmationEmailController implements RequestHandlerInterface
$id = Arr::get($request->getQueryParams(), 'id');
$actor = $request->getAttribute('actor');
$this->assertRegistered($actor);
$actor->assertRegistered();
if ($actor->id != $id || $actor->is_email_confirmed) {
throw new PermissionDeniedException;

View File

@@ -9,7 +9,6 @@
namespace Flarum\Api\Controller;
use Flarum\User\AssertPermissionTrait;
use Illuminate\Container\Container;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Mail\Message;
@@ -21,8 +20,6 @@ use Symfony\Component\Translation\TranslatorInterface;
class SendTestMailController implements RequestHandlerInterface
{
use AssertPermissionTrait;
protected $container;
protected $mailer;
@@ -39,7 +36,7 @@ class SendTestMailController implements RequestHandlerInterface
public function handle(ServerRequestInterface $request): ResponseInterface
{
$actor = $request->getAttribute('actor');
$this->assertAdmin($actor);
$actor->assertAdmin();
$body = $this->translator->trans('core.email.send_test.body', ['{username}' => $actor->username]);

View File

@@ -10,7 +10,6 @@
namespace Flarum\Api\Controller;
use Flarum\Group\Permission;
use Flarum\User\AssertPermissionTrait;
use Illuminate\Support\Arr;
use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface;
@@ -19,14 +18,12 @@ use Psr\Http\Server\RequestHandlerInterface;
class SetPermissionController implements RequestHandlerInterface
{
use AssertPermissionTrait;
/**
* {@inheritdoc}
*/
public function handle(ServerRequestInterface $request): ResponseInterface
{
$this->assertAdmin($request->getAttribute('actor'));
$request->getAttribute('actor')->assertAdmin();
$body = $request->getParsedBody();
$permission = Arr::get($body, 'permission');

View File

@@ -11,7 +11,6 @@ namespace Flarum\Api\Controller;
use Flarum\Settings\Event;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\AssertPermissionTrait;
use Illuminate\Contracts\Events\Dispatcher;
use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface;
@@ -20,8 +19,6 @@ use Psr\Http\Server\RequestHandlerInterface;
class SetSettingsController implements RequestHandlerInterface
{
use AssertPermissionTrait;
/**
* @var \Flarum\Settings\SettingsRepositoryInterface
*/
@@ -46,7 +43,7 @@ class SetSettingsController implements RequestHandlerInterface
*/
public function handle(ServerRequestInterface $request): ResponseInterface
{
$this->assertAdmin($request->getAttribute('actor'));
$request->getAttribute('actor')->assertAdmin();
$settings = $request->getParsedBody();

View File

@@ -11,15 +11,12 @@ namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\MailSettingsSerializer;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\AssertPermissionTrait;
use Illuminate\Contracts\Validation\Factory;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class ShowMailSettingsController extends AbstractShowController
{
use AssertPermissionTrait;
/**
* {@inheritdoc}
*/
@@ -30,7 +27,7 @@ class ShowMailSettingsController extends AbstractShowController
*/
protected function data(ServerRequestInterface $request, Document $document)
{
$this->assertAdmin($request->getAttribute('actor'));
$request->getAttribute('actor')->assertAdmin();
$drivers = array_map(function ($driver) {
return self::$container->make($driver);

View File

@@ -10,14 +10,11 @@
namespace Flarum\Api\Controller;
use Flarum\Extension\ExtensionManager;
use Flarum\User\AssertPermissionTrait;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
class UninstallExtensionController extends AbstractDeleteController
{
use AssertPermissionTrait;
/**
* @var ExtensionManager
*/
@@ -33,7 +30,7 @@ class UninstallExtensionController extends AbstractDeleteController
protected function delete(ServerRequestInterface $request)
{
$this->assertAdmin($request->getAttribute('actor'));
$request->getAttribute('actor')->assertAdmin();
$name = Arr::get($request->getQueryParams(), 'name');

View File

@@ -10,7 +10,6 @@
namespace Flarum\Api\Controller;
use Flarum\Extension\ExtensionManager;
use Flarum\User\AssertPermissionTrait;
use Illuminate\Support\Arr;
use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface;
@@ -19,8 +18,6 @@ use Psr\Http\Server\RequestHandlerInterface;
class UpdateExtensionController implements RequestHandlerInterface
{
use AssertPermissionTrait;
/**
* @var ExtensionManager
*/
@@ -39,7 +36,7 @@ class UpdateExtensionController implements RequestHandlerInterface
*/
public function handle(ServerRequestInterface $request): ResponseInterface
{
$this->assertAdmin($request->getAttribute('actor'));
$request->getAttribute('actor')->assertAdmin();
$enabled = Arr::get($request->getParsedBody(), 'enabled');
$name = Arr::get($request->getQueryParams(), 'name');

View File

@@ -10,7 +10,6 @@
namespace Flarum\Api\Controller;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\AssertPermissionTrait;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Intervention\Image\ImageManager;
@@ -20,8 +19,6 @@ use Tobscure\JsonApi\Document;
class UploadFaviconController extends ShowForumController
{
use AssertPermissionTrait;
/**
* @var SettingsRepositoryInterface
*/
@@ -47,7 +44,7 @@ class UploadFaviconController extends ShowForumController
*/
public function data(ServerRequestInterface $request, Document $document)
{
$this->assertAdmin($request->getAttribute('actor'));
$request->getAttribute('actor')->assertAdmin();
$file = Arr::get($request->getUploadedFiles(), 'favicon');
$extension = pathinfo($file->getClientFilename(), PATHINFO_EXTENSION);

View File

@@ -10,7 +10,6 @@
namespace Flarum\Api\Controller;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\AssertPermissionTrait;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Intervention\Image\ImageManager;
@@ -20,8 +19,6 @@ use Tobscure\JsonApi\Document;
class UploadLogoController extends ShowForumController
{
use AssertPermissionTrait;
/**
* @var SettingsRepositoryInterface
*/
@@ -47,7 +44,7 @@ class UploadLogoController extends ShowForumController
*/
public function data(ServerRequestInterface $request, Document $document)
{
$this->assertAdmin($request->getAttribute('actor'));
$request->getAttribute('actor')->assertAdmin();
$file = Arr::get($request->getUploadedFiles(), 'logo');

View File

@@ -27,7 +27,7 @@ abstract class Migration
{
return [
'up' => function (Builder $schema) use ($name, $definition) {
$schema->create($name, function (Blueprint $table) use ($schema, $definition) {
$schema->create($name, function (Blueprint $table) use ($definition) {
$definition($table);
});
},
@@ -59,7 +59,7 @@ abstract class Migration
{
return [
'up' => function (Builder $schema) use ($tableName, $columnDefinitions) {
$schema->table($tableName, function (Blueprint $table) use ($schema, $columnDefinitions) {
$schema->table($tableName, function (Blueprint $table) use ($columnDefinitions) {
foreach ($columnDefinitions as $columnName => $options) {
$type = array_shift($options);
$table->addColumn($type, $columnName, $options);

View File

@@ -12,14 +12,12 @@ namespace Flarum\Discussion\Command;
use Flarum\Discussion\DiscussionRepository;
use Flarum\Discussion\Event\Deleting;
use Flarum\Foundation\DispatchEventsTrait;
use Flarum\User\AssertPermissionTrait;
use Flarum\User\Exception\PermissionDeniedException;
use Illuminate\Contracts\Events\Dispatcher;
class DeleteDiscussionHandler
{
use DispatchEventsTrait;
use AssertPermissionTrait;
/**
* @var \Flarum\Discussion\DiscussionRepository
@@ -47,7 +45,7 @@ class DeleteDiscussionHandler
$discussion = $this->discussions->findOrFail($command->discussionId, $actor);
$this->assertCan($actor, 'delete', $discussion);
$actor->assertCan('delete', $discussion);
$this->events->dispatch(
new Deleting($discussion, $actor, $command->data)

View File

@@ -13,14 +13,12 @@ use Flarum\Discussion\DiscussionRepository;
use Flarum\Discussion\DiscussionValidator;
use Flarum\Discussion\Event\Saving;
use Flarum\Foundation\DispatchEventsTrait;
use Flarum\User\AssertPermissionTrait;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\Arr;
class EditDiscussionHandler
{
use DispatchEventsTrait;
use AssertPermissionTrait;
/**
* @var DiscussionRepository
@@ -58,13 +56,13 @@ class EditDiscussionHandler
$discussion = $this->discussions->findOrFail($command->discussionId, $actor);
if (isset($attributes['title'])) {
$this->assertCan($actor, 'rename', $discussion);
$actor->assertCan('rename', $discussion);
$discussion->rename($attributes['title']);
}
if (isset($attributes['isHidden'])) {
$this->assertCan($actor, 'hide', $discussion);
$actor->assertCan('hide', $discussion);
if ($attributes['isHidden']) {
$discussion->hide($actor);

View File

@@ -12,13 +12,11 @@ namespace Flarum\Discussion\Command;
use Flarum\Discussion\DiscussionRepository;
use Flarum\Discussion\Event\UserDataSaving;
use Flarum\Foundation\DispatchEventsTrait;
use Flarum\User\AssertPermissionTrait;
use Illuminate\Contracts\Events\Dispatcher;
class ReadDiscussionHandler
{
use DispatchEventsTrait;
use AssertPermissionTrait;
/**
* @var DiscussionRepository
@@ -44,7 +42,7 @@ class ReadDiscussionHandler
{
$actor = $command->actor;
$this->assertRegistered($actor);
$actor->assertRegistered();
$discussion = $this->discussions->findOrFail($command->discussionId, $actor);

View File

@@ -15,7 +15,6 @@ use Flarum\Discussion\DiscussionValidator;
use Flarum\Discussion\Event\Saving;
use Flarum\Foundation\DispatchEventsTrait;
use Flarum\Post\Command\PostReply;
use Flarum\User\AssertPermissionTrait;
use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher;
use Illuminate\Contracts\Events\Dispatcher as EventDispatcher;
use Illuminate\Support\Arr;
@@ -23,7 +22,6 @@ use Illuminate\Support\Arr;
class StartDiscussionHandler
{
use DispatchEventsTrait;
use AssertPermissionTrait;
/**
* @var BusDispatcher
@@ -58,7 +56,7 @@ class StartDiscussionHandler
$data = $command->data;
$ipAddress = $command->ipAddress;
$this->assertCan($actor, 'startDiscussion');
$actor->assertCan('startDiscussion');
// Create a new Discussion entity, persist it, and dispatch domain
// events. Before persistence, though, fire an event to give plugins

View File

@@ -12,6 +12,7 @@ namespace Flarum\Event;
use Flarum\User\User;
/**
* @deprecated beta 14, remove in beta 15. Use the User extender instead.
* The `PrepareUserGroups` event.
*/
class PrepareUserGroups

View File

@@ -9,8 +9,6 @@
namespace Flarum\Extend;
use Flarum\Extension\Event\Disabled;
use Flarum\Extension\Event\Enabled;
use Flarum\Extension\Extension;
use Flarum\Foundation\Event\ClearingCache;
use Flarum\Frontend\Assets;
@@ -23,7 +21,7 @@ use Flarum\Locale\LocaleManager;
use Flarum\Settings\Event\Saved;
use Illuminate\Contracts\Container\Container;
class Frontend implements ExtenderInterface
class Frontend implements ExtenderInterface, LifecycleInterface
{
private $frontend;
@@ -115,13 +113,9 @@ class Frontend implements ExtenderInterface
$events = $container->make('events');
$events->listen(
[Enabled::class, Disabled::class, ClearingCache::class],
function () use ($container, $abstract) {
$recompile = new RecompileFrontendAssets(
$container->make($abstract),
$container->make(LocaleManager::class)
);
$recompile->flush();
ClearingCache::class,
function () use ($container) {
$this->recompile($container);
}
);
@@ -185,4 +179,27 @@ class Frontend implements ExtenderInterface
{
return $extension ? $extension->getId() : 'site-custom';
}
public function onEnable(Container $container, Extension $extension)
{
if (! empty($this->js) || ! empty($this->css)) {
$this->recompile($container);
}
}
public function onDisable(Container $container, Extension $extension)
{
if (! empty($this->js) || ! empty($this->css)) {
$this->recompile($container);
}
}
private function recompile($container)
{
$recompile = new RecompileFrontendAssets(
$container->make('flarum.assets.'.$this->frontend),
$container->make(LocaleManager::class)
);
$recompile->flush();
}
}

View File

@@ -15,16 +15,38 @@ use Illuminate\Contracts\Container\Container;
class User implements ExtenderInterface
{
private $displayNameDrivers = [];
private $groupProcessors = [];
/**
* Add a mail driver.
* Add a display name driver.
*
* @param string $identifier Identifier for display name driver. E.g. 'username' for UserNameDriver
* @param string $driver ::class attribute of driver class, which must implement Flarum\User\DisplayName\DriverInterface
*/
public function displayNameDriver(string $identifier, $driver)
{
$this->drivers[$identifier] = $driver;
$this->displayNameDrivers[$identifier] = $driver;
return $this;
}
/**
* Dynamically process a user's list of groups when calculating permissions.
* This can be used to give a user permissions for groups they aren't actually in, based on context.
* It will not change the group badges displayed for the user.
*
* @param callable $callable
*
* The callable can be a closure or invokable class, and should accept:
* - \Flarum\User\User $user: the user in question.
* - array $groupIds: an array of ids for the groups the user belongs to.
*
* The callable should return:
* - array $groupIds: an array of ids for the groups the user belongs to.
*/
public function permissionGroups(callable $callable)
{
$this->groupProcessors[] = $callable;
return $this;
}
@@ -32,7 +54,11 @@ class User implements ExtenderInterface
public function extend(Container $container, Extension $extension = null)
{
$container->extend('flarum.user.display_name.supported_drivers', function ($existingDrivers) {
return array_merge($existingDrivers, $this->drivers);
return array_merge($existingDrivers, $this->displayNameDrivers);
});
$container->extend('flarum.user.group_processors', function ($existingRelations) {
return array_merge($existingRelations, $this->groupProcessors);
});
}
}

51
src/Extend/View.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Extend;
use Flarum\Extension\Extension;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\View\Factory;
class View implements ExtenderInterface
{
private $namespaces = [];
/**
* Register a new namespace of Laravel views.
*
* Views are php files that use the Laravel Blade syntax for creation of server-side generated html.
* Flarum core uses them for error pages, the installer, HTML emails, and the skeletons for the forum and admin sites.
* To create and use views in your extension, you will need to put them in a folder, and register that folder as a namespace.
*
* Views can then be used in your extension by injecting an instance of `Illuminate\Contracts\View\Factory`,
* and calling its `make` method. The `make` method takes the view parameter in the format NAMESPACE::VIEW_NAME.
* You can also pass variables into a view: for more information, see https://laravel.com/api/6.x/Illuminate/View/Factory.html#method_make
*
* @param string $namespace: The name of the namespace.
* @param string|array $hints: This is a path (or an array of paths) to the folder(s)
* where view files are stored, relative to the extend.php file.
* @return $this
*/
public function namespace($namespace, $hints)
{
$this->namespaces[$namespace] = $hints;
return $this;
}
public function extend(Container $container, Extension $extension = null)
{
$container->resolving(Factory::class, function (Factory $view) {
foreach ($this->namespaces as $namespace => $hints) {
$view->addNamespace($namespace, $hints);
}
});
}
}

Some files were not shown because too many files have changed in this diff Show More