1
0
mirror of https://github.com/flarum/core.git synced 2025-08-16 21:34:08 +02:00

Compare commits

..

225 Commits

Author SHA1 Message Date
David Sevilla Martin
f6d88bf724 Create Pagination util & make DiscussionListState and DiscussionList use it
I'm 100% sure that this code can be improved by a ton. Just pushing this so we can potentially work off of it.
Some stuff taken from PR #1829.
Does *not* have changing URL query parameter - this code is already a disaster.
2021-01-10 10:56:26 -05:00
Alexander Skvortsov
925628c208 Add vscode config to gitignore 2021-01-07 23:27:32 -05:00
Alexander Skvortsov
aae83c4fbc Fix deleting posts/discussions by deleted user (#2521)
Making the $user argument nullable prevents this unnecessary exception, and doesn't introduce any issues since we check that $user exists as part of the method.
2021-01-07 17:46:14 -05:00
flarum-bot
d4b2d89da0 Bundled output for commit 9b27b0d9d7 [skip ci] 2021-01-07 15:26:14 +00:00
Sami Mazouz
9b27b0d9d7 Fix composer header hidden by mobile browser (#2279) 2021-01-07 10:25:12 -05:00
Alexander Skvortsov
94381dca62 Fix IOS scroll menu bug (#2527)
Fixes https://github.com/flarum/core/issues/1959

These transform lines are known to cause issues on iOS, and were added to hack around chrome issues that have since been fixed upstream.
2021-01-05 19:40:11 -05:00
Sami Mazouz
a2d5dd3397 Add default value to Settings extender (#2495) 2021-01-05 01:28:25 -05:00
Daniël Klabbers
f8edc2d827 npm audit fix 2020-12-20 20:55:51 +01:00
flarum-bot
62235a16ca Bundled output for commit 36c55e8f69 [skip ci] 2020-12-20 17:15:07 +00:00
Sami Mazouz
36c55e8f69 Add ExtensionPermissionGrid to compat (#2501) 2020-12-20 12:14:00 -05:00
Daniël Klabbers
859f014539 beta 15 changelog and version constant 2020-12-18 20:02:22 +01:00
Daniël Klabbers
06e1d21331 Fixes validation failures of avatars that are jpg/jpeg (#2497)
Due to a commit by @fabpot in october, the mimetypes symfony class
now re-orders the shortened mimetypes that are returned when looking
up based on header mimetype. Our validator uses the first key, pops
the prefix off and then matches against our hardcoded array.

I've added a constraint to symfony/mime ^5.2.0 which ships with this change.
This constraint is fully compatible with our current lineup. In addition
I changed the hardcoded array to use the first entry from symfony mime types
now `jpg` instead of `jpeg`.
2020-12-16 13:53:17 -05:00
flarum-bot
fd5de6929e Bundled output for commit 84b1666b24 [skip ci] 2020-12-15 22:50:49 +00:00
Alexander Skvortsov
84b1666b24 Support multiple callback-based settings per-extension 2020-12-15 17:49:24 -05:00
Alexander Skvortsov
0c61fcc61c Clarify that request argument of render callbacks for Formatter must be either nullable or omitted 2020-12-14 17:20:35 -05:00
Alexander Skvortsov
8e25bcb68f Deprecate CheckingForFlooding
This should have been done earlier as part of the ThrottleApi PR
2020-12-14 17:12:05 -05:00
flarum-bot
fad783547c Bundled output for commit 210a6b3e25 [skip ci] 2020-12-14 19:07:44 +00:00
Alexander Skvortsov
210a6b3e25 Fix scroll on long discussions
- Anchor scroll when inserting post placeholders
- Indicate that pages are loading at start of `loadPage`, which allows `onscroll` to not request that multiple pages be loaded at the same time

These changes are particularly applicable to firefox, where previously, dozens of posts could be skipped at a time if scroll up was held while at the top of the viewport.
2020-12-14 14:06:32 -05:00
Ian Morland
73409184b9 Fix wrong namespace in docblock (#2494) 2020-12-12 15:36:25 -05:00
Alexander Skvortsov
afe038699e Fix composer json attribute path to links override section 2020-12-08 19:29:59 -05:00
Sami Mazouz
649851d356 Remove header bottom border (#2489) 2020-12-08 19:15:14 -05:00
Alexander Skvortsov
d1dfa758e4 Policy Extender and Tests (#2461)
Policy application has also been refactored, so that policies return one of `allow`, `deny`, `forceAllow`, `forceDeny`. The result of a set of policies is no longer the first non-null result, but rather the highest priority result (forceDeny > forceAllow > deny > allow, so if a single forceDeny is present, that beats out all other returned results). This removes order in which extensions boot as a factor.
2020-12-08 19:10:06 -05:00
Alexander Skvortsov
8901073d12 Model Visibility Scoping Extender and Tests (#2460) 2020-12-07 20:02:46 -05:00
flarum-bot
e0437d237a Bundled output for commit 07a43f52b4 [skip ci] 2020-12-07 20:15:49 +00:00
Charlie
07a43f52b4 AdminUX Overhaul Small Patches (#2468) 2020-12-07 15:14:22 -05:00
flarum-bot
9e9118fa0d Bundled output for commit 4679448300 [skip ci] 2020-12-07 18:35:10 +00:00
Matt Kilgore
4679448300 Slug Driver Support (#2456)
- Support slug drivers for core's sluggable models, easily extends to other models
- Add automated testing for affected single-model API routes
- Fix nickname selection UI
- Serialize slugs as `slug` attribute
- Make min search length a constant
2020-12-07 13:33:42 -05:00
flarum-bot
ef4bf8128e Bundled output for commit 67a2aac635 [skip ci] 2020-12-07 18:26:51 +00:00
David Sevilla Martín
67a2aac635 Replace forum and admin global compat exports with a Proxy to allow namespace use (#2488) 2020-12-07 13:25:24 -05:00
Sami Mazouz
51a97fb12e ApiController Extender and Tests (#2451) 2020-12-06 15:07:48 -05:00
Sami Mazouz
056d420c7b Pass callback wrapper parameters by reference (#2485)
Because invokable class objects are not directly called and instead it's the callback wrapper that calls these objects, it's currently not possible to receive arguments by reference on an invokable class.

To fix this we pass the arguments by reference by default when calling the object in the callback wrapper.
2020-12-06 14:58:45 -05:00
Sami Mazouz
cfa533ebd6 Add Settings Extender (#2452) 2020-12-04 17:20:06 -05:00
Alexander Skvortsov
eed407812f User Preferences Extender and Tests (#2463) 2020-12-04 15:45:08 -05:00
Daniël Klabbers
641619e820 Fixes issue with the worker defaulting to the illuminate queue manager (#2481)
We are instantiating our own queue handling factory which returns the
flarum.queue.connection binding no matter what. The queue Worker and
other queue related code rely on this manager to get its thing going.
Therefor we need to re-use our own factory everywhere, including in
the worker.
2020-12-02 13:19:25 -05:00
Alexander Skvortsov
984f751c71 Use process isolation for integration tests 2020-12-01 19:33:24 -05:00
flarum-bot
8830e9dd09 Bundled output for commit fe41bc1fdc [skip ci] 2020-12-01 16:22:59 +00:00
Alexander Skvortsov
fe41bc1fdc Remove Deprecated Beta14 Code (#2428) 2020-12-01 11:21:28 -05:00
Nina Pypchenko
5a763050a6 DRY up image uploading code (#2477) 2020-12-01 10:42:05 -05:00
Sami Mazouz
8c813bc340 ApiSerializer Extender (#2438) 2020-11-30 19:24:50 -05:00
flarum-bot
f67dee0a9e Bundled output for commit f968420216 [skip ci] 2020-11-30 19:02:41 +00:00
Alexander Skvortsov
f968420216 Don't use browser scroll restore in DiscussionPage (#2476)
Although native browser scroll restorations have become quite powerful, it interferes with Flarum's PostStream, so if we're on a DiscussionPage, we use manual scroll restoration.
2020-11-30 14:01:08 -05:00
flarum-bot
d5e124b4a2 Bundled output for commit 09e2736cbc [skip ci] 2020-11-29 23:34:50 +00:00
Alexander Skvortsov
09e2736cbc Fix goToIndex to visible end
In the PostStream, `this.visibleEnd` represents the index of the last post + 1, but `loadNearIndex` treated it as if it was the index of the last post. This means that executing `goToIndex` on the post stream's current `this.visiblePost` didn't load new posts, and as a result, the requested scrolling did not occur.
2020-11-29 18:33:29 -05:00
flarum-bot
ddb3d3edb0 Bundled output for commit 28d56f5fc8 [skip ci] 2020-11-29 22:47:21 +00:00
Alexander Skvortsov
28d56f5fc8 Merge pull request #2465 from flarum/0.1.0-beta.14.1 2020-11-29 17:45:58 -05:00
Alexander Skvortsov
9b4012bbb5 Reset dist js 2020-11-29 17:41:16 -05:00
Alexander Skvortsov
1a5e4d454e Move floodgate to middleware, add extender + integration tests (#2170) 2020-11-29 17:13:22 -05:00
sl-kr
387b4fd315 update a user's comment count if deleting a discussion (#2472) 2020-11-29 17:11:05 -05:00
flarum-bot
66482c2815 Bundled output for commit 277a5c3fac [skip ci] 2020-11-26 22:54:38 +00:00
Mohammad Reza
277a5c3fac Clear error alerts in change email modal on success (#2467) 2020-11-26 17:53:38 -05:00
Nina Pypchenko
286d8dec5b Update tsconfig file to include .tsx files (#2457) 2020-11-26 12:00:13 -05:00
flarum-bot
e1c61a0e85 Bundled output for commit 102e76b084 [skip ci] 2020-11-26 06:56:10 +00:00
Alexander Skvortsov
102e76b084 Defer clearing discussion list on discussion start
This prevents an edge case where `app.discussions` is considered empty while the new page is loading, and as a result, the side pane isn't set as "enabled". Then, if the pane has previously been pinned, when the page loads and the side pane appears, it covers up part of the discussion page.

Fixes https://github.com/flarum/core/issues/2471
2020-11-26 01:54:28 -05:00
flarum-bot
d09d4bc507 Bundled output for commit c3989cc952 [skip ci] 2020-11-24 17:46:02 +00:00
Charlie
c3989cc952 AdminUX Overhaul (#2409)
- Extensions now have their own pages
- The API for extensions to register permissions and settings has been overhauled via the `flarum/admin/utils/ExtensionData` util
- An extension grid has been added as a widget to the Dashboard page
2020-11-24 12:44:40 -05:00
flarum-bot
9cb9097b24 Bundled output for commit 571a835be0 [skip ci] 2020-11-14 22:23:04 +00:00
Wadim Kalmykov
571a835be0 Fix mobile PostStream top scroll adjustment & remove App:before (#2385)
- remove App:before so we can use #app-navigation to access the mobile header
- fix mobile postStream scroll top margin adjustment
2020-11-14 17:21:38 -05:00
Alexander Skvortsov
0c95774333 Refactor Route Resolving and Dispatch (#2425)
- Split DispatchRoute. This allows us to run middleware after we figure out which route we're on, but before we actually execute the controller for that route.
- By making the route name explicitly available to middlewares, applications like CSRF and floodgate can set patterns based on route names instead of the path, which is an implementation detail.
- Support using route name match for CSRF extender, deprecate path match
2020-11-10 12:52:12 -05:00
Nina Pypchenko
67741c7a6f Make checkbox switch component background stand out in modals (#2443) 2020-11-09 20:54:21 -05:00
Alexander Skvortsov
f5cfec15e3 Add missing import 2020-11-08 21:49:11 -05:00
Alexander Skvortsov
47d2eee9ce Fix Callables for Extenders (#2423)
- Standardize signatures and variable names for extenders that take callbacks
- Adjust model extender docblock to clarify that default calue can't be an invokable class.
- Make invokable classes provided to Model->relationship
- Add integration tests to ensure Model->relationship and User->groupProcessor extenders accept callbacks
- Extract code for wrapping callbacks into central util
2020-11-08 21:36:38 -05:00
Nina Pypchenko
c10cc92deb Improved Permissions Error Messages for Initial Install (#2435)
- Made the wording of the error more generic
- Added link to the relevant section in the installation guide

Resolves #2327.
2020-11-07 14:48:11 -05:00
Sami Mazouz
529d2edcaf Add Service Provider Extender (#2437) 2020-11-06 13:30:10 -05:00
Sami Mazouz
f0e77a5789 Add Notification Channel Extender (#2432) 2020-11-05 12:09:06 -05:00
Alexander Skvortsov
87c258b2f8 Refactor and improve formatter extender (#2098)
- Deprecated all events involved with Formatter
- Refactor ->configure() method on extender not to use events
- Add extender methods for ->render() and ->parse()
- Add integration tests
2020-11-03 13:05:33 -05:00
Alexander Skvortsov
cee87848fe Added post extender with type method, deprecated ConfigurePostTypes (#2101) 2020-11-03 10:43:49 -05:00
Daniël Klabbers
967cd0e3ca update version constant for beta 14.1 2020-11-02 13:53:20 +01:00
Daniël Klabbers
b79152b977 bundled output for js changes beta 14.1 2020-11-02 11:53:27 +01:00
Daniël Klabbers
ace624db66 changelog for v0.1.0-beta.14.1 2020-11-02 11:51:24 +01:00
Alexander Skvortsov
5842dd1200 Validator extender (#2102)
Added validator extender, integration tests, and deprecated related Validating event
2020-11-01 11:31:16 -05:00
Sami Mazouz
b311512502 Add Notification Type Extender and Tests (#2424) 2020-10-31 17:17:14 -04:00
Alexander Skvortsov
9b9f2c4bb7 Fix exiting composer while in fullscreen mode. 2020-10-30 20:44:52 -04:00
flarum-bot
0b2a5fa5b8 Bundled output for commit 52e45aacad [skip ci] 2020-10-31 00:28:56 +00:00
Lucas Henrique
52e45aacad Convert common time helpers to Typescript (#2391) 2020-10-30 20:27:40 -04:00
Alexander Skvortsov
8b1de457bf Fix broken page title logic on subpath installs
The base path needs to be accounted for when calculating whether we're on the default route.
2020-10-30 14:18:09 -04:00
Alexander Skvortsov
21c2a4b2a4 Updater should show on any subpath, like installer (#2426) 2020-10-30 13:28:20 -04:00
flarum-bot
12c03dc4e1 Bundled output for commit d2927cfdb9 [skip ci] 2020-10-29 16:54:36 +00:00
Alexander Skvortsov
d2927cfdb9 Ensure scripts provided by textformatter are run (#2415) 2020-10-29 12:53:23 -04:00
Daniël Klabbers
24b7a21507 Update Symfony components to v4 (#2407)
This matches the Symfony dependencies of our laravel dependencies.
2020-10-27 17:12:36 -04:00
flarum-bot
c9a04fe009 Bundled output for commit bd7fa11b5a [skip ci] 2020-10-25 17:36:51 +00:00
Alexander Skvortsov
bd7fa11b5a Export SuperTextarea util in compat 2020-10-25 13:35:15 -04:00
Daniël Klabbers
7055f6d941 update version constant 2020-10-20 16:34:54 +02:00
Alexander Skvortsov
f765001f06 Update email 2020-10-20 10:32:24 -04:00
Daniël Klabbers
683739a617 changelog v0.1.0-beta.14 and added core developer @askvortsov1 2020-10-20 16:24:43 +02:00
flarum-bot
69b7fe8d01 Bundled output for commit 1936b9117d [skip ci] 2020-10-17 17:43:39 +00:00
Alexander Skvortsov
1936b9117d Page Scroll Cleanup (#2396)
- Reintroduce cancellable scroll top on page change
- IndexPage: rely on browser to retain scroll position on page reload
- Remove obsolete browser hack
- Fix broken selector
- When on mobile, only retain scroll for IndexPage if we're coming from a discussion
- Move app.cache.scrollTop save into `onbeforeremove` so we make sure to do it before DOM is detached
2020-10-17 13:42:33 -04:00
flarum-bot
d53eeded44 Bundled output for commit 0650788e7c [skip ci] 2020-10-16 20:32:13 +00:00
Alexander Skvortsov
0650788e7c Fix scolling to first post via m.route.set
The default first post number is '1', so we scroll to that if we're calling `m.route.set` without a `near` parameter, as that means we're scrolling to the top.

This was present in beta 13's implementation, but accidentially omitted in 988b6c9.

We also remove unnecessary typecasting for simpler logic and increased consistency with beta 13.
2020-10-16 16:30:27 -04:00
flarum-bot
6a77184611 Bundled output for commit a8b36cb76d [skip ci] 2020-10-16 20:05:15 +00:00
Alexander Skvortsov
a8b36cb76d Fix check for going between discussion pages.
The current implementation for checking whether we are on a discussion page, and going to a discussion page, checks the route we are going to. This is problematic, because the route resolver represents the route being considered, not the route we are currently on. So, if we are currently using a DiscussionPageResolver, we must be going to a route handled by DiscussionPage. Instead, we need to check the route that we are currently on, which is done via `app.current.matches(DiscussionPage)`.
2020-10-16 16:03:32 -04:00
flarum-bot
5cd14d594b Bundled output for commit f4ad9d2d5a [skip ci] 2020-10-16 16:07:09 +00:00
Alexander Skvortsov
f4ad9d2d5a Fix scrolling to reply via 'reply' as near parameter 2020-10-16 12:04:45 -04:00
Alexander Skvortsov
d409484abf Notification: fix wrong external attr for Link 2020-10-16 11:53:45 -04:00
flarum-bot
1fc24635f6 Bundled output for commit ff7ac0b322 [skip ci] 2020-10-16 05:26:03 +00:00
Alexander Skvortsov
ff7ac0b322 Fix PostStream loadRange doesn't return all posts (#2384)
- Also, ensure that posts are ordered by creation timestamp
2020-10-16 01:24:45 -04:00
Wadim Kalmykov
d460aaa3ad order posts by creation date 2020-10-16 01:20:54 -04:00
Wadim Kalmykov
7634a766cb Fix loadRange doesn't return all posts 2020-10-16 01:20:54 -04:00
flarum-bot
e5f53b93a6 Bundled output for commit a38c92d409 [skip ci] 2020-10-16 01:59:56 +00:00
Alexander Skvortsov
a38c92d409 Fix broken import 2020-10-15 21:58:18 -04:00
Alexander Skvortsov
3da655a62f Rename resolver to resolvers for consistency 2020-10-15 21:26:34 -04:00
flarum-bot
46c3124b0b Bundled output for commit e6f59b834f [skip ci] 2020-10-15 22:20:11 +00:00
Alexander Skvortsov
e6f59b834f Default force attr to true on LinkButton
This retains beta 13 behavior.
2020-10-15 18:18:51 -04:00
Alexander Skvortsov
9f5737eb93 Fix routeName attr not being passed into pages 2020-10-15 18:14:20 -04:00
flarum-bot
35cb5b20a0 Bundled output for commit 988b6c9023 [skip ci] 2020-10-15 22:02:46 +00:00
Alexander Skvortsov
988b6c9023 Allow extensions to use route resolvers (#2275)
- mapRoutes: don't wrap components in resolvers if they are already resolvers
- Extract defaultResolver into its own class
- Allow either route resolver instances, or components with an optional resolverClass which should accept the component and route name in its constructor.
- Introduce a resolver for DiscussionPage, so that routing from one post to another on the same discussion triggers a scroll instead of rerendering
2020-10-15 18:01:17 -04:00
flarum-bot
c1d91be2f4 Bundled output for commit f534398645 [skip ci] 2020-10-15 21:47:33 +00:00
Alexander Skvortsov
f534398645 Fix PostStream Reply Scroll (#2366)
- Add an index to reply placeholder so we can scroll to it directly when replying.
- Stop pretending that the currently broken `bottom` scroll functionality works, and explicitly call it `reply` scrolling to be clearer
- Directly get target from state
- Explicitly scroll to placeholder on reply
- Clean up scrollToItem code a bit
- Account for edge case where index is undefined when scrolling to post

Co-authored-by: Wadim Kalmykov <36057469+w-4@users.noreply.github.com>
2020-10-15 17:46:02 -04:00
flarum-bot
cd05ec6589 Bundled output for commit 78be6e2194 [skip ci] 2020-10-15 21:41:56 +00:00
Wadim Kalmykov
78be6e2194 Fix lifecyle method workarounds (#2378)
Essentially, whenever a route is loaded, we add a key to that component. If the key changes, the page completely rerenders. Switching between different routes handled by the same key triggers those rerenders.
2020-10-15 17:40:25 -04:00
flarum-bot
eb498a0a9f Bundled output for commit ac42a5900d [skip ci] 2020-10-15 21:36:46 +00:00
Wadim Kalmykov
ac42a5900d Make PostStreamScrubber work for Posts that have top margin (#2369)
Also fixes incorrect page count when scrolling to bottom (https://github.com/flarum/core/issues/1897)
2020-10-15 17:35:22 -04:00
Alexander Skvortsov
543b136f7c Refactor PostStream animations (#2364)
- If the fadeIn animation is specified on the PostStream class itself, any time we add/remove another animation, it will redo fadeIn. To avoid this, we move fadeIn into it's own css class, which is applied, and then immediately removed after the animation is completed to ensure it only runs once.
- The "fix" for flashItem was actually broken, as it resulted in 'flash' never being removed, so we never went back to .PostStream's fadeIn. We adjust flashItem to ensure that '.flash' is removed. We also remove 'fadeIn' in case it hasn't yet been removed in oncreate.
2020-10-15 17:34:35 -04:00
flarum-bot
8546fb734f Bundled output for commit 20b9455f04 [skip ci] 2020-10-15 18:32:09 +00:00
Wadim Kalmykov
20b9455f04 make scroll listener passive (#2387)
see: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
2020-10-15 14:30:32 -04:00
Wadim Kalmykov
008f1da539 Make header and navigation components redraw after page components (#2390)
Make header and navigation components redraw after page components. Page components manipulate the header (state), but the header redraws before the page on route change. By changing the mount order, we change also the redraw order.
2020-10-15 14:29:42 -04:00
flarum-bot
a3bd431503 Bundled output for commit 6da81a71a4 [skip ci] 2020-10-14 09:39:52 +00:00
Daniël Klabbers
6da81a71a4 npm audit security vulnerabilities patched 2020-10-14 10:11:34 +02:00
flarum-bot
a48d38614b Bundled output for commit 7358437c59 [skip ci] 2020-10-11 21:27:54 +00:00
Sami Mazouz
7358437c59 Fix MarkRead Mobile Gesture (#2383) 2020-10-11 17:26:32 -04:00
flarum-bot
08ec274a24 Bundled output for commit d45478564f [skip ci] 2020-10-09 23:28:51 +00:00
Alexander Skvortsov
d45478564f Fix handling of non-409 errors in ExtensionsPage
If the error isn't a 409, we'll want to re-throw the error so it'll be handled by the default system (showing an alert).

For simplicity, we can also move 409-handling logic out of setTimeout.

Finally, we adjust the timeout to 300 milliseconds to match the modal transition animation length.
2020-10-09 19:27:07 -04:00
flarum-bot
8296ffe8c9 Bundled output for commit 1d2f0ca407 [skip ci] 2020-10-09 23:07:06 +00:00
Alexander Skvortsov
1d2f0ca407 Header UI fixes (#2371)
* Revert "Fix header contents moving when opening modal (#2131)"
* Fix header contents moving when modal opened/closed.

Conditionally apply the navbar-fixed-top class only when needed, so that we can take advantage of it without always having the navbar in position:fixed, as was done in the previous solution. That resulted in a clash with custom headers.

* Show header on refresh of scrolled page

Due to some magic in Mithril 0.1's context:retain flag, some DOM elements were cached across page reloads. Since that has been eliminated, if we refresh the page and we are scrolled down, the "affix" class which makes the header fixed (and as a result, visible) isn't applied until the first scroll. We fix this by running ScrollListener.update() immediately to set initial navbar state.
2020-10-09 19:05:53 -04:00
Wadim Kalmykov
bb69c3bd57 Reduce modal hide timeout (#2367) 2020-10-09 19:04:53 -04:00
Daniël Klabbers
2b1581875a Fixes the queue for beta 14 (#2363)
- rewrite the queue handling for illuminate 6+
- implement missing maintenance mode callable for queue Worker
- Ensure we resolve append the queue commands once the queue bindings are loaded
- Override WorkCommand because it needs the maintenance flag. It tries to use
the isDownForMaintenance method from the Container assuming it is a Laravel
Application. Circumvented this issue by resolving our Config from IOC instead.
2020-10-09 16:06:28 -04:00
Sami Mazouz
a0c36a015b Use @control-bg for Slidable content (#2381) 2020-10-09 14:37:47 -04:00
flarum-bot
656409794c Bundled output for commit 245f3c6846 [skip ci] 2020-10-07 20:25:22 +00:00
Alexander Skvortsov
245f3c6846 DiscussionPage: call onNewRoute properly
When on a discussion page, the URL changing doesn't always mean we've moved to a different page. In our custom rerender logic, we only want to call `this.onNewRoute()` if the page has actually changed.
2020-10-07 16:22:41 -04:00
Alexander Skvortsov
962b49567c Restore stricter email validation
In v5.8, Laravel expanded email validation logic to closer match the RFC. This, however, allows emails that aren't conventional (for example, emails lacking a TLD). This commit changes Flarum's UserValidator to use the `email:filter` validator, which uses PHP's filter_var, and is the pre-5.8 behavior.

See https://laravel.com/docs/5.8/validation#rule-email
2020-10-07 15:33:57 -04:00
flarum-bot
12498b7620 Bundled output for commit 84f7d29d8c [skip ci] 2020-10-07 18:11:32 +00:00
Alexander Skvortsov
84f7d29d8c Slight PostStream scrubber improvement
After we scroll to a post, we redraw to render post content.  We then update the scrubber again so its height is accurate. This commit moves that update to AFTER our adjustment of scroll position, so that scrubber height is based on actual post heights. This fixes some subtle scrubber glitches.
2020-10-07 14:09:53 -04:00
Daniël Klabbers
561e8c6b6a Add test for model object argument in callable for attribute defaults 2020-10-07 11:38:52 +02:00
Daniël Klabbers
ad9917f0d6 Allows callables for default model attribute to gain access
to the current model in order to calculate the value needed.
2020-10-07 11:26:58 +02:00
Daniël Klabbers
6977c24dd8 improve deprecated message for b15 2020-10-07 10:23:46 +02:00
flarum-bot
d1b72429ac Bundled output for commit 63692f12c5 [skip ci] 2020-10-06 15:53:21 +00:00
Wadim Kalmykov
63692f12c5 SubtreeRetainer: fix onbeforeupdate needsRebuild (#2365) 2020-10-06 11:52:05 -04:00
flarum-bot
441ccec8e7 Bundled output for commit 414b0ff6d3 [skip ci] 2020-10-06 00:52:30 +00:00
Alexander Skvortsov
414b0ff6d3 Update mithril request docs link 2020-10-05 20:50:15 -04:00
flarum-bot
2ff0e1efcb Bundled output for commit 8c46b37a6f [skip ci] 2020-10-05 22:07:23 +00:00
Lucas Henrique
8c46b37a6f Convert icon helper to Typescript (#2360) 2020-10-05 18:06:08 -04:00
flarum-bot
9be629cfcc Bundled output for commit df9be1b063 [skip ci] 2020-10-05 20:26:46 +00:00
Alexander Skvortsov
df9be1b063 Move drawer hide and modal close into onNewRoute
Let's stay consistent with previous behavior, and run these on "internal route change" (same component handles different route) as well as on initial render of a page component.
2020-10-05 16:25:23 -04:00
Alexander Skvortsov
97c36f2f7d Use Symfony TranslatorInterface for tests
This seems to be a leftover change missed in https://github.com/flarum/core/pull/2243
2020-10-05 16:02:12 -04:00
flarum-bot
13efd02085 Bundled output for commit 0b44c48433 [skip ci] 2020-10-05 18:41:55 +00:00
Alexander Skvortsov
0b44c48433 Catch promise reject when not logged in on reply 2020-10-05 14:40:33 -04:00
flarum-bot
b562072471 Bundled output for commit 718445cb0c [skip ci] 2020-10-05 14:56:45 +00:00
Wadim Kalmykov
718445cb0c call parent onremove (#2362) 2020-10-05 10:55:14 -04:00
Abhishek Verma
c6f2ff0c80 Fixed Broken Badges in ReadMe.md (#2358) 2020-10-04 16:11:39 -04:00
flarum-bot
67962f48e5 Bundled output for commit f8a0d9459a [skip ci] 2020-10-03 22:48:56 +00:00
Alexander Skvortsov
f8a0d9459a Fix email confirmation alert
Currently, the controls are on a new line due to the container div. We want to wrap ALL children of the alert, including the controls, in the container div.

We need to split it into a separate class so that we can add modify the alert vnode AFTER the alert component's `view` logic has been applied.
2020-10-03 18:47:27 -04:00
David Sevilla Martín
27d562f3fc Remove Mithril namespace export from shims.d.ts (#2347)
From using PhpStorm to try and see if the autocomplete works properly, it appears as it doesn't. The intention was to not have to import Mithril every time we wanted to type something with Mithril.*, but that doesn't seem to be possible - and it's not a big deal anyway
2020-10-02 19:34:06 -04:00
flarum-bot
17a7155f60 Bundled output for commit 4cdce71d65 [skip ci] 2020-10-02 23:06:45 +00:00
Alexander Skvortsov
4cdce71d65 Eliminate temporary BC layers from rewrite
During the frontend rewrite, we introduced mithril patches for a `route` attr, and for `m.stream`. Later, we decided not to go that route, but not to remove the patches yet to avoid breaking extensions while we were finalizing the replacements. We can now remove these.

Other BC layers are for things from before beta 14, so those remain in place.
2020-10-02 19:05:26 -04:00
flarum-bot
eb03f51c4f Bundled output for commit d695d96e00 [skip ci] 2020-10-02 22:50:48 +00:00
Alexander Skvortsov
d695d96e00 Various TypeScript improvements (#2309)
- Use Mithril.Attributes as base for ComponentAttrs, remove =any from class signature for Component
- Convert Alert to TypeScript, introduce AlertAttrs interface
- Convert AlertManagerState to TypeScript, add overload signatures for `show`, introduce AlertState interface for stored Alerts.
- Set ComponentAttrs as default T for Component
- Make attrs in AlertAttrs optional
- Add AlertIdentifier interface, simplify show type signature
- Remove mithril patch shim, as all patches onto m are now deprecated
- Use Mithril.Static for shim
2020-10-02 18:49:40 -04:00
flarum-bot
dc4884485a Bundled output for commit 40548d7c61 [skip ci] 2020-10-02 22:05:01 +00:00
Wadim Kalmykov
40548d7c61 Improve DiscussionListState refresh method (#2322)
- Ensure that the discussion list is cleared before it is updated with fetched results
- Rename `clear` to `deferClear`, improve documentation to make its purpose clearer.
2020-10-02 18:03:44 -04:00
flarum-bot
60714b7ac4 Bundled output for commit 84d14f485a [skip ci] 2020-10-02 21:55:59 +00:00
Alexander Skvortsov
84d14f485a Basic Extension Dependency Support (#2188)
- Don't enable an extension if its dependencies are not enabled
- Don't disable an extension if its dependencies are not disabled
2020-10-02 17:54:28 -04:00
flarum-bot
0a6c5217c1 Bundled output for commit 44a96a82ef [skip ci] 2020-10-02 21:11:38 +00:00
Alexander Skvortsov
44a96a82ef Minor improvements to onNewRoute (#2328)
- Call onNewRoute when page changed with same component in DiscussionPage and UserPage

- Make app.previous and app.current changed in onNewRoute, not in oninit. This way, when the route is changed, but still handled by the same component, a new PageState object will still be created.
2020-10-02 17:10:38 -04:00
flarum-bot
0b3fe10516 Bundled output for commit 5ecb74fb59 [skip ci] 2020-10-02 20:58:06 +00:00
Alexander Skvortsov
5ecb74fb59 Use Link component for links instead of mithril route patch (#2315)
This new component now also supports external links.
2020-10-02 16:56:40 -04:00
flarum-bot
b66d16e44b Bundled output for commit a013d647e0 [skip ci] 2020-10-02 15:15:05 +00:00
Alexander Skvortsov
a013d647e0 Adjust PostStreamScrubber height after scroll (#2333) 2020-10-02 11:13:55 -04:00
Alexander Skvortsov
20b99bcab1 Ensure that modal hide animates (#2332)
We want to return a promise in``onbeforeremove` with arbitrary loading time to ensure that the animateHide animation has time to complete.
2020-10-02 11:12:49 -04:00
flarum-bot
8325b6eed8 Bundled output for commit f9704f9153 [skip ci] 2020-10-01 18:52:08 +00:00
Alexander Skvortsov
f9704f9153 Fix multiple scrolls to same post in PostStream (#2264)
While more pleasant from an FSM standpoint, comparing the current targetPost to the previous one does not work if goToNumber is called twice in a row for the same post. For instance, if a user clicks the mentions link to a post twice, the post stream breaks.
2020-10-01 14:50:54 -04:00
flarum-bot
09a39d5d95 Bundled output for commit a26f01e49c [skip ci] 2020-10-01 01:03:05 +00:00
Alexander Skvortsov
a26f01e49c Use custom event on ALL SuperTextEditor actions 2020-09-30 21:01:24 -04:00
Sami Mazouz
6d826e5b30 Right align discussion controls dropdown on slidable item (#2330) 2020-09-30 16:53:50 -04:00
flarum-bot
f38605b387 Bundled output for commit 93f8ce78b3 [skip ci] 2020-09-30 20:34:47 +00:00
Alexander Skvortsov
93f8ce78b3 Improve PostStreamState.viewingEnd()
In some cases, such as if we've stickied a post, an event post
may have been added / removed.This means that `this.visibleEnd`
and`this.count()` will be out of sync by 1 post, but we are still
"viewing the end" of the post stream, so we should still reload
all posts up until the last one.
2020-09-30 16:33:10 -04:00
Daniël Klabbers
a001736298 Mark keys for Config required only with InstalledSite (#2323)
* use fallback on url to prevent errors in cli during install. The value of the fallback doesn't actually matter, we just need something.
2020-09-30 15:38:19 -04:00
Daniël Klabbers
86d4bf0214 Fix for a bug that would delete the new revision of less/js in case the filenames match. 2020-09-30 09:26:32 +02:00
Daniël Klabbers
c7b67b922b Allow easier overriding of js compiler (#2318) 2020-09-29 19:03:51 -04:00
flarum-bot
3b63d774d3 Bundled output for commit 86f7550bec [skip ci] 2020-09-29 22:42:20 +00:00
Alexander Skvortsov
86f7550bec Merge pull request #2314 from flarum/as/modal-fix
Frontend Rewrite Followup Modal Fixes
2020-09-29 18:41:03 -04:00
Alexander Skvortsov
9d1a87a4c4 Rename onshow and onhide
animateShow and animateHide are more descriptive
2020-09-29 18:37:56 -04:00
Alexander Skvortsov
a2263b8538 Return on animateShow if already loaded 2020-09-29 18:37:56 -04:00
Alexander Skvortsov
1ac09dbc4d Pass ModalManagerState into Modal instances instead of calling the global. 2020-09-29 18:37:56 -04:00
Alexander Skvortsov
be8fe44f0b Ensure that readyCallback is called on modals opened from other modals 2020-09-29 18:37:56 -04:00
Alexander Skvortsov
b7593bc6a8 Prevent hide animation when opening modal from other modal 2020-09-29 18:37:56 -04:00
Alexander Skvortsov
7fc0963e3c Revert "Fix opening modals from other modals. (#2263)"
This reverts commit 5b157f0adb.
2020-09-29 18:37:56 -04:00
flarum-bot
30f3056f70 Bundled output for commit ed23d7d4e7 [skip ci] 2020-09-29 22:35:50 +00:00
Alexander Skvortsov
ed23d7d4e7 Merge pull request #2299 from flarum/as/poststream_improvements
[Frontend Rewrite] PostStream Improvements
2020-09-29 18:34:16 -04:00
Franz Liedke
1e9f7b7d52 README: Update badge to truly show latest release
Refs #2311.
2020-09-29 23:23:37 +02:00
Abolade Eniseyin
08540fd1db Update logo and badges in README (#2311)
Fixes #2296.
2020-09-29 23:22:51 +02:00
flarum-bot
74fa7122ca Bundled output for commit 4b2d20cd85 [skip ci] 2020-09-29 20:56:44 +00:00
Wadim Kalmykov
4b2d20cd85 fix clear search (#2325) 2020-09-29 16:55:26 -04:00
flarum-bot
077eaaa2f9 Bundled output for commit 6668e75019 [skip ci] 2020-09-28 23:18:39 +00:00
Sami Mazouz
6668e75019 Fix mobile controls gesture on discussion deletion/restoration (#2324)
Because the Slidable class was always added on creation, it was lost every time the class list changed (in this case when the discussion was hidden/unhidden which added/removed DiscussionListItem--hidden class). So by determining the Slidable class's presence in elementAttrs() method, it guarantees it always properly set.
2020-09-28 19:07:05 -04:00
Wadim Kalmykov
d6511e0df5 Improve developer experience by forcing LF line endings (#2321) 2020-09-28 14:04:08 -04:00
Alexander Skvortsov
efd68df13a Pass a translator instance to getEmailSubject on MailableInterface (#2244)
* Pass a translator instance to getMailSubject (breaking change)

* Temporarily comment out getEmailSubject to avoid BC breaks
2020-09-28 00:04:28 -04:00
flarum-bot
f1360a1394 Bundled output for commit cc875f3e95 [skip ci] 2020-09-28 03:51:00 +00:00
Alexander Skvortsov
cc875f3e95 Put m.stream in flarum/utils/stream (#2316) 2020-09-27 23:49:33 -04:00
Franz Liedke
6860b24b70 Use reserved TLD for default dev hostname
See https://jdebp.eu/FGA/dns-use-domain-names-that-you-own.html.
2020-09-27 22:55:46 +02:00
flarum-bot
65766a8386 Bundled output for commit c53509d7d0 [skip ci] 2020-09-27 02:14:07 +00:00
Alexander Skvortsov
c53509d7d0 Add warnings to Mithril 2 BC layer (#2313) 2020-09-26 22:12:43 -04:00
Alexander Skvortsov
4c3e1e2625 Fixed noAnimation: previously, the opposite of what was requested happened 2020-09-25 16:02:39 -04:00
Alexander Skvortsov
6508e64f55 DiscussionPage: only set this.discussion after the initial set of posts has loaded, this results in a slightly smoother initial load. 2020-09-25 15:54:54 -04:00
Alexander Skvortsov
963c27ed60 Provide location data to scrubber earlier to avoid unnecessary and confusing scrubber animation on page load. 2020-09-25 15:52:18 -04:00
Alexander Skvortsov
304f05be36 Don't animate the initial Scrubber placement 2020-09-25 15:43:41 -04:00
Alexander Skvortsov
82af307280 Restore fadeIn to 400ms (#2312)
This is the jQuery fadeIn default, which we were relying on before this animation was changed to pure CSS.
2020-09-25 21:15:24 +02:00
Alexander Skvortsov
50cbb7be5c Merge pull request #2271 from flarum/fl/laravel-updates-config
This extracts another real class for dealing with the configuration options stored in `config.php`. The idea is to reduce the scope of the `Application` class and make it easier to inject exactly what's needed (rather than an array, which is complicated, or the bloated `Application` class).
2020-09-25 11:22:53 -04:00
Franz Liedke
9ea57e6329 Use Config class for data from config.php 2020-09-25 11:10:52 +02:00
Franz Liedke
6639678fb2 Inject/use new config class where applicable 2020-09-25 10:58:53 +02:00
Franz Liedke
f869999011 Add a helper class for managing low-level config 2020-09-25 10:58:52 +02:00
flarum-bot
f885cebdc5 Bundled output for commit 54ff6e720c [skip ci] 2020-09-25 02:32:11 +00:00
Alexander Skvortsov
54ff6e720c Add in BC layer for props, initProps, m.withAttr, and m.prop (#2310) 2020-09-24 22:30:55 -04:00
Daniël Klabbers
aea8a3ff1f Changes methods and properties from private to protected (#2308)
The goal of this PR is to offer increased flexibility for integrators and
custom solutions in skeleton modifications.
2020-09-24 14:30:16 -04:00
Alexander Skvortsov
cc48e9ab22 Replace $app->url() with url-generated link to index (#2302) 2020-09-24 11:30:57 -04:00
Alexander Skvortsov
6d38de9c8f Revert https://github.com/flarum/core/pull/1536 (#2305) 2020-09-24 11:30:27 -04:00
flarum-bot
87634449c0 Bundled output for commit b00ca4ef29 [skip ci] 2020-09-24 04:09:18 +00:00
Matteo Contrini
b00ca4ef29 Fix comment for the time gap feature in PostStream (#2294)
The time interval for the time gap feature is 4 days and not 4 hours.
2020-09-24 00:08:12 -04:00
flarum-bot
fd0f0cdf8b Bundled output for commit 5b157f0adb [skip ci] 2020-09-24 03:13:43 +00:00
Alexander Skvortsov
5b157f0adb Fix opening modals from other modals. (#2263)
While seemingly correct, an onremove method in Modal that triggers animateHide is problematic, because if one modal is opened from another, the one currently open will be removed from the DOM, triggering animateHide, and closing the new modal.

To compensate, an onupdate method now closes a modal if one is open but shouldn't be; this supports the functionality of the old method when the modal is closed not from the modal instance itself (e.g. app.modal.close())

This is not ideal, but necessary. We should consider eventually expanding the modal system to support showing multiple modals at the same time (stacked over each other). Then, we can move this back to individual modals.
2020-09-23 23:12:22 -04:00
Alexander Skvortsov
dc8b203037 Only call updateScrubberValues onupdate when necessary
When the page is scrolled, goToIndex is called, or the page is loaded,
various listeners result in the scrubber being updated with a new
position and values. However, if goToNumber is called, the scrubber
will not be updated. Accordingly, we add logic to the scrubber's
onupdate to update itself, but only when needed, as indicated by this

This saves us a LOT of unnecessary calls, and makes scrubber movement smoother.
2020-09-23 23:06:25 -04:00
Alexander Skvortsov
db71f8bf68 Execute oncreate scrubber update after loadPromise has completed
This way, we ensure that the initial position (and data) of the scrubber is correct. Otherwise, we get blank dates / incorrect location.
2020-09-23 23:06:20 -04:00
Alexander Skvortsov
a004b8e057 Fix $(...).offset() is undefined on some scrolls. 2020-09-23 23:06:14 -04:00
flarum-bot
1ff4076f2a Bundled output for commit 6e9db779cd [skip ci] 2020-09-24 03:06:06 +00:00
Alexander Skvortsov
6e9db779cd Fix double fadein for post stream (#2300) 2020-09-23 23:04:56 -04:00
flarum-bot
f4449e962d Bundled output for commit 71f3379fcc [skip ci] 2020-09-24 02:41:41 +00:00
David Sevilla Martín
71f3379fcc Mithril 2 update (#2255)
* Update frontend to Mithril 2

- Update Mithril version to v2.0.4
- Add Typescript typings for Mithril
- Rename "props" to "attrs"; "initProps" to "initAttrs"; "m.prop" to "m.stream"; "m.withAttr" to "utils/withAttr".
- Use Mithril 2's new lifecycle hooks
- SubtreeRetainer has been rewritten to be more useful for the new system
- Utils for forcing page re-initializations have been added (force attr in links, setRouteWithForcedRefresh util)
- Other mechanical changes, following the upgrade guide
- Remove some of the custom stuff in our Component base class
- Introduce "fragments" for non-components that control their own DOM
- Remove Mithril patches, introduce a few new ones (route attrs in <a>; 
- Redesign AlertManagerState `show` with 3 overloads: `show(children)`, `show(attrs, children)`, `show(componentClass, attrs, children)`
- The `affixedSidebar` util has been replaced with an `AffixedSidebar` component

Challenges:
- `children` and `tag` are now reserved, and can not be used as attr names
- Behavior of links to current page changed in Mithril. If moving to a page that is handled by the same component, the page component WILL NOT be re-initialized by default. Additional code to keep track of the current url is needed (See IndexPage, DiscussionPage, and UserPage for examples)
- Native Promise rejections are shown on console when not handled
- Instances of components can no longer be stored. The state pattern should be used instead.

Refs #1821.

Co-authored-by: Alexander Skvortsov <sasha.skvortsov109@gmail.com>
Co-authored-by: Matthew Kilgore <tankerkiller125@gmail.com>
Co-authored-by: Franz Liedke <franz@develophp.org>
2020-09-23 22:40:37 -04:00
Alexander Skvortsov
1321b8cc28 Revert "Use lifecycle interface for frontend extender (#2211)" (#2301)
This reverts commit 3117d2ad7a.
2020-09-23 00:21:45 -04:00
293 changed files with 9639 additions and 2235 deletions

2
.gitattributes vendored
View File

@@ -11,3 +11,5 @@ phpunit.xml export-ignore
tests export-ignore tests export-ignore
js/dist/* -diff js/dist/* -diff
* text=auto eol=lf

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ Thumbs.db
/tests/integration/tmp /tests/integration/tmp
.vagrant .vagrant
.idea/* .idea/*
.vscode

View File

@@ -1,5 +1,170 @@
# Changelog # Changelog
## [0.1.0-beta.15](https://github.com/flarum/core/compare/v0.1.0-beta.14.1...v0.1.0-beta.15)
### Added
- Slug drivers support (https://github.com/flarum/core/pull/2456).
- Notification type extender (https://github.com/flarum/core/pull/2424).
- Validation extender (https://github.com/flarum/core/pull/2102).
- Post extender (https://github.com/flarum/core/pull/2101).
- Notification channel extender (https://github.com/flarum/core/pull/2432).
- Service provider extender (https://github.com/flarum/core/pull/2437).
- API serializer extender (https://github.com/flarum/core/pull/2438).
- User preferences extender (https://github.com/flarum/core/pull/2463).
- Settings extender (https://github.com/flarum/core/pull/2452).
- ApiController extender (https://github.com/flarum/core/pull/2451).
- Model visibility extender (https://github.com/flarum/core/pull/2460).
- Policy extender (https://github.com/flarum/core/pull/2461).
### Changed
- Time helpers converted to Typescript (https://github.com/flarum/core/pull/2391).
- Improved the formatter extender (https://github.com/flarum/core/pull/2098).
- Improve wording on installer when facing file permission issues (https://github.com/flarum/core/pull/2435).
- Background color of checkbox toggles improved for better usability (https://github.com/flarum/core/pull/2443).
- Route resolving refactored (https://github.com/flarum/core/pull/2425).
- Administration panel UX refactored (https://github.com/flarum/core/pull/2409).
- Floodgate moved to middleware and extender added (https://github.com/flarum/core/pull/2170).
- DRY up image uploading logic (https://github.com/flarum/core/pull/2477).
- Process isolation on testing (https://github.com/flarum/core/commit/984f751c718c89501cc09857bc271efa2c7eea8c).
- Forum and admin javascript exports namespaced (https://github.com/flarum/core/pull/2488).
### Fixed
- Web updater does not take into account subfolder installations (https://github.com/flarum/core/pull/2426).
- Callables handling in extenders failed (https://github.com/flarum/core/pull/2423).
- Scrolling on mobile from PostSteam changes didn't work correctly (https://github.com/flarum/core/pull/2385).
- Side pane covers part of the discussion page due to `app.discussions` being empty (https://github.com/flarum/core/commit/102e76b084bf47fdfb4c73f95e1fbb322537f7aa).
- Change email modal keeps showing the previous error message even on success (https://github.com/flarum/core/pull/2467).
- Comment count not updated when discussions are deleted (https://github.com/flarum/core/pull/2472).
- `goToIndex` in PostStream does not trigger an xhr to retrieve new data (https://github.com/flarum/core/commit/09e2736cbcc267594b660beabbd001d9030f9880).
- On refresh the post number is reduced by one (https://github.com/flarum/core/pull/2476).
- Queue worker would instantiate a new Queue factory, not the bound one (https://github.com/flarum/core/pull/2481).
- Header accidentally has a border bottom (https://github.com/flarum/core/pull/2489).
- Namespace mentioned in docblock is incorrect (https://github.com/flarum/core/pull/2494).
- Scrolling inside longer discussions (especially Firefox) skips posts (https://github.com/flarum/core/commit/210a6b3e253d7917bd1eacd3ed8d2f95073ae99d).
- Uploading avatars that are jpg/jpeg fails with a validation error (https://github.com/flarum/core/pull/2497).
### Removed
- MomentJS alias (https://github.com/flarum/core/pull/2428).
- Deprecated user events `GetDisplayName` and `PrepareUserGroups` (https://github.com/flarum/core/pull/2428).
- AssertPermissionTrait (https://github.com/flarum/core/pull/2428).
- Path related helpers and methods in Application (https://github.com/flarum/core/pull/2428).
- Backward compatibility layers from the frontend rewrite (https://github.com/flarum/core/pull/2428).
### Deprecated
- `CheckingForFlooding` (https://github.com/flarum/core/commit/8e25bcb68f86cc992c46dfa70368419fe9f936ac).
## [0.1.0-beta.14.1](https://github.com/flarum/core/compare/v0.1.0-beta.14...v0.1.0-beta.14.1)
### Fixed
- SuperTextarea component is not exported.
- Symfony dependencies do not match those depended on by Laravel (https://github.com/flarum/core/pull/2407).
- Scripts from textformatter aren't executed (https://github.com/flarum/core/pull/2415)
- Sub path installations have no page title.
- Losing focus of Composer area when coming from fullscreen.
## [0.1.0-beta.14](https://github.com/flarum/core/compare/v0.1.0-beta.13...v0.1.0-beta.14)
### Added
- Check dependencies before enabling / disabling extensions (https://github.com/flarum/core/pull/2188)
- Set up temporary infrastructure for TypeScript in core (https://github.com/flarum/core/pull/2206)
- Better UI for request error modals (https://github.com/flarum/core/pull/1929)
- Display name extender, tests, frontend UI (https://github.com/flarum/core/pull/2174)
- Scroll to post or show alert when editing a post from another page (https://github.com/flarum/core/pull/2108)
- Feature to test email config by sending an email to the current user (https://github.com/flarum/core/pull/2023)
- Allow searching users by group ID using the group gambit (https://github.com/flarum/core/pull/2192)
- Use `liveHumanTimes` helper to update times without reload/rerender (https://github.com/flarum/core/pull/2208)
- View extender, tests (https://github.com/flarum/core/pull/2134)
- User extender to replace `PrepareUserGroups` (https://github.com/flarum/core/pull/2110)
- Increase extensibility of skeleton PHP (https://github.com/flarum/core/pull/2308, https://github.com/flarum/core/pull/2318)
- Pass a translator instance to `getEmailSubject` in `MailableInterface` (https://github.com/flarum/core/pull/2244)
- Force LF line endings on windows (https://github.com/flarum/core/pull/2321)
- Add a `Link` component for internal and external links (https://github.com/flarum/core/pull/2315)
- `ConfirmDocumentUnload` component
- Error handler middleware can now be manipulated by the middleware extender
### Changed
- Update to Mithril 2 (https://github.com/flarum/core/pull/2255)
- Stop storing component instances (https://github.com/flarum/core/issues/1821, https://github.com/flarum/core/issues/2144)
- Update to Laravel 6.x (https://github.com/flarum/core/issues/2055)
- `Flarum\Foundation\Application` no longer implements `Illuminate\Contracts\Foundation\Application` (#2142)
- `Flarum\Foundation\Application` no longer inherits `Illuminate\Container\Container` (#2142)
- `paths` have been split off from `Flarum\Foundation\Application` into `Flarum\Foundation\Paths`, which can be injected where needed (#2142)
- `Flarum\User\Gate` no longer implements `Illuminate\Contracts\Auth\Access\Gate` (https://github.com/flarum/core/pull/2181)
- Improve Group Gambit performance (https://github.com/flarum/core/pull/2192)
- Switch to `dayjs` from `momentjs` (https://github.com/flarum/core/pull/2219)
- Don't create a `bio` column in `users` for new installations (https://github.com/flarum/core/pull/2215)
- Start converting core JS to TypeScript (https://github.com/flarum/core/pull/2207)
- Make Carbon an explicit dependency (https://github.com/flarum/core/commit/3b39c212e0fef7522e7d541a9214ff3817138d5d)
- Use Symfony's translator interface instead of Laravel's (https://github.com/flarum/core/pull/2243)
- Use newer versions of fontawesome (https://github.com/flarum/core/pull/2274)
- Use URL generator instead of `app()->url()` where possible (https://github.com/flarum/core/pull/2302)
- Move config from `config.php` into an injectable helper class (https://github.com/flarum/core/pull/2271)
- Use reserved TLD for bogus and test urls (https://github.com/flarum/core/commit/6860b24b70bd04544dde90e537ce021a5fc5a689)
- Replace `m.stream` with `flarum/utils/Stream` (https://github.com/flarum/core/pull/2316)
- Replace `affixedSidebar` util with `AffixedSidebar` component
- Replace `m.withAttr` with `flarum/utils/withAttr`
- Scroll Listener is now passive, performance improvement (https://github.com/flarum/core/pull/2387)
### Fixed
- `generate:migration` command for extensions (https://github.com/flarum/core/commit/443949f7b9d7558dbc1e0994cb898cbac59bec87)
- Container config for `UninstalledSite` (https://github.com/flarum/core/commit/ecdce44d555dd36a365fd472b2916e677ef173cf)
- Tooltip glitch on page chang (https://github.com/flarum/core/issues/2118)
- Using multiple extenders in tests (https://github.com/flarum/core/commit/c4f4f218bf4b175a30880b807f9ccb1a37a25330)
- Header glitch when opening modals (https://github.com/flarum/core/pull/2131)
- Ensure `SameSite` is explicitly set for cookies (https://github.com/flarum/core/pull/2159)
- Ensure `Flarum\User\Event\AvatarChanged` event is properly dispatched (https://github.com/flarum/core/pull/2197)
- Show correct error message on wrong password when changing email (https://github.com/flarum/core/pull/2171)
- Discussion unreadCount could be higher than commentCount if posts deleted (https://github.com/flarum/core/pull/2195)
- Don't show page title on the default route (https://github.com/flarum/core/pull/2047)
- Add page title to `All Discussions` page when it isn't the default route (https://github.com/flarum/core/pull/2047)
- Accept `'0'` as `false` for `flarum/components/Checkbox` (https://github.com/flarum/core/pull/2210)
- Fix PostStreamScrubber background (https://github.com/flarum/core/pull/2222)
- Test port on BaseUrl tests (https://github.com/flarum/core/pull/2226)
- `UrlGenerator` can now generate urls with optional parameters (https://github.com/flarum/core/pull/2246)
- Allow `less` to be compiled independently of Flarum (https://github.com/flarum/core/pull/2252)
- Use correct number abbreviation (https://github.com/flarum/core/pull/2261)
- Ensure avatar html uses alt tags for accessibility (https://github.com/flarum/core/pull/2269)
- Escape regex when searching (https://github.com/flarum/core/pull/2273)
- Remove unneeded semicolons inserted during JS compilation (https://github.com/flarum/core/pull/2280)
- Don't require a username/password for SMTP (https://github.com/flarum/core/pull/2287)
- Allow uppercase entries for SMTP encryption validation (https://github.com/flarum/core/pull/2289)
- Ensure that the right number of posts is returned from list posts API (https://github.com/flarum/core/pull/2291)
- Fix a variety of PostStream bugs (https://github.com/flarum/core/pull/2160, https://github.com/flarum/core/pull/2160)
- Sliding discussion glitch on mobile (https://github.com/flarum/core/pull/2324)
- Sliding discussion button in wrong place (https://github.com/flarum/core/pull/2330, https://github.com/flarum/core/pull/2383)
- Sliding discussion glitch on mobile (https://github.com/flarum/core/pull/2381)
- Fix PostStream for posts with top margins, and scrubber position when scrolling below posts (https://github.com/flarum/core/pull/2369)
### Removed
- `Flarum\Event\AbstractConfigureRoutes` event class
- `Flarum\Event\ConfigureApiRoutes` event class
- `Flarum\Event\ConfigureForumRoutes` event class
- `Flarum\Console\Event\Configuring` event class
- `Flarum\Event\ConfigureModelDates` event class
- `Flarum\Event\ConfigureLocales` event class
- `Flarum\Event\ConfigureModelDefaultAttributes` event class
- `Flarum\Event\GetModelRelationship` event class
- `Flarum\User\Event\BioChanged` event class
- `Flarum\Database\MigrationServiceProvider` moved into `Flarum\Database\DatabaseServiceProvider`
- Unused `admin/components/Widget` component (`admin/component/DashboardWidget` should be used instead)
- Mandrill mail driver (https://github.com/flarum/core/commit/bca833d3f1c34d45d95bf905902368a2753b8908)
### Deprecated
- `Flarum\User\Event\GetDisplayName` event class
- Global path helpers, `Flarum\Foundation\Application` path methods (https://github.com/flarum/core/pull/2155)
- `Flarum\User\AssertPermissionTrait` (https://github.com/flarum/core/pull/2044)
## [0.1.0-beta.13](https://github.com/flarum/core/compare/v0.1.0-beta.12...v0.1.0-beta.13) ## [0.1.0-beta.13](https://github.com/flarum/core/compare/v0.1.0-beta.12...v0.1.0-beta.13)
### Added ### Added

View File

@@ -1,12 +1,14 @@
<p align="center"><img src="https://flarum.org/img/logo.png"></p> <p align="center"><img src="https://flarum.org/assets/img/logo.png"></p>
<p align="center"> <p align="center">
<a href="https://travis-ci.org/flarum/core"><img src="https://travis-ci.org/flarum/core.svg" alt="Build Status"></a> <a href="https://github.com/flarum/core/actions?query=workflow%3ATests"><img src="https://github.com/flarum/core/workflows/Tests/badge.svg" alt="PHP Tests"></a>
<a href="https://packagist.org/packages/flarum/core"><img src="https://poser.pugx.org/flarum/core/d/total.svg" alt="Total Downloads"></a> <a href="https://packagist.org/packages/flarum/core"><img src="https://img.shields.io/packagist/dt/flarum/core" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/flarum/core"><img src="https://poser.pugx.org/flarum/core/v/stable.svg" alt="Latest Stable Version"></a> <a href="https://packagist.org/packages/flarum/core"><img src="https://img.shields.io/github/v/release/flarum/core?sort=semver" alt="Latest Version"></a>
<a href="https://packagist.org/packages/flarum/core"><img src="https://poser.pugx.org/flarum/core/license.svg" alt="License"></a> <a href="https://packagist.org/packages/flarum/core"><img src="https://img.shields.io/packagist/l/flarum/core" alt="License"></a>
<a href="https://github.styleci.io/repos/28257573"><img src="https://github.styleci.io/repos/28257573/shield?style=flat" alt="StyleCI"></a>
</p> </p>
## About Flarum ## About Flarum
**[Flarum](https://flarum.org/) is a delightfully simple discussion platform for your website.** It's fast and easy to use, with all the features you need to run a successful community. It is designed to be: **[Flarum](https://flarum.org/) is a delightfully simple discussion platform for your website.** It's fast and easy to use, with all the features you need to run a successful community. It is designed to be:
@@ -32,4 +34,3 @@ If you discover a security vulnerability within Flarum, please send an e-mail to
## License ## License
Flarum is open-source software licensed under the [MIT License](https://github.com/flarum/flarum/blob/master/LICENSE). Flarum is open-source software licensed under the [MIT License](https://github.com/flarum/flarum/blob/master/LICENSE).

View File

@@ -10,7 +10,7 @@
"email": "franz@develophp.org" "email": "franz@develophp.org"
}, },
{ {
"name": "Daniel Klabbers", "name": "Daniël Klabbers",
"email": "daniel@klabbers.email", "email": "daniel@klabbers.email",
"homepage": "https://luceos.com" "homepage": "https://luceos.com"
}, },
@@ -27,6 +27,10 @@
{ {
"name": "Matthew Kilgore", "name": "Matthew Kilgore",
"email": "matthew@kilgore.dev" "email": "matthew@kilgore.dev"
},
{
"name": "Alexander (Sasha) Skvortsov",
"email": "askvortsov@flarum.org"
} }
], ],
"support": { "support": {
@@ -72,11 +76,12 @@
"psr/http-server-handler": "^1.0", "psr/http-server-handler": "^1.0",
"psr/http-server-middleware": "^1.0", "psr/http-server-middleware": "^1.0",
"s9e/text-formatter": "^2.3.6", "s9e/text-formatter": "^2.3.6",
"symfony/config": "^3.3", "symfony/config": "^4.3.4",
"symfony/console": "^4.2", "symfony/console": "^4.3.4",
"symfony/event-dispatcher": "^4.3.2", "symfony/event-dispatcher": "^4.3.4",
"symfony/translation": "^3.3", "symfony/mime": "^5.2.0",
"symfony/yaml": "^3.3", "symfony/translation": "^4.3.4",
"symfony/yaml": "^4.3.4",
"tobscure/json-api": "^0.3.0", "tobscure/json-api": "^0.3.0",
"wikimedia/less.php": "^3.0" "wikimedia/less.php": "^3.0"
}, },

14
js/dist/admin.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

16
js/dist/forum.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

33
js/package-lock.json generated
View File

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

View File

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

20
js/shims.d.ts vendored
View File

@@ -1,6 +1,5 @@
// Mithril // Mithril
import * as Mithril from 'mithril'; import Mithril from 'mithril';
import Stream from 'mithril/stream';
// Other third-party libs // Other third-party libs
import * as _dayjs from 'dayjs'; import * as _dayjs from 'dayjs';
@@ -9,21 +8,6 @@ import * as _$ from 'jquery';
// Globals from flarum/core // Globals from flarum/core
import Application from './src/common/Application'; import Application from './src/common/Application';
/**
* Helpers that flarum/core patches into Mithril
*/
interface m extends Mithril.Static {
prop: typeof Stream;
}
/**
* Export Mithril typings globally.
*
* This lets us use these typings without an extra import everywhere we use
* Mithril in a TypeScript file.
*/
export as namespace Mithril;
/** /**
* flarum/core exposes several extensions globally: * flarum/core exposes several extensions globally:
* *
@@ -36,7 +20,7 @@ export as namespace Mithril;
*/ */
declare global { declare global {
const $: typeof _$; const $: typeof _$;
const m: m; const m: Mithril.Static;
const dayjs: typeof _dayjs; const dayjs: typeof _dayjs;
} }

View File

@@ -1,13 +1,29 @@
import HeaderPrimary from './components/HeaderPrimary'; import HeaderPrimary from './components/HeaderPrimary';
import HeaderSecondary from './components/HeaderSecondary'; import HeaderSecondary from './components/HeaderSecondary';
import routes from './routes'; import routes from './routes';
import ExtensionPage from './components/ExtensionPage';
import Application from '../common/Application'; import Application from '../common/Application';
import Navigation from '../common/components/Navigation'; import Navigation from '../common/components/Navigation';
import AdminNav from './components/AdminNav'; import AdminNav from './components/AdminNav';
import ExtensionData from './utils/ExtensionData';
export default class AdminApplication extends Application { export default class AdminApplication extends Application {
// Deprecated as of beta 15
extensionSettings = {}; extensionSettings = {};
extensionData = new ExtensionData();
extensionCategories = {
discussion: 70,
moderation: 60,
feature: 50,
formatting: 40,
theme: 30,
authentication: 20,
language: 10,
other: 0,
};
history = { history = {
canGoBack: () => true, canGoBack: () => true,
getPrevious: () => {}, getPrevious: () => {},
@@ -27,24 +43,29 @@ export default class AdminApplication extends Application {
* @inheritdoc * @inheritdoc
*/ */
mount() { mount() {
m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
m.mount(document.getElementById('header-navigation'), Navigation);
m.mount(document.getElementById('header-primary'), HeaderPrimary);
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
m.mount(document.getElementById('admin-navigation'), AdminNav);
// Mithril does not render the home route on https://example.com/admin, so // Mithril does not render the home route on https://example.com/admin, so
// we need to go to https://example.com/admin#/ explicitly. // we need to go to https://example.com/admin#/ explicitly.
if (!document.location.hash) document.location.hash = '#/'; if (!document.location.hash) document.location.hash = '#/';
m.route.prefix = '#'; m.route.prefix = '#';
super.mount(); super.mount();
m.mount(document.getElementById('app-navigation'), {
view: () =>
Navigation.component({
className: 'App-backControl',
drawer: true,
}),
});
m.mount(document.getElementById('header-navigation'), Navigation);
m.mount(document.getElementById('header-primary'), HeaderPrimary);
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
m.mount(document.getElementById('admin-navigation'), AdminNav);
// If an extension has just been enabled, then we will run its settings // If an extension has just been enabled, then we will run its settings
// callback. // callback.
const enabled = localStorage.getItem('enabledExtension'); const enabled = localStorage.getItem('enabledExtension');
if (enabled && this.extensionSettings[enabled]) { if (enabled && this.extensionSettings[enabled] && typeof this.extensionSettings[enabled] === 'function') {
this.extensionSettings[enabled](); this.extensionSettings[enabled]();
localStorage.removeItem('enabledExtension'); localStorage.removeItem('enabledExtension');
} }

View File

@@ -1,19 +1,24 @@
import compat from '../common/compat'; import compat from '../common/compat';
import saveSettings from './utils/saveSettings'; import saveSettings from './utils/saveSettings';
import ExtensionData from './utils/ExtensionData';
import isExtensionEnabled from './utils/isExtensionEnabled';
import getCategorizedExtensions from './utils/getCategorizedExtensions';
import SettingDropdown from './components/SettingDropdown'; import SettingDropdown from './components/SettingDropdown';
import EditCustomFooterModal from './components/EditCustomFooterModal'; import EditCustomFooterModal from './components/EditCustomFooterModal';
import SessionDropdown from './components/SessionDropdown'; import SessionDropdown from './components/SessionDropdown';
import HeaderPrimary from './components/HeaderPrimary'; import HeaderPrimary from './components/HeaderPrimary';
import AppearancePage from './components/AppearancePage'; import AppearancePage from './components/AppearancePage';
import StatusWidget from './components/StatusWidget'; import StatusWidget from './components/StatusWidget';
import ExtensionsWidget from './components/ExtensionsWidget';
import HeaderSecondary from './components/HeaderSecondary'; import HeaderSecondary from './components/HeaderSecondary';
import SettingsModal from './components/SettingsModal'; import SettingsModal from './components/SettingsModal';
import DashboardWidget from './components/DashboardWidget'; import DashboardWidget from './components/DashboardWidget';
import AddExtensionModal from './components/AddExtensionModal'; import ExtensionPage from './components/ExtensionPage';
import ExtensionsPage from './components/ExtensionsPage'; import ExtensionLinkButton from './components/ExtensionLinkButton';
import AdminLinkButton from './components/AdminLinkButton'; import AdminLinkButton from './components/AdminLinkButton';
import PermissionGrid from './components/PermissionGrid'; import PermissionGrid from './components/PermissionGrid';
import ExtensionPermissionGrid from './components/ExtensionPermissionGrid';
import MailPage from './components/MailPage'; import MailPage from './components/MailPage';
import UploadImageButton from './components/UploadImageButton'; import UploadImageButton from './components/UploadImageButton';
import LoadingModal from './components/LoadingModal'; import LoadingModal from './components/LoadingModal';
@@ -23,6 +28,7 @@ import EditCustomHeaderModal from './components/EditCustomHeaderModal';
import PermissionsPage from './components/PermissionsPage'; import PermissionsPage from './components/PermissionsPage';
import PermissionDropdown from './components/PermissionDropdown'; import PermissionDropdown from './components/PermissionDropdown';
import AdminNav from './components/AdminNav'; import AdminNav from './components/AdminNav';
import AdminHeader from './components/AdminHeader';
import EditCustomCssModal from './components/EditCustomCssModal'; import EditCustomCssModal from './components/EditCustomCssModal';
import EditGroupModal from './components/EditGroupModal'; import EditGroupModal from './components/EditGroupModal';
import routes from './routes'; import routes from './routes';
@@ -30,19 +36,24 @@ import AdminApplication from './AdminApplication';
export default Object.assign(compat, { export default Object.assign(compat, {
'utils/saveSettings': saveSettings, 'utils/saveSettings': saveSettings,
'utils/ExtensionData': ExtensionData,
'utils/isExtensionEnabled': isExtensionEnabled,
'utils/getCategorizedExtensions': getCategorizedExtensions,
'components/SettingDropdown': SettingDropdown, 'components/SettingDropdown': SettingDropdown,
'components/EditCustomFooterModal': EditCustomFooterModal, 'components/EditCustomFooterModal': EditCustomFooterModal,
'components/SessionDropdown': SessionDropdown, 'components/SessionDropdown': SessionDropdown,
'components/HeaderPrimary': HeaderPrimary, 'components/HeaderPrimary': HeaderPrimary,
'components/AppearancePage': AppearancePage, 'components/AppearancePage': AppearancePage,
'components/StatusWidget': StatusWidget, 'components/StatusWidget': StatusWidget,
'components/ExtensionsWidget': ExtensionsWidget,
'components/HeaderSecondary': HeaderSecondary, 'components/HeaderSecondary': HeaderSecondary,
'components/SettingsModal': SettingsModal, 'components/SettingsModal': SettingsModal,
'components/DashboardWidget': DashboardWidget, 'components/DashboardWidget': DashboardWidget,
'components/AddExtensionModal': AddExtensionModal, 'components/ExtensionPage': ExtensionPage,
'components/ExtensionsPage': ExtensionsPage, 'components/ExtensionLinkButton': ExtensionLinkButton,
'components/AdminLinkButton': AdminLinkButton, 'components/AdminLinkButton': AdminLinkButton,
'components/PermissionGrid': PermissionGrid, 'components/PermissionGrid': PermissionGrid,
'components/ExtensionPermissionGrid': ExtensionPermissionGrid,
'components/MailPage': MailPage, 'components/MailPage': MailPage,
'components/UploadImageButton': UploadImageButton, 'components/UploadImageButton': UploadImageButton,
'components/LoadingModal': LoadingModal, 'components/LoadingModal': LoadingModal,
@@ -52,6 +63,7 @@ export default Object.assign(compat, {
'components/PermissionsPage': PermissionsPage, 'components/PermissionsPage': PermissionsPage,
'components/PermissionDropdown': PermissionDropdown, 'components/PermissionDropdown': PermissionDropdown,
'components/AdminNav': AdminNav, 'components/AdminNav': AdminNav,
'components/AdminHeader': AdminHeader,
'components/EditCustomCssModal': EditCustomCssModal, 'components/EditCustomCssModal': EditCustomCssModal,
'components/EditGroupModal': EditGroupModal, 'components/EditGroupModal': EditGroupModal,
routes: routes, routes: routes,

View File

@@ -0,0 +1,19 @@
import Component from '../../common/Component';
import classList from '../../common/utils/classList';
import icon from '../../common/helpers/icon';
export default class AdminHeader extends Component {
view(vnode) {
return [
<div className={classList(['AdminHeader', this.attrs.className])}>
<div className="container">
<h2>
{icon(this.attrs.icon)}
{vnode.children}
</h2>
<div className="AdminHeader-description">{this.attrs.description}</div>
</div>
</div>,
];
}
}

View File

@@ -1,106 +1,150 @@
/* import ExtensionLinkButton from './ExtensionLinkButton';
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import Component from '../../common/Component'; import Component from '../../common/Component';
import AdminLinkButton from './AdminLinkButton'; import LinkButton from '../../common/components/LinkButton';
import SelectDropdown from '../../common/components/SelectDropdown'; import SelectDropdown from '../../common/components/SelectDropdown';
import getCategorizedExtensions from '../utils/getCategorizedExtensions';
import ItemList from '../../common/utils/ItemList'; import ItemList from '../../common/utils/ItemList';
import Stream from '../../common/utils/Stream';
export default class AdminNav extends Component { export default class AdminNav extends Component {
oninit(vnode) {
super.oninit(vnode);
this.query = Stream('');
}
view() { view() {
return ( return (
<SelectDropdown className="AdminNav App-titleControl" buttonClassName="Button"> <SelectDropdown className="AdminNav App-titleControl AdminNav-Main" buttonClassName="Button">
{this.items().toArray()} {this.items().toArray().concat(this.extensionItems().toArray())}
</SelectDropdown> </SelectDropdown>
); );
} }
oncreate(vnode) {
super.oncreate(vnode);
this.scrollToActive();
}
onupdate() {
this.scrollToActive();
}
scrollToActive() {
const children = $('.Dropdown-menu').children('.active');
const nav = $('#admin-navigation');
const time = app.previous.type ? 250 : 0;
if (
children.length > 0 &&
(children[0].offsetTop > nav.scrollTop() + nav.outerHeight() || children[0].offsetTop + children[0].offsetHeight < nav.scrollTop())
) {
nav.animate(
{
scrollTop: children[0].offsetTop - nav.height() / 2,
},
time
);
}
}
/** /**
* Build an item list of links to show in the admin navigation. * Build an item list of main links to show in the admin navigation.
* *
* @return {ItemList} * @return {ItemList}
*/ */
items() { items() {
const items = new ItemList(); const items = new ItemList();
items.add('category-core', <h4 className="ExtensionListTitle">{app.translator.trans('core.admin.nav.categories.core')}</h4>);
items.add( items.add(
'dashboard', 'dashboard',
AdminLinkButton.component( <LinkButton href={app.route('dashboard')} icon="far fa-chart-bar" title={app.translator.trans('core.admin.nav.dashboard_title')}>
{ {app.translator.trans('core.admin.nav.dashboard_button')}
href: app.route('dashboard'), </LinkButton>
icon: 'far fa-chart-bar',
description: app.translator.trans('core.admin.nav.dashboard_text'),
},
app.translator.trans('core.admin.nav.dashboard_button')
)
); );
items.add( items.add(
'basics', 'basics',
AdminLinkButton.component( <LinkButton href={app.route('basics')} icon="fas fa-pencil-alt" title={app.translator.trans('core.admin.nav.basics_title')}>
{ {app.translator.trans('core.admin.nav.basics_button')}
href: app.route('basics'), </LinkButton>
icon: 'fas fa-pencil-alt',
description: app.translator.trans('core.admin.nav.basics_text'),
},
app.translator.trans('core.admin.nav.basics_button')
)
); );
items.add( items.add(
'mail', 'mail',
AdminLinkButton.component( <LinkButton href={app.route('mail')} icon="fas fa-envelope" title={app.translator.trans('core.admin.nav.email_title')}>
{ {app.translator.trans('core.admin.nav.email_button')}
href: app.route('mail'), </LinkButton>
icon: 'fas fa-envelope',
description: app.translator.trans('core.admin.nav.email_text'),
},
app.translator.trans('core.admin.nav.email_button')
)
); );
items.add( items.add(
'permissions', 'permissions',
AdminLinkButton.component( <LinkButton href={app.route('permissions')} icon="fas fa-key" title={app.translator.trans('core.admin.nav.permissions_title')}>
{ {app.translator.trans('core.admin.nav.permissions_button')}
href: app.route('permissions'), </LinkButton>
icon: 'fas fa-key',
description: app.translator.trans('core.admin.nav.permissions_text'),
},
app.translator.trans('core.admin.nav.permissions_button')
)
); );
items.add( items.add(
'appearance', 'appearance',
AdminLinkButton.component( <LinkButton href={app.route('appearance')} icon="fas fa-paint-brush" title={app.translator.trans('core.admin.nav.appearance_title')}>
{ {app.translator.trans('core.admin.nav.appearance_button')}
href: app.route('appearance'), </LinkButton>
icon: 'fas fa-paint-brush',
description: app.translator.trans('core.admin.nav.appearance_text'),
},
app.translator.trans('core.admin.nav.appearance_button')
)
); );
items.add( items.add(
'extensions', 'search',
AdminLinkButton.component( <div className="Search-input">
{ <input
href: app.route('extensions'), className="FormControl SearchBar"
icon: 'fas fa-puzzle-piece', bidi={this.query}
description: app.translator.trans('core.admin.nav.extensions_text'), type="search"
}, placeholder={app.translator.trans('core.admin.nav.search_placeholder')}
app.translator.trans('core.admin.nav.extensions_button') />
) </div>
); );
return items; return items;
} }
extensionItems() {
const items = new ItemList();
const categorizedExtensions = getCategorizedExtensions();
const categories = app.extensionCategories;
Object.keys(categorizedExtensions).map((category) => {
if (!this.query()) {
items.add(
`category-${category}`,
<h4 className="ExtensionListTitle">{app.translator.trans(`core.admin.nav.categories.${category}`)}</h4>,
categories[category]
);
}
categorizedExtensions[category].map((extension) => {
const query = this.query().toUpperCase();
const title = extension.extra['flarum-extension'].title;
if (!query || title.toUpperCase().includes(query) || extension.description.toUpperCase().includes(query)) {
items.add(
`extension-${extension.id}`,
<ExtensionLinkButton
href={app.route('extension', { id: extension.id })}
extensionId={extension.id}
className="ExtensionNavButton"
title={extension.description}
>
{title}
</ExtensionLinkButton>,
categories[category]
);
}
});
});
return items;
}
} }

View File

@@ -1,26 +1,34 @@
import Page from '../../common/components/Page'; import Page from '../../common/components/Page';
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
import Switch from '../../common/components/Switch'; import Switch from '../../common/components/Switch';
import Stream from '../../common/utils/Stream';
import EditCustomCssModal from './EditCustomCssModal'; import EditCustomCssModal from './EditCustomCssModal';
import EditCustomHeaderModal from './EditCustomHeaderModal'; import EditCustomHeaderModal from './EditCustomHeaderModal';
import EditCustomFooterModal from './EditCustomFooterModal'; import EditCustomFooterModal from './EditCustomFooterModal';
import UploadImageButton from './UploadImageButton'; import UploadImageButton from './UploadImageButton';
import saveSettings from '../utils/saveSettings'; import saveSettings from '../utils/saveSettings';
import withAttr from '../../common/utils/withAttr'; import AdminHeader from './AdminHeader';
export default class AppearancePage extends Page { export default class AppearancePage extends Page {
oninit(vnode) { oninit(vnode) {
super.oninit(vnode); super.oninit(vnode);
this.primaryColor = m.stream(app.data.settings.theme_primary_color); this.primaryColor = Stream(app.data.settings.theme_primary_color);
this.secondaryColor = m.stream(app.data.settings.theme_secondary_color); this.secondaryColor = Stream(app.data.settings.theme_secondary_color);
this.darkMode = m.stream(app.data.settings.theme_dark_mode); this.darkMode = Stream(app.data.settings.theme_dark_mode);
this.coloredHeader = m.stream(app.data.settings.theme_colored_header); this.coloredHeader = Stream(app.data.settings.theme_colored_header);
} }
view() { view() {
return ( return (
<div className="AppearancePage"> <div className="AppearancePage">
<AdminHeader
icon="fas fa-paint-brush"
description={app.translator.trans('core.admin.appearance.description')}
className="AppearancePage-header"
>
{app.translator.trans('core.admin.appearance.title')}
</AdminHeader>
<div className="container"> <div className="container">
<form onsubmit={this.onsubmit.bind(this)}> <form onsubmit={this.onsubmit.bind(this)}>
<fieldset className="AppearancePage-colors"> <fieldset className="AppearancePage-colors">

View File

@@ -5,7 +5,9 @@ import Button from '../../common/components/Button';
import saveSettings from '../utils/saveSettings'; import saveSettings from '../utils/saveSettings';
import ItemList from '../../common/utils/ItemList'; import ItemList from '../../common/utils/ItemList';
import Switch from '../../common/components/Switch'; import Switch from '../../common/components/Switch';
import Stream from '../../common/utils/Stream';
import withAttr from '../../common/utils/withAttr'; import withAttr from '../../common/utils/withAttr';
import AdminHeader from './AdminHeader';
export default class BasicsPage extends Page { export default class BasicsPage extends Page {
oninit(vnode) { oninit(vnode) {
@@ -23,10 +25,6 @@ export default class BasicsPage extends Page {
'welcome_message', 'welcome_message',
'display_name_driver', 'display_name_driver',
]; ];
this.values = {};
const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = m.stream(settings[key])));
this.localeOptions = {}; this.localeOptions = {};
const locales = app.data.locales; const locales = app.data.locales;
@@ -40,14 +38,38 @@ export default class BasicsPage extends Page {
this.displayNameOptions[identifier] = identifier; this.displayNameOptions[identifier] = identifier;
}, this); }, this);
this.slugDriverOptions = {};
Object.keys(app.data.slugDrivers).forEach((model) => {
this.fields.push(`slug_driver_${model}`);
this.slugDriverOptions[model] = {};
app.data.slugDrivers[model].forEach((option) => {
this.slugDriverOptions[model][option] = option;
});
});
this.values = {};
const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = Stream(settings[key])));
if (!this.values.display_name_driver() && displayNameDrivers.includes('username')) this.values.display_name_driver('username'); if (!this.values.display_name_driver() && displayNameDrivers.includes('username')) this.values.display_name_driver('username');
Object.keys(app.data.slugDrivers).forEach((model) => {
if (!this.values[`slug_driver_${model}`]() && 'default' in this.slugDriverOptions[model]) {
this.values[`slug_driver_${model}`]('default');
}
});
if (typeof this.values.show_language_selector() !== 'number') this.values.show_language_selector(1); if (typeof this.values.show_language_selector() !== 'number') this.values.show_language_selector(1);
} }
view() { view() {
return ( return (
<div className="BasicsPage"> <div className="BasicsPage">
<AdminHeader icon="fas fa-pencil-alt" description={app.translator.trans('core.admin.basics.description')} className="BasicsPage-header">
{app.translator.trans('core.admin.basics.title')}
</AdminHeader>
<div className="container"> <div className="container">
<form onsubmit={this.onsubmit.bind(this)}> <form onsubmit={this.onsubmit.bind(this)}>
{FieldSet.component( {FieldSet.component(
@@ -127,20 +149,30 @@ export default class BasicsPage extends Page {
] ]
)} )}
{Object.keys(this.displayNameOptions).length > 1 {Object.keys(this.displayNameOptions).length > 1 ? (
? FieldSet.component( <FieldSet label={app.translator.trans('core.admin.basics.display_name_heading')}>
{ <div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>
label: app.translator.trans('core.admin.basics.display_name_heading'), <Select
}, options={this.displayNameOptions}
[ value={this.values.display_name_driver()}
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>, onchange={this.values.display_name_driver}
Select.component({ ></Select>
options: this.displayNameOptions, </FieldSet>
bidi: this.values.display_name_driver, ) : (
}), ''
] )}
)
: ''} {Object.keys(this.slugDriverOptions).map((model) => {
const options = this.slugDriverOptions[model];
if (Object.keys(options).length > 1) {
return (
<FieldSet label={app.translator.trans('core.admin.basics.slug_driver_heading', { model })}>
<div className="helpText">{app.translator.trans('core.admin.basics.slug_driver_text', { model })}</div>
<Select options={options} value={this.values[`slug_driver_${model}`]()} onchange={this.values[`slug_driver_${model}`]}></Select>
</FieldSet>
);
}
})}
{Button.component( {Button.component(
{ {
@@ -193,9 +225,7 @@ export default class BasicsPage extends Page {
saveSettings(settings) saveSettings(settings)
.then(() => { .then(() => {
this.successAlert = app.alerts.show(app.translator.trans('core.admin.basics.saved_message'), { this.successAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.basics.saved_message'));
type: 'success',
});
}) })
.catch(() => {}) .catch(() => {})
.then(() => { .then(() => {

View File

@@ -1,16 +1,29 @@
import Page from '../../common/components/Page'; import Page from '../../common/components/Page';
import StatusWidget from './StatusWidget'; import StatusWidget from './StatusWidget';
import ExtensionsWidget from './ExtensionsWidget';
import AdminHeader from './AdminHeader';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
export default class DashboardPage extends Page { export default class DashboardPage extends Page {
view() { view() {
return ( return (
<div className="DashboardPage"> <div className="DashboardPage">
<div className="container">{this.availableWidgets()}</div> <AdminHeader icon="fas fa-chart-bar" description={app.translator.trans('core.admin.dashboard.description')} className="DashboardPage-header">
{app.translator.trans('core.admin.dashboard.title')}
</AdminHeader>
<div className="container">{this.availableWidgets().toArray()}</div>
</div> </div>
); );
} }
availableWidgets() { availableWidgets() {
return [<StatusWidget />]; const items = new ItemList();
items.add('status', <StatusWidget />, 30);
items.add('extensions', <ExtensionsWidget />, 10);
return items;
} }
} }

View File

@@ -4,7 +4,7 @@ import Badge from '../../common/components/Badge';
import Group from '../../common/models/Group'; import Group from '../../common/models/Group';
import ItemList from '../../common/utils/ItemList'; import ItemList from '../../common/utils/ItemList';
import Switch from '../../common/components/Switch'; import Switch from '../../common/components/Switch';
import withAttr from '../../common/utils/withAttr'; import Stream from '../../common/utils/Stream';
/** /**
* The `EditGroupModal` component shows a modal dialog which allows the user * The `EditGroupModal` component shows a modal dialog which allows the user
@@ -16,11 +16,11 @@ export default class EditGroupModal extends Modal {
this.group = this.attrs.group || app.store.createRecord('groups'); this.group = this.attrs.group || app.store.createRecord('groups');
this.nameSingular = m.stream(this.group.nameSingular() || ''); this.nameSingular = Stream(this.group.nameSingular() || '');
this.namePlural = m.stream(this.group.namePlural() || ''); this.namePlural = Stream(this.group.namePlural() || '');
this.icon = m.stream(this.group.icon() || ''); this.icon = Stream(this.group.icon() || '');
this.color = m.stream(this.group.color() || ''); this.color = Stream(this.group.color() || '');
this.isHidden = m.stream(this.group.isHidden() || false); this.isHidden = Stream(this.group.isHidden() || false);
} }
className() { className() {

View File

@@ -0,0 +1,29 @@
import isExtensionEnabled from '../utils/isExtensionEnabled';
import LinkButton from '../../common/components/LinkButton';
import icon from '../../common/helpers/icon';
import ItemList from '../../common/utils/ItemList';
export default class ExtensionLinkButton extends LinkButton {
getButtonContent(children) {
const content = super.getButtonContent(children);
const extension = app.data.extensions[this.attrs.extensionId];
const statuses = this.statusItems(extension.id).toArray();
content.unshift(
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
{extension.icon ? icon(extension.icon.name) : ''}
</span>
);
content.push(statuses);
return content;
}
statusItems(name) {
const items = new ItemList();
items.add('enabled', <span class={'ExtensionListItem-Dot ' + (isExtensionEnabled(name) ? 'enabled' : 'disabled')} />);
return items;
}
}

View File

@@ -0,0 +1,363 @@
import Button from '../../common/components/Button';
import Link from '../../common/components/Link';
import LinkButton from '../../common/components/LinkButton';
import Page from '../../common/components/Page';
import Select from '../../common/components/Select';
import Switch from '../../common/components/Switch';
import icon from '../../common/helpers/icon';
import punctuateSeries from '../../common/helpers/punctuateSeries';
import listItems from '../../common/helpers/listItems';
import ItemList from '../../common/utils/ItemList';
import Stream from '../../common/utils/Stream';
import LoadingModal from './LoadingModal';
import ExtensionPermissionGrid from './ExtensionPermissionGrid';
import saveSettings from '../utils/saveSettings';
import isExtensionEnabled from '../utils/isExtensionEnabled';
export default class ExtensionPage extends Page {
oninit(vnode) {
super.oninit(vnode);
this.loading = false;
this.extension = app.data.extensions[this.attrs.id];
this.changingState = false;
this.settings = {};
this.infoFields = {
discuss: 'fas fa-comment-alt',
documentation: 'fas fa-book',
support: 'fas fa-life-ring',
website: 'fas fa-link',
donate: 'fas fa-donate',
source: 'fas fa-code',
};
// Backwards compatibility layer will be removed in
// Beta 16
if (app.extensionSettings[this.extension.id]) {
app.extensionData[this.extension.id] = app.extensionSettings[this.extension.id];
}
}
className() {
return this.extension.id + '-Page';
}
view() {
return (
<div className={'ExtensionPage ' + this.className()}>
{this.header()}
{!this.isEnabled() ? (
<div className="container">
<h3 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.enable_to_see')}</h3>
</div>
) : (
<div className="ExtensionPage-body">{this.sections().toArray()}</div>
)}
</div>
);
}
header() {
return [
<div className="ExtensionPage-header">
<div className="container">
<div className="ExtensionTitle">
<span className="ExtensionIcon" style={this.extension.icon}>
{this.extension.icon ? icon(this.extension.icon.name) : ''}
</span>
<div className="ExtensionName">
<h2>{this.extension.extra['flarum-extension'].title}</h2>
</div>
<div className="ExtensionPage-headerTopItems">
<ul>{listItems(this.topItems().toArray())}</ul>
</div>
</div>
<div className="helpText">{this.extension.description}</div>
<div className="ExtensionPage-headerItems">
<Switch state={this.isEnabled()} onchange={this.toggle.bind(this, this.extension.id)}>
{this.isEnabled(this.extension.id)
? app.translator.trans('core.admin.extension.enabled')
: app.translator.trans('core.admin.extension.disabled')}
</Switch>
<aside className="ExtensionInfo">
<ul>{listItems(this.infoItems().toArray())}</ul>
</aside>
</div>
</div>
</div>,
];
}
sections() {
const items = new ItemList();
items.add('content', this.content());
items.add('permissions', [
<div className="ExtensionPage-permissions">
<div className="ExtensionPage-permissions-header">
<div className="container">
<h2 className="ExtensionTitle">{app.translator.trans('core.admin.extension.permissions_title')}</h2>
</div>
</div>
<div className="container">
{app.extensionData.extensionHasPermissions(this.extension.id) ? (
ExtensionPermissionGrid.component({ extensionId: this.extension.id })
) : (
<h3 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_permissions')}</h3>
)}
</div>
</div>,
]);
return items;
}
content() {
const settings = app.extensionData.getSettings(this.extension.id);
return (
<div className="ExtensionPage-settings">
<div className="container">
{typeof app.extensionData[this.extension.id] === 'function' ? (
<Button onclick={app.extensionData[this.extension.id].bind(this)} className="Button Button--primary">
{app.translator.trans('core.admin.extension.open_modal')}
</Button>
) : settings ? (
<div className="Form">
{settings.map(this.buildSettingComponent.bind(this))}
<div className="Form-group">{this.submitButton()}</div>
</div>
) : (
<h3 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_settings')}</h3>
)}
</div>
</div>
);
}
topItems() {
const items = new ItemList();
items.add('version', <span className="ExtensionVersion">{this.extension.version}</span>);
if (!this.isEnabled()) {
const uninstall = () => {
if (confirm(app.translator.trans('core.admin.extension.confirm_uninstall'))) {
app
.request({
url: app.forum.attribute('apiUrl') + '/extensions/' + this.extension.id,
method: 'DELETE',
})
.then(() => window.location.reload());
app.modal.show(LoadingModal);
}
};
items.add(
'uninstall',
<Button icon="fas fa-trash-alt" className="Button Button--primary" onclick={uninstall.bind(this)}>
{app.translator.trans('core.admin.extension.uninstall_button')}
</Button>
);
}
return items;
}
infoItems() {
const items = new ItemList();
const links = this.extension.links;
if (links.authors.length) {
let authors = [];
links.authors.map((author) => {
authors.push(
<Link href={author.link} external={true} target="_blank">
{author.name}
</Link>
);
});
items.add('authors', [icon('fas fa-user'), <span>{punctuateSeries(authors)}</span>]);
}
Object.keys(this.infoFields).map((field) => {
if (links[field]) {
items.add(
field,
<LinkButton href={links[field]} icon={this.infoFields[field]} external={true} target="_blank">
{app.translator.trans(`core.admin.extension.info_links.${field}`)}
</LinkButton>
);
}
});
return items;
}
submitButton() {
return (
<Button onclick={this.saveSettings.bind(this)} className="Button Button--primary" loading={this.loading} disabled={!this.isChanged()}>
{app.translator.trans('core.admin.settings.submit_button')}
</Button>
);
}
/**
* getSetting takes a settings object and turns it into a component.
* Depending on the type of input, you can set the type to 'bool', 'select', or
* any standard <input> type.
*
* Alternatively, you can pass a callback that will be executed in ExtensionPage's
* context to include custom JSX elements.
*
* @example
*
* {
* setting: 'acme.checkbox',
* label: app.translator.trans('acme.admin.setting_label'),
* type: 'bool'
* }
*
* @example
*
* {
* setting: 'acme.select',
* label: app.translator.trans('acme.admin.setting_label'),
* type: 'select',
* options: {
* 'option1': 'Option 1 label',
* 'option2': 'Option 2 label',
* },
* default: 'option1',
* }
*
* @param setting
* @returns {JSX.Element}
*/
buildSettingComponent(entry) {
if (typeof entry === 'function') {
return entry.call(this);
}
const setting = entry.setting;
const value = this.setting([setting])();
if (['bool', 'checkbox', 'switch', 'boolean'].includes(entry.type)) {
return (
<div className="Form-group">
<Switch state={!!value && value !== '0'} onchange={this.settings[setting]}>
{entry.label}
</Switch>
</div>
);
} else if (['select', 'dropdown', 'selectdropdown'].includes(entry.type)) {
return (
<div className="Form-group">
<label>{entry.label}</label>
<Select value={value || entry.default} options={entry.options} buttonClassName="Button" onchange={this.settings[setting]} />
</div>
);
} else {
return (
<div className="Form-group">
<label>{entry.label}</label>
<input type={entry.type} className="FormControl" bidi={this.setting(setting)} />
</div>
);
}
}
toggle() {
const enabled = this.isEnabled();
this.changingState = true;
app
.request({
url: app.forum.attribute('apiUrl') + '/extensions/' + this.extension.id,
method: 'PATCH',
body: { enabled: !enabled },
errorHandler: this.onerror.bind(this),
})
.then(() => {
if (!enabled) localStorage.setItem('enabledExtension', this.extension.id);
window.location.reload();
});
app.modal.show(LoadingModal);
}
dirty() {
const dirty = {};
Object.keys(this.settings).forEach((key) => {
const value = this.settings[key]();
if (value !== app.data.settings[key]) {
dirty[key] = value;
}
});
return dirty;
}
isChanged() {
return Object.keys(this.dirty()).length;
}
saveSettings(e) {
e.preventDefault();
app.alerts.clear();
this.loading = true;
saveSettings(this.dirty()).then(this.onsaved.bind(this));
}
onsaved() {
this.loading = false;
app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.extension.saved_message'));
}
setting(key, fallback = '') {
this.settings[key] = this.settings[key] || Stream(app.data.settings[key] || fallback);
return this.settings[key];
}
isEnabled() {
let isEnabled = isExtensionEnabled(this.extension.id);
return this.changingState ? !isEnabled : isEnabled;
}
onerror(e) {
// We need to give the modal animation time to start; if we close the modal too early,
// it breaks the bootstrap modal library.
// TODO: This workaround should be removed when we move away from bootstrap JS for modals.
setTimeout(() => {
app.modal.close();
}, 300); // Bootstrap's Modal.TRANSITION_DURATION is 300 ms.
if (e.status !== 409) {
throw e;
}
const error = e.response.errors[0];
app.alerts.show(
{ type: 'error' },
app.translator.trans(`core.lib.error.${error.code}_message`, {
extension: error.extension,
extensions: error.extensions.join(', '),
})
);
}
}

View File

@@ -0,0 +1,39 @@
import PermissionGrid from './PermissionGrid';
import ItemList from '../../common/utils/ItemList';
export default class ExtensionPermissionGrid extends PermissionGrid {
oninit(vnode) {
super.oninit(vnode);
this.extensionId = this.attrs.extensionId;
}
permissionItems() {
const permissionCategories = super.permissionItems();
permissionCategories.items = Object.entries(permissionCategories.items)
.filter(([category, info]) => info.content.children.length > 0)
.reduce((obj, [category, info]) => {
obj[category] = info;
return obj;
}, {});
return permissionCategories;
}
viewItems() {
return app.extensionData.getExtensionPermissions(this.extensionId, 'view') || new ItemList();
}
startItems() {
return app.extensionData.getExtensionPermissions(this.extensionId, 'start') || new ItemList();
}
replyItems() {
return app.extensionData.getExtensionPermissions(this.extensionId, 'reply') || new ItemList();
}
moderateItems() {
return app.extensionData.getExtensionPermissions(this.extensionId, 'moderate') || new ItemList();
}
}

View File

@@ -1,134 +0,0 @@
import Page from '../../common/components/Page';
import Button from '../../common/components/Button';
import Dropdown from '../../common/components/Dropdown';
import AddExtensionModal from './AddExtensionModal';
import LoadingModal from './LoadingModal';
import ItemList from '../../common/utils/ItemList';
import icon from '../../common/helpers/icon';
export default class ExtensionsPage extends Page {
view() {
return (
<div className="ExtensionsPage">
<div className="ExtensionsPage-header">
<div className="container">
{Button.component(
{
icon: 'fas fa-plus',
className: 'Button Button--primary',
onclick: () => app.modal.show(AddExtensionModal),
},
app.translator.trans('core.admin.extensions.add_button')
)}
</div>
</div>
<div className="ExtensionsPage-list">
<div className="container">
<ul className="ExtensionList">
{Object.keys(app.data.extensions).map((id) => {
const extension = app.data.extensions[id];
const controls = this.controlItems(extension.id).toArray();
return (
<li className={'ExtensionListItem ' + (!this.isEnabled(extension.id) ? 'disabled' : '')}>
<div className="ExtensionListItem-content">
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
{extension.icon ? icon(extension.icon.name) : ''}
</span>
{controls.length ? (
<Dropdown
className="ExtensionListItem-controls"
buttonClassName="Button Button--icon Button--flat"
menuClassName="Dropdown-menu--right"
icon="fas fa-ellipsis-h"
>
{controls}
</Dropdown>
) : (
''
)}
<div className="ExtensionListItem-main">
<label className="ExtensionListItem-title">
<input type="checkbox" checked={this.isEnabled(extension.id)} onclick={this.toggle.bind(this, extension.id)} />{' '}
{extension.extra['flarum-extension'].title}
</label>
<div className="ExtensionListItem-version">{extension.version}</div>
<div className="ExtensionListItem-description">{extension.description}</div>
</div>
</div>
</li>
);
})}
</ul>
</div>
</div>
</div>
);
}
controlItems(name) {
const items = new ItemList();
const enabled = this.isEnabled(name);
if (app.extensionSettings[name]) {
items.add(
'settings',
Button.component(
{
icon: 'fas fa-cog',
onclick: app.extensionSettings[name],
},
app.translator.trans('core.admin.extensions.settings_button')
)
);
}
if (!enabled) {
items.add(
'uninstall',
Button.component(
{
icon: 'far fa-trash-alt',
onclick: () => {
app
.request({
url: app.forum.attribute('apiUrl') + '/extensions/' + name,
method: 'DELETE',
})
.then(() => window.location.reload());
app.modal.show(LoadingModal);
},
},
app.translator.trans('core.admin.extensions.uninstall_button')
)
);
}
return items;
}
isEnabled(name) {
const enabled = JSON.parse(app.data.settings.extensions_enabled);
return enabled.indexOf(name) !== -1;
}
toggle(id) {
const enabled = this.isEnabled(id);
app
.request({
url: app.forum.attribute('apiUrl') + '/extensions/' + id,
method: 'PATCH',
body: { enabled: !enabled },
})
.then(() => {
if (!enabled) localStorage.setItem('enabledExtension', id);
window.location.reload();
});
app.modal.show(LoadingModal);
}
}

View File

@@ -0,0 +1,46 @@
import DashboardWidget from './DashboardWidget';
import isExtensionEnabled from '../utils/isExtensionEnabled';
import getCategorizedExtensions from '../utils/getCategorizedExtensions';
import Link from '../../common/components/Link';
import icon from '../../common/helpers/icon';
export default class ExtensionsWidget extends DashboardWidget {
className() {
return 'ExtensionsWidget';
}
content() {
const categorizedExtensions = getCategorizedExtensions();
const categories = app.extensionCategories;
return (
<div className="ExtensionsWidget-list">
{Object.keys(categories).map((category) => {
if (categorizedExtensions[category]) {
return (
<div className="ExtensionList-Category">
<h4 className="ExtensionList-Label">{app.translator.trans(`core.admin.nav.categories.${category}`)}</h4>
<ul className="ExtensionList">
{categorizedExtensions[category].map((extension) => {
return (
<li className={'ExtensionListItem ' + (!isExtensionEnabled(extension.id) ? 'disabled' : '')}>
<Link href={app.route('extension', { id: extension.id })}>
<div className="ExtensionListItem-content">
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
{extension.icon ? icon(extension.icon.name) : ''}
</span>
<span className="ExtensionListItem-title">{extension.extra['flarum-extension'].title}</span>
</div>
</Link>
</li>
);
})}
</ul>
</div>
);
}
})}
</div>
);
}
}

View File

@@ -1,4 +1,5 @@
import Component from '../../common/Component'; import Component from '../../common/Component';
import LinkButton from '../../common/components/LinkButton';
import SessionDropdown from './SessionDropdown'; import SessionDropdown from './SessionDropdown';
import ItemList from '../../common/utils/ItemList'; import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems'; import listItems from '../../common/helpers/listItems';
@@ -19,6 +20,13 @@ export default class HeaderSecondary extends Component {
items() { items() {
const items = new ItemList(); const items = new ItemList();
items.add(
'help',
<LinkButton href="https://docs.flarum.org/troubleshoot.html" icon="fas fa-question-circle" external={true} target="_blank">
{app.translator.trans('core.admin.header.get_help')}
</LinkButton>
);
items.add('session', SessionDropdown.component()); items.add('session', SessionDropdown.component());
return items; return items;

View File

@@ -5,7 +5,9 @@ import Alert from '../../common/components/Alert';
import Select from '../../common/components/Select'; import Select from '../../common/components/Select';
import LoadingIndicator from '../../common/components/LoadingIndicator'; import LoadingIndicator from '../../common/components/LoadingIndicator';
import saveSettings from '../utils/saveSettings'; import saveSettings from '../utils/saveSettings';
import withAttr from '../../common/utils/withAttr'; import Stream from '../../common/utils/Stream';
import icon from '../../common/helpers/icon';
import AdminHeader from './AdminHeader';
export default class MailPage extends Page { export default class MailPage extends Page {
oninit(vnode) { oninit(vnode) {
@@ -25,7 +27,7 @@ export default class MailPage extends Page {
this.status = { sending: false, errors: {} }; this.status = { sending: false, errors: {} };
const settings = app.data.settings; const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = m.stream(settings[key]))); this.fields.forEach((key) => (this.values[key] = Stream(settings[key])));
app app
.request({ .request({
@@ -40,7 +42,7 @@ export default class MailPage extends Page {
for (const driver in this.driverFields) { for (const driver in this.driverFields) {
for (const field in this.driverFields[driver]) { for (const field in this.driverFields[driver]) {
this.fields.push(field); this.fields.push(field);
this.values[field] = m.stream(settings[field]); this.values[field] = Stream(settings[field]);
} }
} }
@@ -65,11 +67,11 @@ export default class MailPage extends Page {
return ( return (
<div className="MailPage"> <div className="MailPage">
<AdminHeader icon="fas fa-envelope" description={app.translator.trans('core.admin.email.description')} className="MailPage-header">
{app.translator.trans('core.admin.email.title')}
</AdminHeader>
<div className="container"> <div className="container">
<form onsubmit={this.onsubmit.bind(this)}> <form onsubmit={this.onsubmit.bind(this)}>
<h2>{app.translator.trans('core.admin.email.heading')}</h2>
<div className="helpText">{app.translator.trans('core.admin.email.text')}</div>
{FieldSet.component( {FieldSet.component(
{ {
label: app.translator.trans('core.admin.email.addresses_heading'), label: app.translator.trans('core.admin.email.addresses_heading'),
@@ -194,9 +196,7 @@ export default class MailPage extends Page {
}) })
.then((response) => { .then((response) => {
this.sendingTest = false; this.sendingTest = false;
this.testEmailSuccessAlert = app.alerts.show(app.translator.trans('core.admin.email.send_test_mail_success'), { this.testEmailSuccessAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.email.send_test_mail_success'));
type: 'success',
});
}) })
.catch((error) => { .catch((error) => {
this.sendingTest = false; this.sendingTest = false;
@@ -219,9 +219,7 @@ export default class MailPage extends Page {
saveSettings(settings) saveSettings(settings)
.then(() => { .then(() => {
this.successAlert = app.alerts.show(app.translator.trans('core.admin.basics.saved_message'), { this.successAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.basics.saved_message'));
type: 'success',
});
}) })
.catch(() => {}) .catch(() => {})
.then(() => { .then(() => {

View File

@@ -6,12 +6,6 @@ import ItemList from '../../common/utils/ItemList';
import icon from '../../common/helpers/icon'; import icon from '../../common/helpers/icon';
export default class PermissionGrid extends Component { export default class PermissionGrid extends Component {
oninit(vnode) {
super.oninit(vnode);
this.permissions = this.permissionItems().toArray();
}
view() { view() {
const scopes = this.scopeItems().toArray(); const scopes = this.scopeItems().toArray();
@@ -35,25 +29,27 @@ export default class PermissionGrid extends Component {
<th>{this.scopeControlItems().toArray()}</th> <th>{this.scopeControlItems().toArray()}</th>
</tr> </tr>
</thead> </thead>
{this.permissions.map((section) => ( {this.permissionItems()
<tbody> .toArray()
<tr className="PermissionGrid-section"> .map((section) => (
<th>{section.label}</th> <tbody>
{permissionCells(section)} <tr className="PermissionGrid-section">
<td /> <th>{section.label}</th>
</tr> {permissionCells(section)}
{section.children.map((child) => (
<tr className="PermissionGrid-child">
<th>
{icon(child.icon)}
{child.label}
</th>
{permissionCells(child)}
<td /> <td />
</tr> </tr>
))} {section.children.map((child) => (
</tbody> <tr className="PermissionGrid-child">
))} <th>
{icon(child.icon)}
{child.label}
</th>
{permissionCells(child)}
<td />
</tr>
))}
</tbody>
))}
</table> </table>
); );
} }
@@ -158,6 +154,8 @@ export default class PermissionGrid extends Component {
permission: 'user.viewLastSeenAt', permission: 'user.viewLastSeenAt',
}); });
items.merge(app.extensionData.getAllExtensionPermissions('view'));
return items; return items;
} }
@@ -198,6 +196,8 @@ export default class PermissionGrid extends Component {
90 90
); );
items.merge(app.extensionData.getAllExtensionPermissions('start'));
return items; return items;
} }
@@ -238,6 +238,8 @@ export default class PermissionGrid extends Component {
90 90
); );
items.merge(app.extensionData.getAllExtensionPermissions('reply'));
return items; return items;
} }
@@ -334,6 +336,8 @@ export default class PermissionGrid extends Component {
60 60
); );
items.merge(app.extensionData.getAllExtensionPermissions('moderate'));
return items; return items;
} }

View File

@@ -4,11 +4,15 @@ import EditGroupModal from './EditGroupModal';
import Group from '../../common/models/Group'; import Group from '../../common/models/Group';
import icon from '../../common/helpers/icon'; import icon from '../../common/helpers/icon';
import PermissionGrid from './PermissionGrid'; import PermissionGrid from './PermissionGrid';
import AdminHeader from './AdminHeader';
export default class PermissionsPage extends Page { export default class PermissionsPage extends Page {
view() { view() {
return ( return (
<div className="PermissionsPage"> <div className="PermissionsPage">
<AdminHeader icon="fas fa-key" description={app.translator.trans('core.admin.permissions.description')} className="PermissionsPage-header">
{app.translator.trans('core.admin.permissions.title')}
</AdminHeader>
<div className="PermissionsPage-groups"> <div className="PermissionsPage-groups">
<div className="container"> <div className="container">
{app.store {app.store

View File

@@ -1,5 +1,6 @@
import Modal from '../../common/components/Modal'; import Modal from '../../common/components/Modal';
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
import Stream from '../../common/utils/Stream';
import saveSettings from '../utils/saveSettings'; import saveSettings from '../utils/saveSettings';
export default class SettingsModal extends Modal { export default class SettingsModal extends Modal {
@@ -35,7 +36,7 @@ export default class SettingsModal extends Modal {
} }
setting(key, fallback = '') { setting(key, fallback = '') {
this.settings[key] = this.settings[key] || m.stream(app.data.settings[key] || fallback); this.settings[key] = this.settings[key] || Stream(app.data.settings[key] || fallback);
return this.settings[key]; return this.settings[key];
} }

View File

@@ -10,8 +10,9 @@ export { app };
// Export public API // Export public API
// Export compat API // Export compat API
import compat from './compat'; import compatObj from './compat';
import proxifyCompat from '../common/utils/proxifyCompat';
compat.app = app; compatObj.app = app;
export { compat }; export const compat = proxifyCompat(compatObj, 'admin');

View File

@@ -0,0 +1,19 @@
import DefaultResolver from '../../common/resolvers/DefaultResolver';
/**
* A custom route resolver for ExtensionPage that generates handles routes
* to default extension pages or a page provided by an extension.
*/
export default class ExtensionPageResolver extends DefaultResolver {
static extension: string | null = null;
onmatch(args, requestedPath, route) {
const extensionPage = app.extensionData.getPage(args.id);
if (extensionPage) {
return extensionPage;
}
return super.onmatch(args, requestedPath, route);
}
}

View File

@@ -2,8 +2,9 @@ import DashboardPage from './components/DashboardPage';
import BasicsPage from './components/BasicsPage'; import BasicsPage from './components/BasicsPage';
import PermissionsPage from './components/PermissionsPage'; import PermissionsPage from './components/PermissionsPage';
import AppearancePage from './components/AppearancePage'; import AppearancePage from './components/AppearancePage';
import ExtensionsPage from './components/ExtensionsPage';
import MailPage from './components/MailPage'; import MailPage from './components/MailPage';
import ExtensionPage from './components/ExtensionPage';
import ExtensionPageResolver from './resolvers/ExtensionPageResolver';
/** /**
* The `routes` initializer defines the forum app's routes. * The `routes` initializer defines the forum app's routes.
@@ -16,7 +17,7 @@ export default function (app) {
basics: { path: '/basics', component: BasicsPage }, basics: { path: '/basics', component: BasicsPage },
permissions: { path: '/permissions', component: PermissionsPage }, permissions: { path: '/permissions', component: PermissionsPage },
appearance: { path: '/appearance', component: AppearancePage }, appearance: { path: '/appearance', component: AppearancePage },
extensions: { path: '/extensions', component: ExtensionsPage },
mail: { path: '/mail', component: MailPage }, mail: { path: '/mail', component: MailPage },
extension: { path: '/extension/:id', component: ExtensionPage, resolverClass: ExtensionPageResolver },
}; };
} }

View File

@@ -0,0 +1,177 @@
import ItemList from '../../common/utils/ItemList';
export default class ExtensionData {
constructor() {
this.data = {};
this.currentExtension = null;
}
/**
* This function simply takes the extension id
*
* @example
* app.extensionData.load('flarum-tags')
*
* flarum/flags -> flarum-flags | acme/extension -> acme-extension
*
* @param extension
*/
for(extension) {
this.currentExtension = extension;
this.data[extension] = this.data[extension] || {};
return this;
}
/**
* This function registers your settings with Flarum
*
* It takes either a settings object or a callback.
*
* @example
*
* .registerSetting({
* setting: 'flarum-flags.guidelines_url',
* type: 'text', // This will be inputted into the input tag for the setting (text/number/etc)
* label: app.translator.trans('flarum-flags.admin.settings.guidelines_url_label')
* }, 15) // priority is optional (ItemList)
*
*
* @param content
* @param priority
* @returns {ExtensionData}
*/
registerSetting(content, priority = 0) {
this.data[this.currentExtension].settings = this.data[this.currentExtension].settings || new ItemList();
// Callbacks can be passed in instead of settings to display custom content.
// By default, they will be added with the `null` key, since they don't have a `.setting` attr.
// To support multiple such items for one extension, we assign a random ID.
// 36 is arbitrary length, but makes collisions very unlikely.
if (typeof content === 'function') {
content.setting = Math.random().toString(36);
}
this.data[this.currentExtension].settings.add(content.setting, content, priority);
return this;
}
/**
* This function registers your permission with Flarum
*
* @example
*
* .registerPermission('permissions', {
* icon: 'fas fa-flag',
* label: app.translator.trans('flarum-flags.admin.permissions.view_flags_label'),
* permission: 'discussion.viewFlags'
* }, 'moderate', 65)
*
* @param content
* @param permissionType
* @param priority
* @returns {ExtensionData}
*/
registerPermission(content, permissionType = null, priority = 0) {
this.data[this.currentExtension].permissions = this.data[this.currentExtension].permissions || {};
if (!this.data[this.currentExtension].permissions[permissionType]) {
this.data[this.currentExtension].permissions[permissionType] = new ItemList();
}
this.data[this.currentExtension].permissions[permissionType].add(content.permission, content, priority);
return this;
}
/**
* Replace the default extension page with a custom component.
* This component would typically extend ExtensionPage
*
* @param component
* @returns {ExtensionData}
*/
registerPage(component) {
this.data[this.currentExtension].page = component;
return this;
}
/**
* Get an extension's registered settings
*
* @param extensionId
* @returns {boolean|*}
*/
getSettings(extensionId) {
if (this.data[extensionId] && this.data[extensionId].settings) {
return this.data[extensionId].settings.toArray();
}
return false;
}
/**
*
* Get an ItemList of all extensions' registered permissions
*
* @param extension
* @param type
* @returns {ItemList}
*/
getAllExtensionPermissions(type) {
const items = new ItemList();
Object.keys(this.data).map((extension) => {
if (this.extensionHasPermissions(extension) && this.data[extension].permissions[type]) {
items.merge(this.data[extension].permissions[type]);
}
});
return items;
}
/**
* Get a singular extension's registered permissions
*
* @param extension
* @param type
* @returns {boolean|*}
*/
getExtensionPermissions(extension, type) {
if (this.extensionHasPermissions(extension) && this.data[extension].permissions[type]) {
return this.data[extension].permissions[type];
}
return new ItemList();
}
/**
* Checks whether a given extension has registered permissions.
*
* @param extension
* @returns {boolean}
*/
extensionHasPermissions(extension) {
if (this.data[extension] && this.data[extension].permissions) {
return true;
}
return false;
}
/**
* Returns an extension's custom page component if it exists.
*
* @param extension
* @returns {boolean|*}
*/
getPage(extension) {
if (this.data[extension]) {
return this.data[extension].page;
}
return false;
}
}

View File

@@ -0,0 +1,25 @@
export default function getCategorizedExtensions() {
let extensions = {};
Object.keys(app.data.extensions).map((id) => {
const extension = app.data.extensions[id];
let category = extension.extra['flarum-extension'].category;
// Wrap languages packs into new system
if (extension.extra['flarum-locale']) {
category = 'language';
}
if (category in app.extensionCategories) {
extensions[category] = extensions[category] || [];
extensions[category].push(extension);
} else {
extensions.other = extensions.other || [];
extensions.other.push(extension);
}
});
return extensions;
}

View File

@@ -0,0 +1,5 @@
export default function isExtensionEnabled(name) {
const enabled = JSON.parse(app.data.settings.extensions_enabled);
return enabled.includes(name);
}

View File

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

View File

@@ -1,10 +1,6 @@
import * as Mithril from 'mithril'; import * as Mithril from 'mithril';
export type ComponentAttrs = { export interface ComponentAttrs extends Mithril.Attributes {}
className?: string;
[key: string]: any;
};
/** /**
* The `Component` class defines a user interface 'building block'. A component * The `Component` class defines a user interface 'building block'. A component
@@ -33,7 +29,7 @@ export type ComponentAttrs = {
* *
* @see https://mithril.js.org/components.html * @see https://mithril.js.org/components.html
*/ */
export default abstract class Component<T extends ComponentAttrs = any> implements Mithril.ClassComponent<T> { export default abstract class Component<T extends ComponentAttrs = ComponentAttrs> implements Mithril.ClassComponent<T> {
/** /**
* The root DOM element for the component. * The root DOM element for the component.
*/ */

View File

@@ -15,7 +15,7 @@ import * as Mithril from 'mithril';
* This should only be used when necessary, and only with `m.render`. If you are unsure whether you need * This should only be used when necessary, and only with `m.render`. If you are unsure whether you need
* this or `Component, you probably need `Component`. * this or `Component, you probably need `Component`.
*/ */
export default abstract class Fragment implements Mithril.ClassComponent { export default abstract class Fragment {
/** /**
* The root DOM element for the fragment. * The root DOM element for the fragment.
*/ */
@@ -52,7 +52,7 @@ export default abstract class Fragment implements Mithril.ClassComponent {
* *
* @final * @final
*/ */
public render(): Mithril.Vnode { public render(): Mithril.Vnode<Mithril.Attributes, this> {
const vdom = this.view(); const vdom = this.view();
vdom.attrs = vdom.attrs || {}; vdom.attrs = vdom.attrs || {};
@@ -61,15 +61,14 @@ export default abstract class Fragment implements Mithril.ClassComponent {
vdom.attrs.oncreate = (vnode) => { vdom.attrs.oncreate = (vnode) => {
this.element = vnode.dom; this.element = vnode.dom;
if (this.oncreate) this.oncreate.apply(this, vnode); if (originalOnCreate) originalOnCreate.apply(this, [vnode]);
if (originalOnCreate) originalOnCreate.apply(this, vnode);
}; };
return vdom; return vdom;
} }
/** /**
* @inheritdoc * Creates a view out of virtual elements.
*/ */
abstract view(); abstract view(): Mithril.Vnode<Mithril.Attributes, this>;
} }

View File

@@ -12,13 +12,16 @@ import anchorScroll from './utils/anchorScroll';
import RequestError from './utils/RequestError'; import RequestError from './utils/RequestError';
import abbreviateNumber from './utils/abbreviateNumber'; import abbreviateNumber from './utils/abbreviateNumber';
import * as string from './utils/string'; import * as string from './utils/string';
import Stream from './utils/Stream';
import SubtreeRetainer from './utils/SubtreeRetainer'; import SubtreeRetainer from './utils/SubtreeRetainer';
import setRouteWithForcedRefresh from './utils/setRouteWithForcedRefresh'; import setRouteWithForcedRefresh from './utils/setRouteWithForcedRefresh';
import extract from './utils/extract'; import extract from './utils/extract';
import ScrollListener from './utils/ScrollListener'; import ScrollListener from './utils/ScrollListener';
import stringToColor from './utils/stringToColor'; import stringToColor from './utils/stringToColor';
import subclassOf from './utils/subclassOf'; import subclassOf from './utils/subclassOf';
import SuperTextarea from './utils/SuperTextarea';
import patchMithril from './utils/patchMithril'; import patchMithril from './utils/patchMithril';
import proxifyCompat from './utils/proxifyCompat';
import classList from './utils/classList'; import classList from './utils/classList';
import extractText from './utils/extractText'; import extractText from './utils/extractText';
import formatNumber from './utils/formatNumber'; import formatNumber from './utils/formatNumber';
@@ -46,6 +49,7 @@ import FieldSet from './components/FieldSet';
import Select from './components/Select'; import Select from './components/Select';
import Navigation from './components/Navigation'; import Navigation from './components/Navigation';
import Alert from './components/Alert'; import Alert from './components/Alert';
import Link from './components/Link';
import LinkButton from './components/LinkButton'; import LinkButton from './components/LinkButton';
import Checkbox from './components/Checkbox'; import Checkbox from './components/Checkbox';
import SelectDropdown from './components/SelectDropdown'; import SelectDropdown from './components/SelectDropdown';
@@ -65,6 +69,7 @@ import username from './helpers/username';
import userOnline from './helpers/userOnline'; import userOnline from './helpers/userOnline';
import listItems from './helpers/listItems'; import listItems from './helpers/listItems';
import Fragment from './Fragment'; import Fragment from './Fragment';
import DefaultResolver from './resolvers/DefaultResolver';
export default { export default {
extend: extend, extend: extend,
@@ -85,9 +90,12 @@ export default {
'utils/extract': extract, 'utils/extract': extract,
'utils/ScrollListener': ScrollListener, 'utils/ScrollListener': ScrollListener,
'utils/stringToColor': stringToColor, 'utils/stringToColor': stringToColor,
'utils/Stream': Stream,
'utils/subclassOf': subclassOf, 'utils/subclassOf': subclassOf,
'utils/SuperTextarea': SuperTextarea,
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh, 'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
'utils/patchMithril': patchMithril, 'utils/patchMithril': patchMithril,
'utils/proxifyCompat': proxifyCompat,
'utils/classList': classList, 'utils/classList': classList,
'utils/extractText': extractText, 'utils/extractText': extractText,
'utils/formatNumber': formatNumber, 'utils/formatNumber': formatNumber,
@@ -116,6 +124,7 @@ export default {
'components/Select': Select, 'components/Select': Select,
'components/Navigation': Navigation, 'components/Navigation': Navigation,
'components/Alert': Alert, 'components/Alert': Alert,
'components/Link': Link,
'components/LinkButton': LinkButton, 'components/LinkButton': LinkButton,
'components/Checkbox': Checkbox, 'components/Checkbox': Checkbox,
'components/SelectDropdown': SelectDropdown, 'components/SelectDropdown': SelectDropdown,
@@ -134,4 +143,5 @@ export default {
'helpers/username': username, 'helpers/username': username,
'helpers/userOnline': userOnline, 'helpers/userOnline': userOnline,
'helpers/listItems': listItems, 'helpers/listItems': listItems,
'resolvers/DefaultResolver': DefaultResolver,
}; };

View File

@@ -1,31 +1,33 @@
import Component from '../Component'; import Component, { ComponentAttrs } from '../Component';
import Button from './Button'; import Button from './Button';
import listItems from '../helpers/listItems'; import listItems from '../helpers/listItems';
import extract from '../utils/extract'; import extract from '../utils/extract';
import Mithril from 'mithril';
export interface AlertAttrs extends ComponentAttrs {
/** The type of alert this is. Will be used to give the alert a class name of `Alert--{type}`. */
type?: string;
/** An array of controls to show in the alert. */
controls?: Mithril.Children;
/** Whether or not the alert can be dismissed. */
dismissible?: boolean;
/** A callback to run when the alert is dismissed */
ondismiss?: Function;
}
/** /**
* The `Alert` component represents an alert box, which contains a message, * The `Alert` component represents an alert box, which contains a message,
* some controls, and may be dismissible. * some controls, and may be dismissible.
*
* ### Attrs
*
* - `type` The type of alert this is. Will be used to give the alert a class
* name of `Alert--{type}`.
* - `controls` An array of controls to show in the alert.
* - `dismissible` Whether or not the alert can be dismissed.
* - `ondismiss` A callback to run when the alert is dismissed.
*
* All other attrs will be assigned as attributes on the DOM element.
*/ */
export default class Alert extends Component { export default class Alert<T extends AlertAttrs = AlertAttrs> extends Component<T> {
view(vnode) { view(vnode: Mithril.Vnode) {
const attrs = Object.assign({}, this.attrs); const attrs = Object.assign({}, this.attrs);
const type = extract(attrs, 'type'); const type = extract(attrs, 'type');
attrs.className = 'Alert Alert--' + type + ' ' + (attrs.className || ''); attrs.className = 'Alert Alert--' + type + ' ' + (attrs.className || '');
const content = extract(attrs, 'content') || vnode.children; const content = extract(attrs, 'content') || vnode.children;
const controls = extract(attrs, 'controls') || []; const controls = (extract(attrs, 'controls') || []) as Mithril.ChildArray;
// If the alert is meant to be dismissible (which is the case by default), // If the alert is meant to be dismissible (which is the case by default),
// then we will create a dismiss button to append as the final control in // then we will create a dismiss button to append as the final control in

View File

@@ -35,6 +35,11 @@ export default class Button extends Component {
attrs['aria-label'] = attrs.title; attrs['aria-label'] = attrs.title;
} }
// If given a translation object, extract the text.
if (typeof attrs.title === 'object') {
attrs.title = extractText(attrs.title);
}
// If nothing else is provided, we use the textual button content as tooltip // If nothing else is provided, we use the textual button content as tooltip
if (!attrs.title && vnode.children) { if (!attrs.title && vnode.children) {
attrs.title = extractText(vnode.children); attrs.title = extractText(vnode.children);

View File

@@ -0,0 +1,47 @@
import Component from '../Component';
import extract from '../utils/extract';
/**
* The link component enables both internal and external links.
* It will return a regular HTML link for any links to external sites,
* and it will use Mithril's m.route.Link for any internal links.
*
* Links will default to internal; the 'external' attr must be set to
* `true` for the link to be external.
*/
export default class Link extends Component {
view(vnode) {
let { options = {}, ...attrs } = vnode.attrs;
attrs.href = attrs.href || '';
// For some reason, m.route.Link does not like vnode.text, so if present, we
// need to convert it to text vnodes and store it in children.
const children = vnode.children || { tag: '#', children: vnode.text };
if (attrs.external) {
return <a {...attrs}>{children}</a>;
}
// If the href URL of the link is the same as the current page path
// we will not add a new entry to the browser history.
// This allows us to still refresh the Page component
// without adding endless history entries.
if (attrs.href === m.route.get()) {
if (!('replace' in options)) options.replace = true;
}
// Mithril 2 does not completely rerender the page if a route change leads to the same route
// (or the same component handling a different route).
// Here, the `force` parameter will use Mithril's key system to force a full rerender
// see https://mithril.js.org/route.html#key-parameter
if (extract(attrs, 'force')) {
if (!('state' in options)) options.state = {};
if (!('key' in options.state)) options.state.key = Date.now();
}
attrs.options = options;
return <m.route.Link {...attrs}>{children}</m.route.Link>;
}
}

View File

@@ -1,4 +1,5 @@
import Button from './Button'; import Button from './Button';
import Link from './Link';
/** /**
* The `LinkButton` component defines a `Button` which links to a route. * The `LinkButton` component defines a `Button` which links to a route.
@@ -11,18 +12,20 @@ import Button from './Button';
* active. * active.
* - `href` The URL to link to. If the current URL `m.route()` matches this, * - `href` The URL to link to. If the current URL `m.route()` matches this,
* the `active` prop will automatically be set to true. * the `active` prop will automatically be set to true.
* - `force` Whether the page should be fully rerendered. Defaults to `true`.
*/ */
export default class LinkButton extends Button { export default class LinkButton extends Button {
static initAttrs(attrs) { static initAttrs(attrs) {
super.initAttrs(attrs); super.initAttrs(attrs);
attrs.active = this.isActive(attrs); attrs.active = this.isActive(attrs);
if (attrs.force === undefined) attrs.force = true;
} }
view(vnode) { view(vnode) {
const vdom = super.view(vnode); const vdom = super.view(vnode);
vdom.tag = m.route.Link; vdom.tag = Link;
vdom.attrs.active = String(vdom.attrs.active); vdom.attrs.active = String(vdom.attrs.active);
return vdom; return vdom;

View File

@@ -24,11 +24,20 @@ export default class Modal extends Component {
oncreate(vnode) { oncreate(vnode) {
super.oncreate(vnode); super.oncreate(vnode);
this.attrs.onshow(() => this.onready()); this.attrs.animateShow(() => this.onready());
} }
onremove() { onbeforeremove() {
this.attrs.onhide(); // If the global modal state currently contains a modal,
// we've just opened up a new one, and accordingly,
// we don't need to show a hide animation.
if (!this.attrs.state.modal) {
this.attrs.animateHide();
// Here, we ensure that the animation has time to complete.
// See https://mithril.js.org/lifecycle-methods.html#onbeforeremove
// Bootstrap's Modal.TRANSITION_DURATION is 300 ms.
return new Promise((resolve) => setTimeout(resolve, 300));
}
} }
view() { view() {
@@ -103,13 +112,11 @@ export default class Modal extends Component {
this.$('form').find('input, select, textarea').first().focus().select(); this.$('form').find('input, select, textarea').first().focus().select();
} }
onhide() {}
/** /**
* Hide the modal. * Hide the modal.
*/ */
hide() { hide() {
this.attrs.onhide(); this.attrs.state.close();
} }
/** /**

View File

@@ -11,7 +11,14 @@ export default class ModalManager extends Component {
return ( return (
<div className="ModalManager modal fade"> <div className="ModalManager modal fade">
{modal ? modal.componentClass.component({ ...modal.attrs, onshow: this.animateShow.bind(this), onhide: this.animateHide.bind(this) }) : ''} {modal
? modal.componentClass.component({
...modal.attrs,
animateShow: this.animateShow.bind(this),
animateHide: this.animateHide.bind(this),
state: this.attrs.state,
})
: ''}
</div> </div>
); );
} }
@@ -28,6 +35,14 @@ export default class ModalManager extends Component {
animateShow(readyCallback) { animateShow(readyCallback) {
const dismissible = !!this.attrs.state.modal.componentClass.isDismissible; const dismissible = !!this.attrs.state.modal.componentClass.isDismissible;
// If we are opening this modal while another modal is already open,
// the shown event will not run, because the modal is already open.
// So, we need to manually trigger the readyCallback.
if (this.$().hasClass('in')) {
readyCallback();
return;
}
this.$() this.$()
.one('shown.bs.modal', readyCallback) .one('shown.bs.modal', readyCallback)
.modal({ .modal({

View File

@@ -11,9 +11,7 @@ export default class Page extends Component {
super.oninit(vnode); super.oninit(vnode);
app.previous = app.current; app.previous = app.current;
app.current = new PageState(this.constructor); app.current = new PageState(this.constructor, { routeName: this.attrs.routeName });
this.onNewRoute();
app.drawer.hide(); app.drawer.hide();
app.modal.close(); app.modal.close();
@@ -24,16 +22,20 @@ export default class Page extends Component {
* @type {String} * @type {String}
*/ */
this.bodyClass = ''; this.bodyClass = '';
}
/** /**
* A collections of actions to run when the route changes. * Whether we should scroll to the top of the page when its rendered.
* This is extracted here, and not hardcoded in oninit, as oninit is not called *
* when a different route is handled by the same component, but we still need to * @type {Boolean}
* adjust the current route name. */
*/ this.scrollTopOnCreate = true;
onNewRoute() {
app.current.set('routeName', this.attrs.routeName); /**
* Whether the browser should restore scroll state on refreshes.
*
* @type {Boolean}
*/
this.useBrowserScrollRestoration = true;
} }
oncreate(vnode) { oncreate(vnode) {
@@ -42,6 +44,14 @@ export default class Page extends Component {
if (this.bodyClass) { if (this.bodyClass) {
$('#app').addClass(this.bodyClass); $('#app').addClass(this.bodyClass);
} }
if (this.scrollTopOnCreate) {
$(window).scrollTop(0);
}
if ('scrollRestoration' in history) {
history.scrollRestoration = this.useBrowserScrollRestoration ? 'auto' : 'manual';
}
} }
onremove() { onremove() {

View File

@@ -12,6 +12,9 @@ import icon from '../helpers/icon';
function isActive(vnode) { function isActive(vnode) {
const tag = vnode.tag; const tag = vnode.tag;
// Allow non-selectable dividers/headers to be added.
if (typeof tag === 'string' && tag !== 'a' && tag !== 'button') return false;
if ('initAttrs' in tag) { if ('initAttrs' in tag) {
tag.initAttrs(vnode.attrs); tag.initAttrs(vnode.attrs);
} }

View File

@@ -1,11 +1,11 @@
import dayjs from 'dayjs';
import * as Mithril from 'mithril';
/** /**
* The `fullTime` helper displays a formatted time string wrapped in a <time> * The `fullTime` helper displays a formatted time string wrapped in a <time>
* tag. * tag.
*
* @param {Date} time
* @return {Object}
*/ */
export default function fullTime(time) { export default function fullTime(time: Date): Mithril.Vnode {
const d = dayjs(time); const d = dayjs(time);
const datetime = d.format(); const datetime = d.format();

View File

@@ -1,14 +1,13 @@
import dayjs from 'dayjs';
import * as Mithril from 'mithril';
import humanTimeUtil from '../utils/humanTime'; import humanTimeUtil from '../utils/humanTime';
/** /**
* The `humanTime` helper displays a time in a human-friendly time-ago format * The `humanTime` helper displays a time in a human-friendly time-ago format
* (e.g. '12 days ago'), wrapped in a <time> tag with other information about * (e.g. '12 days ago'), wrapped in a <time> tag with other information about
* the time. * the time.
*
* @param {Date} time
* @return {Object}
*/ */
export default function humanTime(time) { export default function humanTime(time: Date): Mithril.Vnode {
const d = dayjs(time); const d = dayjs(time);
const datetime = d.format(); const datetime = d.format();

View File

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

View File

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

View File

@@ -51,8 +51,6 @@ export default function listItems(items) {
</li> </li>
); );
node.state = node.state || {};
return node; return node;
}); });
} }

View File

@@ -1,6 +1,6 @@
import 'expose-loader?$!expose-loader?jQuery!jquery'; import 'expose-loader?$!expose-loader?jQuery!jquery';
import 'expose-loader?m!mithril'; import 'expose-loader?m!mithril';
import 'expose-loader?moment!expose-loader?dayjs!dayjs'; import 'expose-loader?dayjs!dayjs';
import 'expose-loader?m.bidi!m.attrs.bidi'; import 'expose-loader?m.bidi!m.attrs.bidi';
import 'bootstrap/js/affix'; import 'bootstrap/js/affix';
import 'bootstrap/js/dropdown'; import 'bootstrap/js/dropdown';

View File

@@ -10,6 +10,7 @@ export default class User extends Model {}
Object.assign(User.prototype, { Object.assign(User.prototype, {
username: Model.attribute('username'), username: Model.attribute('username'),
slug: Model.attribute('slug'),
displayName: Model.attribute('displayName'), displayName: Model.attribute('displayName'),
email: Model.attribute('email'), email: Model.attribute('email'),
isEmailConfirmed: Model.attribute('isEmailConfirmed'), isEmailConfirmed: Model.attribute('isEmailConfirmed'),

View File

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

View File

@@ -1,50 +0,0 @@
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(children, 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] = { children, 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,80 @@
import Mithril from 'mithril';
import Alert, { AlertAttrs } from '../components/Alert';
/**
* Returned by `AlertManagerState.show`. Used to dismiss alerts.
*/
export type AlertIdentifier = number;
export interface AlertState {
componentClass: typeof Alert;
attrs: AlertAttrs;
children: Mithril.Children;
}
export default class AlertManagerState {
protected activeAlerts: { [id: number]: AlertState } = {};
protected alertId = 0;
getActiveAlerts() {
return this.activeAlerts;
}
/**
* Show an Alert in the alerts area.
*
* @returns The alert's ID, which can be used to dismiss the alert.
*/
show(children: Mithril.Children): AlertIdentifier;
show(attrs: AlertAttrs, children: Mithril.Children): AlertIdentifier;
show(componentClass: Alert, attrs: AlertAttrs, children: Mithril.Children): AlertIdentifier;
show(arg1: any, arg2?: any, arg3?: any) {
// Assigns variables as per the above signatures
let componentClass = Alert;
let attrs: AlertAttrs = {};
let children: Mithril.Children;
if (arguments.length == 1) {
children = arg1 as Mithril.Children;
} else if (arguments.length == 2) {
attrs = arg1 as AlertAttrs;
children = arg2 as Mithril.Children;
} else if (arguments.length == 3) {
componentClass = arg1 as typeof Alert;
attrs = arg2 as AlertAttrs;
children = arg3;
}
// 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] = { children, attrs, componentClass };
m.redraw();
return this.alertId;
}
/**
* Dismiss an alert.
*/
dismiss(key: AlertIdentifier): void {
if (!key || !(key in this.activeAlerts)) return;
delete this.activeAlerts[key];
m.redraw();
}
/**
* Clear all alerts.
*/
clear(): void {
this.activeAlerts = {};
m.redraw();
}
}

View File

@@ -0,0 +1,80 @@
export default class Pagination<T> {
private readonly loadFunction: (page: number) => Promise<any>;
public loading = {
prev: false,
next: false,
};
public page: number;
public data: { [page: number]: T } = {};
public pages: {
first: number;
last: number;
};
constructor(load: (page: number) => Promise<any>, page: number = 1) {
this.loadFunction = load;
this.page = page;
this.pages = {
first: page,
last: page,
};
}
clear() {
this.data = {};
}
refresh(page: number) {
this.clear();
this.page = page;
this.pages.last = page - 1;
this.pages.first = page;
return this.loadNext();
}
loadNext() {
this.loading.next = true;
const page = this.pages.last + 1;
return this.load(
page,
() => (this.loading.next = false),
() => (this.pages.last = this.page = page)
);
}
loadPrev() {
this.loading.prev = true;
const page = this.pages.first - 1;
return this.load(
page,
() => (this.loading.prev = false),
() => (this.pages.first = this.page = page)
);
}
private load(page, done, success) {
return this.loadFunction(page)
.then((out) => {
done();
success();
this.data[this.page] = out;
return out;
})
.catch((err) => {
done();
return Promise.reject(err);
});
}
}

View File

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

View File

@@ -0,0 +1,3 @@
import Stream from 'mithril/stream';
export default Stream;

View File

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

View File

@@ -22,6 +22,8 @@ export default class SuperTextarea {
*/ */
setValue(value) { setValue(value) {
this.$.val(value).trigger('input'); this.$.val(value).trigger('input');
this.el.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
} }
/** /**
@@ -49,8 +51,6 @@ export default class SuperTextarea {
*/ */
insertAtCursor(text) { insertAtCursor(text) {
this.insertAt(this.el.selectionStart, text); this.insertAt(this.el.selectionStart, text);
this.el.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
} }
/** /**

View File

@@ -1,3 +1,6 @@
import dayjs from 'dayjs';
import 'dayjs/plugin/relativeTime';
/** /**
* The `humanTime` utility converts a date to a localized, human-readable time- * The `humanTime` utility converts a date to a localized, human-readable time-
* ago string. * ago string.

View File

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

View File

@@ -1,39 +1,6 @@
import Stream from 'mithril/stream';
import extract from './extract';
export default function patchMithril(global) { export default function patchMithril(global) {
const defaultMithril = global.m; const defaultMithril = global.m;
/**
* If the href URL of the link is the same as the current page path
* we will not add a new entry to the browser history.
*
* This allows us to still refresh the Page component
* without adding endless history entries.
*
* We also add the `force` attribute that adds a custom state key
* for when you want to force a complete refresh of the Page
*/
const defaultLinkView = defaultMithril.route.Link.view;
const modifiedLink = {
view: function (vnode) {
let { href, options = {} } = vnode.attrs;
if (href === m.route.get()) {
if (!('replace' in options)) options.replace = true;
}
if (extract(vnode.attrs, 'force')) {
if (!('state' in options)) options.state = {};
if (!('key' in options.state)) options.state.key = Date.now();
}
vnode.attrs.options = options;
return defaultLinkView(vnode);
},
};
const modifiedMithril = function (comp, ...args) { const modifiedMithril = function (comp, ...args) {
const node = defaultMithril.apply(this, arguments); const node = defaultMithril.apply(this, arguments);
@@ -44,29 +11,10 @@ export default function patchMithril(global) {
modifiedMithril.bidi(node, node.attrs.bidi); modifiedMithril.bidi(node, node.attrs.bidi);
} }
// Allows us to use a "route" attr on links, which will automatically convert the link to one which
// supports linking to other pages in the SPA without refreshing the document.
if (node.attrs.route) {
node.attrs.href = node.attrs.route;
node.tag = modifiedLink;
// For some reason, m.route.Link does not like vnode.text, so if present, we
// need to convert it to text vnodes and store it in children.
if (node.text) {
node.children = { tag: '#', children: node.text };
}
delete node.attrs.route;
}
return node; return node;
}; };
Object.keys(defaultMithril).forEach((key) => (modifiedMithril[key] = defaultMithril[key])); Object.keys(defaultMithril).forEach((key) => (modifiedMithril[key] = defaultMithril[key]));
modifiedMithril.stream = Stream;
modifiedMithril.route.Link = modifiedLink;
global.m = modifiedMithril; global.m = modifiedMithril;
} }

View File

@@ -0,0 +1,10 @@
export default (compat: { [key: string]: any }, namespace: string) => {
// regex to replace common/ and NAMESPACE/ for core & core extensions
// e.g. admin/utils/extract --> utils/extract
// e.g. tags/common/utils/sortTags --> tags/utils/sortTags
const regex = new RegExp(`(\\w+\\/)?(${namespace}|common)\\/`);
return new Proxy(compat, {
get: (obj, prop: string) => obj[prop] || obj[prop.replace(regex, '$1')],
});
};

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import Modal from '../../common/components/Modal'; import Modal from '../../common/components/Modal';
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
import Stream from '../../common/utils/Stream';
/** /**
* The `ChangeEmailModal` component shows a modal dialog which allows the user * The `ChangeEmailModal` component shows a modal dialog which allows the user
@@ -21,14 +22,14 @@ export default class ChangeEmailModal extends Modal {
* *
* @type {function} * @type {function}
*/ */
this.email = m.stream(app.session.user.email()); this.email = Stream(app.session.user.email());
/** /**
* The value of the password input. * The value of the password input.
* *
* @type {function} * @type {function}
*/ */
this.password = m.stream(''); this.password = Stream('');
} }
className() { className() {
@@ -117,7 +118,10 @@ export default class ChangeEmailModal extends Modal {
meta: { password: this.password() }, meta: { password: this.password() },
} }
) )
.then(() => (this.success = true)) .then(() => {
this.success = true;
this.alertAttrs = null;
})
.catch(() => {}) .catch(() => {})
.then(this.loaded.bind(this)); .then(this.loaded.bind(this));
} }

View File

@@ -56,9 +56,7 @@ export default class CommentPost extends Post {
]); ]);
} }
onupdate(vnode) { refreshContent() {
super.onupdate();
const contentHtml = this.isEditing() ? '' : this.attrs.post.contentHtml(); const contentHtml = this.isEditing() ? '' : this.attrs.post.contentHtml();
// If the post content has changed since the last render, we'll run through // If the post content has changed since the last render, we'll run through
@@ -66,13 +64,28 @@ export default class CommentPost extends Post {
// necessary because TextFormatter outputs them for e.g. syntax highlighting. // necessary because TextFormatter outputs them for e.g. syntax highlighting.
if (this.contentHtml !== contentHtml) { if (this.contentHtml !== contentHtml) {
this.$('.Post-body script').each(function () { this.$('.Post-body script').each(function () {
eval.call(window, $(this).text()); const script = document.createElement('script');
script.textContent = this.textContent;
Array.from(this.attributes).forEach((attr) => script.setAttribute(attr.name, attr.value));
this.parentNode.replaceChild(script, this);
}); });
} }
this.contentHtml = contentHtml; this.contentHtml = contentHtml;
} }
oncreate(vnode) {
super.oncreate(vnode);
this.refreshContent();
}
onupdate(vnode) {
super.onupdate(vnode);
this.refreshContent();
}
isEditing() { isEditing() {
return app.composer.bodyMatches(EditPostComposer, { post: this.attrs.post }); return app.composer.bodyMatches(EditPostComposer, { post: this.attrs.post });
} }

View File

@@ -82,7 +82,7 @@ export default class Composer extends Component {
}); });
// When the escape key is pressed on any inputs, close the composer. // When the escape key is pressed on any inputs, close the composer.
this.$().on('keydown', ':input', 'esc', () => this.close()); this.$().on('keydown', ':input', 'esc', () => this.state.close());
this.handlers = {}; this.handlers = {};
@@ -199,7 +199,7 @@ export default class Composer extends Component {
*/ */
animatePositionChange() { animatePositionChange() {
// When exiting full-screen mode: focus content // When exiting full-screen mode: focus content
if (this.prevPosition === ComposerState.Position.FULLSCREEN) { if (this.prevPosition === ComposerState.Position.FULLSCREEN && this.state.position === ComposerState.Position.NORMAL) {
this.focus(); this.focus();
return; return;
} }
@@ -265,7 +265,7 @@ export default class Composer extends Component {
this.animateHeightChange().then(() => this.focus()); this.animateHeightChange().then(() => this.focus());
if (app.screen() === 'phone') { if (app.screen() === 'phone') {
this.$().css('top', $(window).scrollTop()); this.$().css('top', 0);
this.showBackdrop(); this.showBackdrop();
} }
} }

View File

@@ -44,12 +44,6 @@ export default class ComposerBody extends Component {
} }
this.composer.fields.content(this.attrs.originalContent || ''); this.composer.fields.content(this.attrs.originalContent || '');
/**
* @deprecated BC layer, remove in Beta 15.
*/
this.content = this.composer.fields.content;
this.editor = this.composer;
} }
view() { view() {

View File

@@ -1,5 +1,6 @@
import ComposerBody from './ComposerBody'; import ComposerBody from './ComposerBody';
import extractText from '../../common/utils/extractText'; import extractText from '../../common/utils/extractText';
import Stream from '../../common/utils/Stream';
/** /**
* The `DiscussionComposer` component displays the composer content for starting * The `DiscussionComposer` component displays the composer content for starting
@@ -26,7 +27,7 @@ export default class DiscussionComposer extends ComposerBody {
oninit(vnode) { oninit(vnode) {
super.oninit(vnode); super.oninit(vnode);
this.composer.fields.title = this.composer.fields.title || m.stream(''); this.composer.fields.title = this.composer.fields.title || Stream('');
/** /**
* The value of the title input. * The value of the title input.
@@ -99,7 +100,7 @@ export default class DiscussionComposer extends ComposerBody {
.save(data) .save(data)
.then((discussion) => { .then((discussion) => {
this.composer.hide(); this.composer.hide();
app.discussions.refresh(); app.discussions.refresh({ deferClear: true });
m.route.set(app.route.discussion(discussion)); m.route.set(app.route.discussion(discussion));
}, this.loaded.bind(this)); }, this.loaded.bind(this));
} }

View File

@@ -21,13 +21,7 @@ export default class DiscussionList extends Component {
if (state.isLoading()) { if (state.isLoading()) {
loading = LoadingIndicator.component(); loading = LoadingIndicator.component();
} else if (state.moreResults) { } else if (state.moreResults) {
loading = Button.component( loading = this.getLoadButton('more', state.loadMore.bind(state));
{
className: 'Button',
onclick: state.loadMore.bind(state),
},
app.translator.trans('core.forum.discussion_list.load_more_button')
);
} }
if (state.empty()) { if (state.empty()) {
@@ -35,8 +29,18 @@ export default class DiscussionList extends Component {
return <div className="DiscussionList">{Placeholder.component({ text })}</div>; return <div className="DiscussionList">{Placeholder.component({ text })}</div>;
} }
console.log(state);
return ( return (
<div className={'DiscussionList' + (state.isSearchResults() ? ' DiscussionList--searchResults' : '')}> <div className={'DiscussionList' + (state.isSearchResults() ? ' DiscussionList--searchResults' : '')}>
{state.isLoadingPrev() ? (
<LoadingIndicator />
) : state.pagination.pages.first !== 1 ? (
<div className="DiscussionList-loadMore">{this.getLoadButton('prev', state.loadPrev.bind(state))}</div>
) : (
''
)}
<ul className="DiscussionList-discussions"> <ul className="DiscussionList-discussions">
{state.discussions.map((discussion) => { {state.discussions.map((discussion) => {
return ( return (
@@ -46,8 +50,17 @@ export default class DiscussionList extends Component {
); );
})} })}
</ul> </ul>
<div className="DiscussionList-loadMore">{loading}</div> <div className="DiscussionList-loadMore">{loading}</div>
</div> </div>
); );
} }
getLoadButton(key, onclick) {
return (
<Button className="Button" onclick={onclick}>
{app.translator.trans(`core.forum.discussion_list.load_${key}_button`)}
</Button>
);
}
} }

View File

@@ -1,4 +1,5 @@
import Component from '../../common/Component'; import Component from '../../common/Component';
import Link from '../../common/components/Link';
import avatar from '../../common/helpers/avatar'; import avatar from '../../common/helpers/avatar';
import listItems from '../../common/helpers/listItems'; import listItems from '../../common/helpers/listItems';
import highlight from '../../common/helpers/highlight'; import highlight from '../../common/helpers/highlight';
@@ -13,6 +14,7 @@ import DiscussionControls from '../utils/DiscussionControls';
import slidable from '../utils/slidable'; import slidable from '../utils/slidable';
import extractText from '../../common/utils/extractText'; import extractText from '../../common/utils/extractText';
import classList from '../../common/utils/classList'; import classList from '../../common/utils/classList';
import DiscussionPage from './DiscussionPage';
import { escapeRegExp } from 'lodash-es'; import { escapeRegExp } from 'lodash-es';
/** /**
@@ -50,6 +52,7 @@ export default class DiscussionListItem extends Component {
'DiscussionListItem', 'DiscussionListItem',
this.active() ? 'active' : '', this.active() ? 'active' : '',
this.attrs.discussion.isHidden() ? 'DiscussionListItem--hidden' : '', this.attrs.discussion.isHidden() ? 'DiscussionListItem--hidden' : '',
'ontouchstart' in window ? 'Slidable' : '',
]), ]),
}; };
} }
@@ -89,16 +92,16 @@ export default class DiscussionListItem extends Component {
) )
: ''} : ''}
<a <span
className={'Slidable-underneath Slidable-underneath--left Slidable-underneath--elastic' + (isUnread ? '' : ' disabled')} className={'Slidable-underneath Slidable-underneath--left Slidable-underneath--elastic' + (isUnread ? '' : ' disabled')}
onclick={this.markAsRead.bind(this)} onclick={this.markAsRead.bind(this)}
> >
{icon('fas fa-check')} {icon('fas fa-check')}
</a> </span>
<div className={'DiscussionListItem-content Slidable-content' + (isUnread ? ' unread' : '') + (isRead ? ' read' : '')}> <div className={'DiscussionListItem-content Slidable-content' + (isUnread ? ' unread' : '') + (isRead ? ' read' : '')}>
<a <Link
route={user ? app.route.user(user) : '#'} href={user ? app.route.user(user) : '#'}
className="DiscussionListItem-author" className="DiscussionListItem-author"
title={extractText( title={extractText(
app.translator.trans('core.forum.discussion_list.started_text', { user: user, ago: humanTime(discussion.createdAt()) }) app.translator.trans('core.forum.discussion_list.started_text', { user: user, ago: humanTime(discussion.createdAt()) })
@@ -108,14 +111,14 @@ export default class DiscussionListItem extends Component {
}} }}
> >
{avatar(user, { title: '' })} {avatar(user, { title: '' })}
</a> </Link>
<ul className="DiscussionListItem-badges badges">{listItems(discussion.badges().toArray())}</ul> <ul className="DiscussionListItem-badges badges">{listItems(discussion.badges().toArray())}</ul>
<a route={app.route.discussion(discussion, jumpTo)} className="DiscussionListItem-main"> <Link href={app.route.discussion(discussion, jumpTo)} className="DiscussionListItem-main">
<h3 className="DiscussionListItem-title">{highlight(discussion.title(), this.highlightRegExp)}</h3> <h3 className="DiscussionListItem-title">{highlight(discussion.title(), this.highlightRegExp)}</h3>
<ul className="DiscussionListItem-info">{listItems(this.infoItems().toArray())}</ul> <ul className="DiscussionListItem-info">{listItems(this.infoItems().toArray())}</ul>
</a> </Link>
<span <span
className="DiscussionListItem-count" className="DiscussionListItem-count"
@@ -136,7 +139,7 @@ export default class DiscussionListItem extends Component {
// This allows the user to drag the row to either side of the screen to // This allows the user to drag the row to either side of the screen to
// reveal controls. // reveal controls.
if ('ontouchstart' in window) { if ('ontouchstart' in window) {
const slidableInstance = slidable(this.$().addClass('Slidable')); const slidableInstance = slidable(this.$());
this.$('.DiscussionListItem-controls').on('hidden.bs.dropdown', () => slidableInstance.reset()); this.$('.DiscussionListItem-controls').on('hidden.bs.dropdown', () => slidableInstance.reset());
} }
@@ -154,9 +157,7 @@ export default class DiscussionListItem extends Component {
* @return {Boolean} * @return {Boolean}
*/ */
active() { active() {
const idParam = m.route.param('id'); return app.current.matches(DiscussionPage, { discussion: this.attrs.discussion });
return idParam && idParam.split('-')[0] === this.attrs.discussion.id();
} }
/** /**

View File

@@ -18,6 +18,8 @@ export default class DiscussionPage extends Page {
oninit(vnode) { oninit(vnode) {
super.oninit(vnode); super.oninit(vnode);
this.useBrowserScrollRestoration = false;
/** /**
* The discussion that is being viewed. * The discussion that is being viewed.
* *
@@ -47,11 +49,10 @@ export default class DiscussionPage extends Page {
app.history.push('discussion'); app.history.push('discussion');
this.bodyClass = 'App--discussion'; this.bodyClass = 'App--discussion';
this.prevRoute = m.route.get();
} }
onremove() { onremove() {
super.onremove();
// If we are indeed navigating away from this discussion, then disable the // If we are indeed navigating away from this discussion, then disable the
// discussion list pane. Also, if we're composing a reply to this // discussion list pane. Also, if we're composing a reply to this
// discussion, minimize the composer unless it's empty, in which case // discussion, minimize the composer unless it's empty, in which case
@@ -83,7 +84,6 @@ export default class DiscussionPage extends Page {
{PostStream.component({ {PostStream.component({
discussion, discussion,
stream: this.stream, stream: this.stream,
targetPost: this.stream.targetPost,
onPositionChange: this.positionChanged.bind(this), onPositionChange: this.positionChanged.bind(this),
})} })}
</div> </div>
@@ -95,33 +95,6 @@ export default class DiscussionPage extends Page {
); );
} }
onbeforeupdate(vnode) {
super.onbeforeupdate(vnode);
if (m.route.get() !== this.prevRoute) {
this.prevRoute = m.route.get();
// If we have routed to the same discussion as we were viewing previously,
// cancel the unloading of this controller and instead prompt the post
// stream to jump to the new 'near' param.
if (this.discussion) {
const idParam = m.route.param('id');
if (idParam && idParam.split('-')[0] === this.discussion.id()) {
const near = m.route.param('near') || '1';
if (near !== String(this.near)) {
this.stream.goToNumber(near);
}
this.near = near;
} else {
this.oninit(vnode);
}
}
}
}
/** /**
* Load the discussion from the API or use the preloaded one. * Load the discussion from the API or use the preloaded one.
*/ */
@@ -136,7 +109,7 @@ export default class DiscussionPage extends Page {
} else { } else {
const params = this.requestParams(); const params = this.requestParams();
app.store.find('discussions', m.route.param('id').split('-')[0], params).then(this.show.bind(this)); app.store.find('discussions', m.route.param('id'), params).then(this.show.bind(this));
} }
m.redraw(); m.redraw();
@@ -150,6 +123,7 @@ export default class DiscussionPage extends Page {
*/ */
requestParams() { requestParams() {
return { return {
bySlug: true,
page: { near: this.near }, page: { near: this.near },
}; };
} }
@@ -160,10 +134,8 @@ export default class DiscussionPage extends Page {
* @param {Discussion} discussion * @param {Discussion} discussion
*/ */
show(discussion) { show(discussion) {
this.discussion = discussion;
app.history.push('discussion', discussion.title()); app.history.push('discussion', discussion.title());
app.setTitle(this.discussion.title()); app.setTitle(discussion.title());
app.setTitleCount(0); app.setTitleCount(0);
// When the API responds with a discussion, it will also include a number of // When the API responds with a discussion, it will also include a number of
@@ -186,7 +158,7 @@ export default class DiscussionPage extends Page {
record.relationships.discussion.data.id === discussionId record.relationships.discussion.data.id === discussionId
) )
.map((record) => app.store.getById('posts', record.id)) .map((record) => app.store.getById('posts', record.id))
.sort((a, b) => a.id() - b.id()) .sort((a, b) => a.createdAt() - b.createdAt())
.slice(0, 20); .slice(0, 20);
} }
@@ -194,10 +166,12 @@ export default class DiscussionPage extends Page {
// posts we want to display. Tell the stream to scroll down and highlight // posts we want to display. Tell the stream to scroll down and highlight
// the specific post that was routed to. // the specific post that was routed to.
this.stream = new PostStreamState(discussion, includedPosts); this.stream = new PostStreamState(discussion, includedPosts);
this.stream.goToNumber(m.route.param('near') || (includedPosts[0] && includedPosts[0].number()), true); this.stream.goToNumber(m.route.param('near') || (includedPosts[0] && includedPosts[0].number()), true).then(() => {
this.discussion = discussion;
app.current.set('discussion', discussion); app.current.set('discussion', discussion);
app.current.set('stream', this.stream); app.current.set('stream', this.stream);
});
} }
/** /**
@@ -246,10 +220,7 @@ export default class DiscussionPage extends Page {
// replace it into the window's history and our own history stack. // replace it into the window's history and our own history stack.
const url = app.route.discussion(discussion, (this.near = startNumber)); const url = app.route.discussion(discussion, (this.near = startNumber));
this.prevRoute = url;
m.route.set(url, null, { replace: true });
window.history.replaceState(null, document.title, url); window.history.replaceState(null, document.title, url);
app.history.push('discussion', discussion.title()); app.history.push('discussion', discussion.title());
// If the user hasn't read past here before, then we'll update their read // If the user hasn't read past here before, then we'll update their read

View File

@@ -1,5 +1,6 @@
import highlight from '../../common/helpers/highlight'; import highlight from '../../common/helpers/highlight';
import LinkButton from '../../common/components/LinkButton'; import LinkButton from '../../common/components/LinkButton';
import Link from '../../common/components/Link';
/** /**
* The `DiscussionsSearchSource` finds and displays discussion search results in * The `DiscussionsSearchSource` finds and displays discussion search results in
@@ -47,10 +48,10 @@ export default class DiscussionsSearchSource {
return ( return (
<li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()}> <li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()}>
<a route={app.route.discussion(discussion, mostRelevantPost && mostRelevantPost.number())}> <Link href={app.route.discussion(discussion, mostRelevantPost && mostRelevantPost.number())}>
<div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div> <div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div>
{mostRelevantPost ? <div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain(), query, 100)}</div> : ''} {mostRelevantPost ? <div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain(), query, 100)}</div> : ''}
</a> </Link>
</li> </li>
); );
}), }),

View File

@@ -1,5 +1,6 @@
import ComposerBody from './ComposerBody'; import ComposerBody from './ComposerBody';
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
import Link from '../../common/components/Link';
import icon from '../../common/helpers/icon'; import icon from '../../common/helpers/icon';
function minimizeComposerIfFullScreen(e) { function minimizeComposerIfFullScreen(e) {
@@ -39,9 +40,9 @@ export default class EditPostComposer extends ComposerBody {
'title', 'title',
<h3> <h3>
{icon('fas fa-pencil-alt')}{' '} {icon('fas fa-pencil-alt')}{' '}
<a route={app.route.discussion(post.discussion(), post.number())} onclick={minimizeComposerIfFullScreen}> <Link href={app.route.discussion(post.discussion(), post.number())} onclick={minimizeComposerIfFullScreen}>
{app.translator.trans('core.forum.composer_edit.post_link', { number: post.number(), discussion: post.discussion().title() })} {app.translator.trans('core.forum.composer_edit.post_link', { number: post.number(), discussion: post.discussion().title() })}
</a> </Link>
</h3> </h3>
); );
@@ -95,10 +96,13 @@ export default class EditPostComposer extends ComposerBody {
}, },
app.translator.trans('core.forum.composer_edit.view_button') app.translator.trans('core.forum.composer_edit.view_button')
); );
alert = app.alerts.show(app.translator.trans('core.forum.composer_edit.edited_message'), { alert = app.alerts.show(
type: 'success', {
controls: [viewButton], type: 'success',
}); controls: [viewButton],
},
app.translator.trans('core.forum.composer_edit.edited_message')
);
} }
this.composer.hide(); this.composer.hide();

View File

@@ -4,6 +4,7 @@ import GroupBadge from '../../common/components/GroupBadge';
import Group from '../../common/models/Group'; import Group from '../../common/models/Group';
import extractText from '../../common/utils/extractText'; import extractText from '../../common/utils/extractText';
import ItemList from '../../common/utils/ItemList'; import ItemList from '../../common/utils/ItemList';
import Stream from '../../common/utils/Stream';
/** /**
* The `EditUserModal` component displays a modal dialog with a login form. * The `EditUserModal` component displays a modal dialog with a login form.
@@ -14,17 +15,17 @@ export default class EditUserModal extends Modal {
const user = this.attrs.user; const user = this.attrs.user;
this.username = m.stream(user.username() || ''); this.username = Stream(user.username() || '');
this.email = m.stream(user.email() || ''); this.email = Stream(user.email() || '');
this.isEmailConfirmed = m.stream(user.isEmailConfirmed() || false); this.isEmailConfirmed = Stream(user.isEmailConfirmed() || false);
this.setPassword = m.stream(false); this.setPassword = Stream(false);
this.password = m.stream(user.password() || ''); this.password = Stream(user.password() || '');
this.groups = {}; this.groups = {};
app.store app.store
.all('groups') .all('groups')
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1) .filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
.forEach((group) => (this.groups[group.id()] = m.stream(user.groups().indexOf(group) !== -1))); .forEach((group) => (this.groups[group.id()] = Stream(user.groups().indexOf(group) !== -1)));
} }
className() { className() {

View File

@@ -2,6 +2,7 @@ import Post from './Post';
import { ucfirst } from '../../common/utils/string'; import { ucfirst } from '../../common/utils/string';
import usernameHelper from '../../common/helpers/username'; import usernameHelper from '../../common/helpers/username';
import icon from '../../common/helpers/icon'; import icon from '../../common/helpers/icon';
import Link from '../../common/components/Link';
/** /**
* The `EventPost` component displays a post which indicating a discussion * The `EventPost` component displays a post which indicating a discussion
@@ -29,9 +30,9 @@ export default class EventPost extends Post {
const data = Object.assign(this.descriptionData(), { const data = Object.assign(this.descriptionData(), {
user, user,
username: user ? ( username: user ? (
<a className="EventPost-user" route={app.route.user(user)}> <Link className="EventPost-user" href={app.route.user(user)}>
{username} {username}
</a> </Link>
) : ( ) : (
username username
), ),

View File

@@ -1,6 +1,7 @@
import Modal from '../../common/components/Modal'; import Modal from '../../common/components/Modal';
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
import extractText from '../../common/utils/extractText'; import extractText from '../../common/utils/extractText';
import Stream from '../../common/utils/Stream';
/** /**
* The `ForgotPasswordModal` component displays a modal which allows the user to * The `ForgotPasswordModal` component displays a modal which allows the user to
@@ -19,7 +20,7 @@ export default class ForgotPasswordModal extends Modal {
* *
* @type {Function} * @type {Function}
*/ */
this.email = m.stream(this.attrs.email || ''); this.email = Stream(this.attrs.email || '');
/** /**
* Whether or not the password reset email was sent successfully. * Whether or not the password reset email was sent successfully.

View File

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

View File

@@ -5,6 +5,7 @@ import Button from '../../common/components/Button';
import LogInButtons from './LogInButtons'; import LogInButtons from './LogInButtons';
import extractText from '../../common/utils/extractText'; import extractText from '../../common/utils/extractText';
import ItemList from '../../common/utils/ItemList'; import ItemList from '../../common/utils/ItemList';
import Stream from '../../common/utils/Stream';
/** /**
* The `LogInModal` component displays a modal dialog with a login form. * The `LogInModal` component displays a modal dialog with a login form.
@@ -23,21 +24,21 @@ export default class LogInModal extends Modal {
* *
* @type {Function} * @type {Function}
*/ */
this.identification = m.stream(this.attrs.identification || ''); this.identification = Stream(this.attrs.identification || '');
/** /**
* The value of the password input. * The value of the password input.
* *
* @type {Function} * @type {Function}
*/ */
this.password = m.stream(this.attrs.password || ''); this.password = Stream(this.attrs.password || '');
/** /**
* The value of the remember me input. * The value of the remember me input.
* *
* @type {Function} * @type {Function}
*/ */
this.remember = m.stream(!!this.attrs.remember); this.remember = Stream(!!this.attrs.remember);
} }
className() { className() {

View File

@@ -3,6 +3,7 @@ import avatar from '../../common/helpers/avatar';
import icon from '../../common/helpers/icon'; import icon from '../../common/helpers/icon';
import humanTime from '../../common/helpers/humanTime'; import humanTime from '../../common/helpers/humanTime';
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
import Link from '../../common/components/Link';
/** /**
* The `Notification` component abstract displays a single notification. * The `Notification` component abstract displays a single notification.
@@ -19,13 +20,11 @@ export default class Notification extends Component {
const notification = this.attrs.notification; const notification = this.attrs.notification;
const href = this.href(); const href = this.href();
const linkAttrs = {};
linkAttrs[href.indexOf('://') === -1 ? 'route' : 'href'] = href;
return ( return (
<a <Link
className={'Notification Notification--' + notification.contentType() + ' ' + (!notification.isRead() ? 'unread' : '')} className={'Notification Notification--' + notification.contentType() + ' ' + (!notification.isRead() ? 'unread' : '')}
{...linkAttrs} href={href}
external={href.includes('://')}
onclick={this.markAsRead.bind(this)} onclick={this.markAsRead.bind(this)}
> >
{!notification.isRead() && {!notification.isRead() &&
@@ -45,7 +44,7 @@ export default class Notification extends Component {
<span className="Notification-content">{this.content()}</span> <span className="Notification-content">{this.content()}</span>
{humanTime(notification.createdAt())} {humanTime(notification.createdAt())}
<div className="Notification-excerpt">{this.excerpt()}</div> <div className="Notification-excerpt">{this.excerpt()}</div>
</a> </Link>
); );
} }

View File

@@ -1,6 +1,7 @@
import Component from '../../common/Component'; import Component from '../../common/Component';
import listItems from '../../common/helpers/listItems'; import listItems from '../../common/helpers/listItems';
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
import Link from '../../common/components/Link';
import LoadingIndicator from '../../common/components/LoadingIndicator'; import LoadingIndicator from '../../common/components/LoadingIndicator';
import Discussion from '../../common/models/Discussion'; import Discussion from '../../common/models/Discussion';
@@ -63,10 +64,10 @@ export default class NotificationList extends Component {
return ( return (
<div className="NotificationGroup"> <div className="NotificationGroup">
{group.discussion ? ( {group.discussion ? (
<a className="NotificationGroup-header" route={app.route.discussion(group.discussion)}> <Link className="NotificationGroup-header" href={app.route.discussion(group.discussion)}>
{badges && badges.length ? <ul className="NotificationGroup-badges badges">{listItems(badges)}</ul> : ''} {badges && badges.length ? <ul className="NotificationGroup-badges badges">{listItems(badges)}</ul> : ''}
{group.discussion.title()} {group.discussion.title()}
</a> </Link>
) : ( ) : (
<div className="NotificationGroup-header">{app.forum.attribute('title')}</div> <div className="NotificationGroup-header">{app.forum.attribute('title')}</div>
)} )}

View File

@@ -1,4 +1,5 @@
import Component from '../../common/Component'; import Component from '../../common/Component';
import Link from '../../common/components/Link';
import avatar from '../../common/helpers/avatar'; import avatar from '../../common/helpers/avatar';
import username from '../../common/helpers/username'; import username from '../../common/helpers/username';
import highlight from '../../common/helpers/highlight'; import highlight from '../../common/helpers/highlight';
@@ -18,12 +19,12 @@ export default class PostPreview extends Component {
const excerpt = highlight(post.contentPlain(), this.attrs.highlight, 300); const excerpt = highlight(post.contentPlain(), this.attrs.highlight, 300);
return ( return (
<a className="PostPreview" route={app.route.post(post)} onclick={this.attrs.onclick}> <Link className="PostPreview" href={app.route.post(post)} onclick={this.attrs.onclick}>
<span className="PostPreview-content"> <span className="PostPreview-content">
{avatar(user)} {avatar(user)}
{username(user)} <span className="PostPreview-excerpt">{excerpt}</span> {username(user)} <span className="PostPreview-excerpt">{excerpt}</span>
</span> </span>
</a> </Link>
); );
} }
} }

View File

@@ -26,17 +26,19 @@ export default class PostStream extends Component {
} }
view() { view() {
function fadeIn(element, isInitialized, context) {
if (!context.fadedIn) $(element).hide().fadeIn();
context.fadedIn = true;
}
let lastTime; let lastTime;
const viewingEnd = this.stream.viewingEnd(); const viewingEnd = this.stream.viewingEnd();
const posts = this.stream.posts(); const posts = this.stream.posts();
const postIds = this.discussion.postIds(); const postIds = this.discussion.postIds();
const postFadeIn = (vnode) => {
$(vnode.dom).addClass('fadeIn');
// 500 is the duration of the fadeIn CSS animation + 100ms,
// so the animation has time to complete
setTimeout(() => $(vnode.dom).removeClass('fadeIn'), 500);
};
const items = posts.map((post, i) => { const items = posts.map((post, i) => {
let content; let content;
const attrs = { 'data-index': this.stream.visibleStart + i }; const attrs = { 'data-index': this.stream.visibleStart + i };
@@ -47,13 +49,13 @@ export default class PostStream extends Component {
content = PostComponent ? PostComponent.component({ post }) : ''; content = PostComponent ? PostComponent.component({ post }) : '';
attrs.key = 'post' + post.id(); attrs.key = 'post' + post.id();
attrs.config = fadeIn; attrs.oncreate = postFadeIn;
attrs['data-time'] = time.toISOString(); attrs['data-time'] = time.toISOString();
attrs['data-number'] = post.number(); attrs['data-number'] = post.number();
attrs['data-id'] = post.id(); attrs['data-id'] = post.id();
attrs['data-type'] = post.contentType(); attrs['data-type'] = post.contentType();
// If the post before this one was more than 4 hours ago, we will // If the post before this one was more than 4 days ago, we will
// display a 'time gap' indicating how long it has been in between // display a 'time gap' indicating how long it has been in between
// the posts. // the posts.
const dt = time - lastTime; const dt = time - lastTime;
@@ -95,7 +97,7 @@ export default class PostStream extends Component {
// is not already doing so, then show a 'write a reply' placeholder. // is not already doing so, then show a 'write a reply' placeholder.
if (viewingEnd && (!app.session.user || this.discussion.canReply())) { if (viewingEnd && (!app.session.user || this.discussion.canReply())) {
items.push( items.push(
<div className="PostStream-item" key="reply"> <div className="PostStream-item" key="reply" data-index={this.stream.count()} oncreate={postFadeIn}>
{ReplyPlaceholder.component({ discussion: this.discussion })} {ReplyPlaceholder.component({ discussion: this.discussion })}
</div> </div>
); );
@@ -127,24 +129,16 @@ export default class PostStream extends Component {
* Start scrolling, if appropriate, to a newly-targeted post. * Start scrolling, if appropriate, to a newly-targeted post.
*/ */
triggerScroll() { triggerScroll() {
if (!this.attrs.targetPost) return; if (!this.stream.needsScroll) return;
const oldTarget = this.prevTarget; const target = this.stream.targetPost;
const newTarget = this.attrs.targetPost; this.stream.needsScroll = false;
if (oldTarget) { if ('number' in target) {
if ('number' in oldTarget && oldTarget.number === newTarget.number) return; this.scrollToNumber(target.number, this.stream.animateScroll);
if ('index' in oldTarget && oldTarget.index === newTarget.index) return; } else if ('index' in target) {
this.scrollToIndex(target.index, this.stream.animateScroll, target.reply);
} }
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);
}
this.prevTarget = newTarget;
} }
/** /**
@@ -155,6 +149,11 @@ export default class PostStream extends Component {
*/ */
onscroll(top = window.pageYOffset) { onscroll(top = window.pageYOffset) {
if (this.stream.paused) return; if (this.stream.paused) return;
this.updateScrubber(top);
if (this.stream.pagesLoading) return;
const marginTop = this.getMarginTop(); const marginTop = this.getMarginTop();
const viewportHeight = $(window).height() - marginTop; const viewportHeight = $(window).height() - marginTop;
const viewportTop = top + marginTop; const viewportTop = top + marginTop;
@@ -180,8 +179,6 @@ export default class PostStream extends Component {
// viewport) to 100ms. // viewport) to 100ms.
clearTimeout(this.calculatePositionTimeout); clearTimeout(this.calculatePositionTimeout);
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this, top), 100); this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this, top), 100);
this.updateScrubber(top);
} }
updateScrubber(top = window.pageYOffset) { updateScrubber(top = window.pageYOffset) {
@@ -194,9 +191,9 @@ export default class PostStream extends Component {
// seen if the browser were scrolled right up to the top of the page, // seen if the browser were scrolled right up to the top of the page,
// and the viewport had a height of 0. // and the viewport had a height of 0.
const $items = this.$('.PostStream-item[data-index]'); const $items = this.$('.PostStream-item[data-index]');
let index = $items.first().data('index') || 0;
let visible = 0; let visible = 0;
let period = ''; let period = '';
let indexFromViewPort = null;
// Now loop through each of the items in the discussion. An 'item' is // 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 // either a single post or a 'gap' of one or more posts that haven't
@@ -222,8 +219,10 @@ export default class PostStream extends Component {
const visibleBottom = Math.min(height, viewportTop + viewportHeight - top); const visibleBottom = Math.min(height, viewportTop + viewportHeight - top);
const visiblePost = visibleBottom - visibleTop; const visiblePost = visibleBottom - visibleTop;
if (top <= viewportTop) { // We take the index of the first item that passed the previous checks.
index = parseFloat($this.data('index')) + visibleTop / height; // It is the item that is first visible in the viewport.
if (indexFromViewPort === null) {
indexFromViewPort = parseFloat($this.data('index')) + visibleTop / height;
} }
if (visiblePost > 0) { if (visiblePost > 0) {
@@ -236,7 +235,10 @@ export default class PostStream extends Component {
if (time) period = time; if (time) period = time;
}); });
this.stream.index = index + 1; // If indexFromViewPort is null, it means no posts are visible in the
// viewport. This can happen, when drafting a long reply post. In that case
// set the index to the last post.
this.stream.index = indexFromViewPort !== null ? indexFromViewPort + 1 : this.stream.count();
this.stream.visible = visible; this.stream.visible = visible;
if (period) this.stream.description = dayjs(period).format('MMMM YYYY'); if (period) this.stream.description = dayjs(period).format('MMMM YYYY');
} }
@@ -288,7 +290,9 @@ export default class PostStream extends Component {
* @return {Integer} * @return {Integer}
*/ */
getMarginTop() { getMarginTop() {
return this.$() && $('#header').outerHeight() + parseInt(this.$().css('margin-top'), 10); const headerId = app.screen() === 'phone' ? '#app-navigation' : '#header';
return this.$() && $(headerId).outerHeight() + parseInt(this.$().css('margin-top'), 10);
} }
/** /**
@@ -309,18 +313,17 @@ export default class PostStream extends Component {
* *
* @param {Integer} index * @param {Integer} index
* @param {Boolean} animate * @param {Boolean} animate
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post * @param {Boolean} reply Whether or not to scroll to the reply placeholder.
* at the given index, instead of the top of it.
* @return {jQuery.Deferred} * @return {jQuery.Deferred}
*/ */
scrollToIndex(index, animate, bottom) { scrollToIndex(index, animate, reply) {
const $item = this.$(`.PostStream-item[data-index=${index}]`); const $item = reply ? $('.PostStream-item:last-child') : this.$(`.PostStream-item[data-index=${index}]`);
return this.scrollToItem($item, animate, true, bottom).then(() => { this.scrollToItem($item, animate, true, reply);
if (index == this.stream.count() - 1) {
this.flashItem(this.$('.PostStream-item:last-child')); if (reply) {
} this.flashItem($item);
}); }
} }
/** /**
@@ -330,12 +333,12 @@ export default class PostStream extends Component {
* @param {Boolean} animate * @param {Boolean} animate
* @param {Boolean} force Whether or not to force scrolling to the item, even * @param {Boolean} force Whether or not to force scrolling to the item, even
* if it is already in the viewport. * if it is already in the viewport.
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post * @param {Boolean} reply Whether or not to scroll to the reply placeholder.
* at the given index, instead of the top of it.
* @return {jQuery.Deferred} * @return {jQuery.Deferred}
*/ */
scrollToItem($item, animate, force, bottom) { scrollToItem($item, animate, force, reply) {
const $container = $('html, body').stop(true); const $container = $('html, body').stop(true);
const index = $item.data('index');
if ($item.length) { if ($item.length) {
const itemTop = $item.offset().top - this.getMarginTop(); const itemTop = $item.offset().top - this.getMarginTop();
@@ -344,10 +347,10 @@ export default class PostStream extends Component {
const scrollBottom = scrollTop + $(window).height(); const scrollBottom = scrollTop + $(window).height();
// If the item is already in the viewport, we may not need to scroll. // If the item is already in the viewport, we may not need to scroll.
// If we're scrolling to the bottom of an item, then we'll make sure the // If we're scrolling to the reply placeholder, we'll make sure its
// bottom will line up with the top of the composer. // bottom will line up with the top of the composer.
if (force || itemTop < scrollTop || itemBottom > scrollBottom) { if (force || itemTop < scrollTop || itemBottom > scrollBottom) {
const top = bottom ? itemBottom - $(window).height() + app.composer.computedHeight() : $item.is(':first-child') ? 0 : itemTop; const top = reply ? itemBottom - $(window).height() + app.composer.computedHeight() : $item.is(':first-child') ? 0 : itemTop;
if (!animate) { if (!animate) {
$container.scrollTop(top); $container.scrollTop(top);
@@ -357,12 +360,43 @@ export default class PostStream extends Component {
} }
} }
return Promise.all([$container.promise(), this.stream.loadPromise]).then(() => { const updateScrubberHeight = () => {
// We manually set the index because we want to display the index of the
// exact post we've scrolled to, not just that of the first post within viewport.
this.updateScrubber(); this.updateScrubber();
const index = $item.data('index'); if (index !== undefined) this.stream.index = index + 1;
};
// If we don't update this before the scroll, the scrubber will start
// at the top, and animate down, which can be confusing
updateScrubberHeight();
this.stream.forceUpdateScrubber = true;
return Promise.all([$container.promise(), this.stream.loadPromise]).then(() => {
m.redraw.sync(); m.redraw.sync();
const scroll = index == 0 ? 0 : $(`.PostStream-item[data-index=${$item.data('index')}]`).offset().top - this.getMarginTop();
$(window).scrollTop(scroll); // Rendering post contents will probably throw off our position.
// To counter this, we'll scroll either:
// - To the reply placeholder (aligned with composer top)
// - To the top of the page if we're on the first post
// - To the top of a post (if that post exists)
// If the post does not currently exist, it's probably
// outside of the range we loaded in, so we won't adjust anything,
// as it will soon be rendered by the "load more" system.
let itemOffset;
if (reply) {
const $placeholder = $('.PostStream-item:last-child');
$(window).scrollTop($placeholder.offset().top + $placeholder.height() - $(window).height() + app.composer.computedHeight());
} else if (index === 0) {
$(window).scrollTop(0);
} else if ((itemOffset = $(`.PostStream-item[data-index=${index}]`).offset())) {
$(window).scrollTop(itemOffset.top - this.getMarginTop());
}
// We want to adjust this again after posts have been loaded in
// and position adjusted so that the scrubber's height is accurate.
updateScrubberHeight();
this.calculatePosition(); this.calculatePosition();
this.stream.paused = false; this.stream.paused = false;
}); });
@@ -374,6 +408,11 @@ export default class PostStream extends Component {
* @param {jQuery} $item * @param {jQuery} $item
*/ */
flashItem($item) { flashItem($item) {
$item.addClass('flash').one('animationend webkitAnimationEnd', () => $item.removeClass('flash')); // This might execute before the fadeIn class has been removed in PostStreamItem's
// oncreate, so we remove it just to be safe and avoid a double animation.
$item.removeClass('fadeIn');
$item.addClass('flash').on('animationend webkitAnimationEnd', (e) => {
$item.removeClass('flash');
});
} }
} }

View File

@@ -90,7 +90,10 @@ export default class PostStreamScrubber extends Component {
} }
onupdate() { onupdate() {
this.stream.loadPromise.then(() => this.updateScrubberValues({ animate: true, forceHeightChange: true })); if (this.stream.forceUpdateScrubber) {
this.stream.forceUpdateScrubber = false;
this.stream.loadPromise.then(() => this.updateScrubberValues({ animate: true, forceHeightChange: true }));
}
} }
oncreate(vnode) { oncreate(vnode) {
@@ -137,7 +140,7 @@ export default class PostStreamScrubber extends Component {
setTimeout(() => this.scrollListener.start()); setTimeout(() => this.scrollListener.start());
this.updateScrubberValues({ animate: true, forceHeightChange: true }); this.stream.loadPromise.then(() => this.updateScrubberValues({ animate: false, forceHeightChange: true }));
} }
onremove() { onremove() {

View File

@@ -1,4 +1,5 @@
import Component from '../../common/Component'; import Component from '../../common/Component';
import Link from '../../common/components/Link';
import UserCard from './UserCard'; import UserCard from './UserCard';
import avatar from '../../common/helpers/avatar'; import avatar from '../../common/helpers/avatar';
import username from '../../common/helpers/username'; import username from '../../common/helpers/username';
@@ -40,11 +41,11 @@ export default class PostUser extends Component {
return ( return (
<div className="PostUser"> <div className="PostUser">
<h3> <h3>
<a route={app.route.user(user)}> <Link href={app.route.user(user)}>
{avatar(user, { className: 'PostUser-avatar' })} {avatar(user, { className: 'PostUser-avatar' })}
{userOnline(user)} {userOnline(user)}
{username(user)} {username(user)}
</a> </Link>
</h3> </h3>
<ul className="PostUser-badges badges">{listItems(user.badges().toArray())}</ul> <ul className="PostUser-badges badges">{listItems(user.badges().toArray())}</ul>
{card} {card}

View File

@@ -1,6 +1,7 @@
import UserPage from './UserPage'; import UserPage from './UserPage';
import LoadingIndicator from '../../common/components/LoadingIndicator'; import LoadingIndicator from '../../common/components/LoadingIndicator';
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
import Link from '../../common/components/Link';
import Placeholder from '../../common/components/Placeholder'; import Placeholder from '../../common/components/Placeholder';
import CommentPost from './CommentPost'; import CommentPost from './CommentPost';
@@ -73,7 +74,7 @@ export default class PostsUserPage extends UserPage {
<li> <li>
<div className="PostsUserPage-discussion"> <div className="PostsUserPage-discussion">
{app.translator.trans('core.forum.user.in_discussion_text', { {app.translator.trans('core.forum.user.in_discussion_text', {
discussion: <a route={app.route.post(post)}>{post.discussion().title()}</a>, discussion: <Link href={app.route.post(post)}>{post.discussion().title()}</Link>,
})} })}
</div> </div>

View File

@@ -1,5 +1,6 @@
import Modal from '../../common/components/Modal'; import Modal from '../../common/components/Modal';
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
import Stream from '../../common/utils/Stream';
/** /**
* The 'RenameDiscussionModal' displays a modal dialog with an input to rename a discussion * The 'RenameDiscussionModal' displays a modal dialog with an input to rename a discussion
@@ -10,7 +11,7 @@ export default class RenameDiscussionModal extends Modal {
this.discussion = this.attrs.discussion; this.discussion = this.attrs.discussion;
this.currentTitle = this.attrs.currentTitle; this.currentTitle = this.attrs.currentTitle;
this.newTitle = m.stream(this.currentTitle); this.newTitle = Stream(this.currentTitle);
} }
className() { className() {

View File

@@ -1,5 +1,6 @@
import ComposerBody from './ComposerBody'; import ComposerBody from './ComposerBody';
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
import Link from '../../common/components/Link';
import icon from '../../common/helpers/icon'; import icon from '../../common/helpers/icon';
import extractText from '../../common/utils/extractText'; import extractText from '../../common/utils/extractText';
@@ -36,9 +37,9 @@ export default class ReplyComposer extends ComposerBody {
'title', 'title',
<h3> <h3>
{icon('fas fa-reply')}{' '} {icon('fas fa-reply')}{' '}
<a route={app.route.discussion(discussion)} onclick={minimizeComposerIfFullScreen}> <Link href={app.route.discussion(discussion)} onclick={minimizeComposerIfFullScreen}>
{discussion.title()} {discussion.title()}
</a> </Link>
</h3> </h3>
); );
@@ -98,10 +99,13 @@ export default class ReplyComposer extends ComposerBody {
}, },
app.translator.trans('core.forum.composer_reply.view_button') app.translator.trans('core.forum.composer_reply.view_button')
); );
alert = app.alerts.show(app.translator.trans('core.forum.composer_reply.posted_message'), { alert = app.alerts.show(
type: 'success', {
controls: [viewButton], type: 'success',
}); controls: [viewButton],
},
app.translator.trans('core.forum.composer_reply.posted_message')
);
} }
this.composer.hide(); this.composer.hide();

View File

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

View File

@@ -21,6 +21,8 @@ import UsersSearchSource from './UsersSearchSource';
* - state: SearchState instance. * - state: SearchState instance.
*/ */
export default class Search extends Component { export default class Search extends Component {
static MIN_SEARCH_LEN = 3;
oninit(vnode) { oninit(vnode) {
super.oninit(vnode); super.oninit(vnode);
this.state = this.attrs.state; this.state = this.attrs.state;
@@ -152,7 +154,7 @@ export default class Search extends Component {
search.searchTimeout = setTimeout(() => { search.searchTimeout = setTimeout(() => {
if (state.isCached(query)) return; if (state.isCached(query)) return;
if (query.length >= 3) { if (query.length >= Search.MIN_SEARCH_LEN) {
search.sources.map((source) => { search.sources.map((source) => {
if (!source.search) return; if (!source.search) return;

View File

@@ -4,6 +4,7 @@ import Button from '../../common/components/Button';
import LogInButtons from './LogInButtons'; import LogInButtons from './LogInButtons';
import extractText from '../../common/utils/extractText'; import extractText from '../../common/utils/extractText';
import ItemList from '../../common/utils/ItemList'; import ItemList from '../../common/utils/ItemList';
import Stream from '../../common/utils/Stream';
/** /**
* The `SignUpModal` component displays a modal dialog with a singup form. * The `SignUpModal` component displays a modal dialog with a singup form.
@@ -24,21 +25,21 @@ export default class SignUpModal extends Modal {
* *
* @type {Function} * @type {Function}
*/ */
this.username = m.stream(this.attrs.username || ''); this.username = Stream(this.attrs.username || '');
/** /**
* The value of the email input. * The value of the email input.
* *
* @type {Function} * @type {Function}
*/ */
this.email = m.stream(this.attrs.email || ''); this.email = Stream(this.attrs.email || '');
/** /**
* The value of the password input. * The value of the password input.
* *
* @type {Function} * @type {Function}
*/ */
this.password = m.stream(this.attrs.password || ''); this.password = Stream(this.attrs.password || '');
} }
className() { className() {
@@ -174,7 +175,7 @@ export default class SignUpModal extends Modal {
* Get the data that should be submitted in the sign-up request. * Get the data that should be submitted in the sign-up request.
* *
* @return {Object} * @return {Object}
* @public * @protected
*/ */
submitData() { submitData() {
const data = { const data = {

View File

@@ -6,6 +6,7 @@ import avatar from '../../common/helpers/avatar';
import username from '../../common/helpers/username'; import username from '../../common/helpers/username';
import icon from '../../common/helpers/icon'; import icon from '../../common/helpers/icon';
import Dropdown from '../../common/components/Dropdown'; import Dropdown from '../../common/components/Dropdown';
import Link from '../../common/components/Link';
import AvatarEditor from './AvatarEditor'; import AvatarEditor from './AvatarEditor';
import listItems from '../../common/helpers/listItems'; import listItems from '../../common/helpers/listItems';
@@ -50,10 +51,10 @@ export default class UserCard extends Component {
{this.attrs.editable ? ( {this.attrs.editable ? (
[AvatarEditor.component({ user, className: 'UserCard-avatar' }), username(user)] [AvatarEditor.component({ user, className: 'UserCard-avatar' }), username(user)]
) : ( ) : (
<a route={app.route.user(user)}> <Link href={app.route.user(user)}>
<div className="UserCard-avatar">{avatar(user)}</div> <div className="UserCard-avatar">{avatar(user)}</div>
{username(user)} {username(user)}
</a> </Link>
)} )}
</h2> </h2>

View File

@@ -27,17 +27,6 @@ export default class UserPage extends Page {
this.user = null; this.user = null;
this.bodyClass = 'App--user'; this.bodyClass = 'App--user';
this.prevUsername = m.route.param('username');
}
onbeforeupdate() {
const currUsername = m.route.param('username');
if (currUsername !== this.prevUsername) {
this.prevUsername = currUsername;
this.loadUser(currUsername);
}
} }
view() { view() {
@@ -113,7 +102,7 @@ export default class UserPage extends Page {
}); });
if (!this.user) { if (!this.user) {
app.store.find('users', username).then(this.show.bind(this)); app.store.find('users', username, { bySlug: true }).then(this.show.bind(this));
} }
} }
@@ -146,7 +135,7 @@ export default class UserPage extends Page {
items.add( items.add(
'posts', 'posts',
<LinkButton href={app.route('user.posts', { username: user.username() })} force icon="far fa-comment"> <LinkButton href={app.route('user.posts', { username: user.username() })} icon="far fa-comment">
{app.translator.trans('core.forum.user.posts_link')} <span className="Button-badge">{user.commentCount()}</span> {app.translator.trans('core.forum.user.posts_link')} <span className="Button-badge">{user.commentCount()}</span>
</LinkButton>, </LinkButton>,
100 100
@@ -154,7 +143,7 @@ export default class UserPage extends Page {
items.add( items.add(
'discussions', 'discussions',
<LinkButton href={app.route('user.discussions', { username: user.username() })} force icon="fas fa-bars"> <LinkButton href={app.route('user.discussions', { username: user.username() })} icon="fas fa-bars">
{app.translator.trans('core.forum.user.discussions_link')} <span className="Button-badge">{user.discussionCount()}</span> {app.translator.trans('core.forum.user.discussions_link')} <span className="Button-badge">{user.discussionCount()}</span>
</LinkButton>, </LinkButton>,
90 90

View File

@@ -1,6 +1,7 @@
import highlight from '../../common/helpers/highlight'; import highlight from '../../common/helpers/highlight';
import avatar from '../../common/helpers/avatar'; import avatar from '../../common/helpers/avatar';
import username from '../../common/helpers/username'; import username from '../../common/helpers/username';
import Link from '../../common/components/Link';
/** /**
* The `UsersSearchSource` finds and displays user search results in the search * The `UsersSearchSource` finds and displays user search results in the search
@@ -48,10 +49,10 @@ export default class UsersSearchResults {
return ( return (
<li className="UserSearchResult" data-index={'users' + user.id()}> <li className="UserSearchResult" data-index={'users' + user.id()}>
<a route={app.route.user(user)}> <Link href={app.route.user(user)}>
{avatar(user)} {avatar(user)}
{{ ...name, text: undefined, children }} {{ ...name, text: undefined, children }}
</a> </Link>
</li> </li>
); );
}), }),

View File

@@ -15,8 +15,9 @@ export { app };
// export { IndexPage, DicsussionList } from './components'; // export { IndexPage, DicsussionList } from './components';
// Export compat API // Export compat API
import compat from './compat'; import compatObj from './compat';
import proxifyCompat from '../common/utils/proxifyCompat';
compat.app = app; compatObj.app = app;
export { compat }; export const compat = proxifyCompat(compatObj, 'forum');

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