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

Compare commits

..

127 Commits

Author SHA1 Message Date
Toby Zerner
53f7112248 Update beta 2 release date 2015-09-15 10:28:47 +09:30
Toby Zerner
a2def83045 Update dependencies 2015-09-14 18:45:49 +09:30
Toby Zerner
cbcad27679 Improve installer validation
Very rough, but works for now. The basic premise being that we need to
collect all user data before we proceed with installation.
2015-09-14 18:13:24 +09:30
Toby Zerner
9bf485359a Prevent XML from being interpreted as PHP short tags 2015-09-14 18:12:36 +09:30
Toby Zerner
60323e0cf9 Bump version number 2015-09-14 16:32:31 +09:30
Toby Zerner
8cccaaaf6b Improve API error handling
- Change 'path' key to 'source.pointer', as per spec
- Add 500 error detail if debug mode is on
2015-09-14 15:40:07 +09:30
Toby Zerner
b7d8afe6a4 Add doctrine/dbal dependency so that migrations can rename columns 2015-09-14 15:31:05 +09:30
Toby Zerner
ff8ec59310 Increase text contrast
closes #390
2015-09-14 15:16:19 +09:30
Toby Zerner
8eda6c7d36 Style disabled fields properly 2015-09-14 14:49:28 +09:30
Toby Zerner
d5b58b3146 Only set XHR authorization header if token isn't empty 2015-09-14 14:49:11 +09:30
Toby Zerner
f00d2b1363 Remove unused component 2015-09-14 14:48:05 +09:30
Toby Zerner
190aa925ac Set cookies to be HTTP only 2015-09-14 14:40:05 +09:30
Toby Zerner
60b19efe0a Password is not necessarily required
e.g. on my LAMP setup, I sometimes use a MySQL account without a
password
2015-09-14 14:39:18 +09:30
Franz Liedke
b2fa6b1a2e Add changelog 2015-09-12 19:11:03 +02:00
Franz Liedke
e7d7df3b0c Cleanup 2015-09-11 09:16:53 +02:00
Franz Liedke
3b5a01e603 Implement more validation in installer 2015-09-11 09:16:43 +02:00
Malay Ladu
b05f83d25a Add green color for online indicator on user cards
Closes #452.
2015-09-11 08:49:07 +02:00
Franz Liedke
902d01712b Remove pointless JSON-API action base class
Cleanup related to #118.
2015-09-09 09:04:49 +02:00
Franz Liedke
502a3787d5 Move remaining extension handling to middleware 2015-09-09 08:56:11 +02:00
Franz Liedke
b8ac49ffcc Move exception handling for Flarum exception classes to middleware
Related to #118.
2015-09-08 22:36:32 +02:00
Franz Liedke
4b4cea4d87 Implement interface to serialize exceptions to JSON-API format
Related to #118
2015-09-08 22:35:39 +02:00
Toby Zerner
c0e7ff5ea1 Give iframes in posts a max width 2015-09-08 11:51:36 +09:30
Toby Zerner
6a5427b600 Make unread discussion titles less overwhelming 2015-09-08 10:27:50 +09:30
Toby Zerner
e8621636c5 Add init hook as a way to effectively monkey patch constructors
Related to #246
2015-09-08 10:27:02 +09:30
Toby Zerner
1aaff46f8e Increase text contrast slightly 2015-09-08 10:24:20 +09:30
Toby Zerner
8c4e095f23 Allow first post to be hidden/restored
Anti-spam extensions may automatically hide the first post in a
discussion, and thus we had to implement smarter permissions so
discussions with zero posts wouldn't be visible to users other than the
author/mods. This change allows those hidden posts to be restored again.
2015-09-07 16:03:45 +09:30
Toby Zerner
05c44ad2df Merge pull request #438 from mtotheikle/default_extension_build_script
Add a default build script for extensions
2015-09-07 11:01:33 +09:30
Toby Zerner
84012ca2fd Preliminary implementation of master API keys
Part of #205
2015-09-07 08:37:33 +09:30
Michael Williams
6393432d92 Add a default build script for extensions 2015-09-06 09:21:04 -07:00
Toby Zerner
f6e21b75e1 Remove unused translations 2015-09-05 16:05:02 +09:30
Toby Zerner
6ee9412f35 Prevent invalid LESS from crashing application
Failure is silent for now... The default LESS will compile without the
invalid customisations. Not sure if we should log an error somewhere
and display it on the admin page?

closes #400
2015-09-04 22:33:26 +09:30
Toby Zerner
478ca90c31 Fallback to English if system-wide default_locale doesn't exist 2015-09-04 22:19:28 +09:30
Toby Zerner
1f8f79d272 Don't require database password confirmation 2015-09-04 21:45:52 +09:30
Franz Liedke
85fc0a3129 Web installer: Fix name of table prefix field 2015-09-04 12:14:48 +02:00
Franz Liedke
db8b9ed0c0 Installer: Fix password confirmation 2015-09-04 12:11:13 +02:00
Franz Liedke
a3d59977b3 Clean up code 2015-09-04 12:05:12 +02:00
Franz Liedke
211d2d25cd Merge pull request #413 from WinterSilence/patch-2
Update RouteCollection::getPath
2015-09-04 12:03:51 +02:00
Franz Liedke
0a992ee9f2 Reorder installer fields 2015-09-04 12:00:39 +02:00
Franz Liedke
42f1abacaf Ask for password confirmation in web installer, too
Closes #405.
2015-09-04 12:00:03 +02:00
Franz Liedke
b26c67dd3c Require password confirmation in console installer
Refs #405.
2015-09-04 11:57:11 +02:00
Toby Zerner
fc7fc41383 Prevent error when hiding/restoring a post with a deleted user 2015-09-04 13:51:13 +09:30
Toby Zerner
a5d3aa9b36 Correctly style hidden post username 2015-09-04 13:50:43 +09:30
Toby Zerner
b18909f1af Fix notifications dropdown appearance on mobile 2015-09-04 13:50:33 +09:30
Toby Zerner
695df18be0 Don't show placeholder when loading discussions 2015-09-04 13:50:17 +09:30
Toby Zerner
ece23de750 API: Add User::hasPermissionLike() and User::getPermissions() 2015-09-04 12:23:50 +09:30
Toby Zerner
4705600d47 Fix typehint 2015-09-04 12:23:27 +09:30
Toby Zerner
8423de754c Fix bad query in isVisibleTo 2015-09-04 12:23:17 +09:30
Toby Zerner
b597e6f8f6 Don't load a custom relation if the relation is already loaded 2015-09-04 12:22:49 +09:30
Toby Zerner
276334ec52 Improve some post/discussion permission logic
- Allow users to see their own posts, even if they have been hidden by
someone else
- Don't require hiding a post to be necessarily attributed to a user
- Hide discussions with zero posts, unless the user can edit posts, or
they are the discussion author
2015-09-04 12:22:27 +09:30
Toby Zerner
9277fca0ec Slightly darken light grey text 2015-09-04 12:19:20 +09:30
Toby Zerner
9ca67635fb Remove unused translations 2015-09-04 12:19:09 +09:30
Toby Zerner
7a6c48c30b Correct check that a translation is an object
typeof translation === 'object' returns true when translation is null
2015-09-04 12:19:02 +09:30
Toby Zerner
f0186d7674 API: Add typehints 2015-09-04 12:18:09 +09:30
Toby Zerner
9bf6862c6d Clean up Post CSS 2015-09-04 12:17:30 +09:30
Toby Zerner
44f460cb11 Prevent ItemList crash when item is a number 2015-09-04 12:17:01 +09:30
Toby Zerner
7cce5b02ba Allow non-array value to be passed into listItems
Useful in some scenarios when using JSX
2015-09-04 12:16:23 +09:30
Toby Zerner
722058f2fb Move generic util into lib
Might come in handy for the admin section later on
2015-09-04 12:15:41 +09:30
Toby Zerner
70815b024a Make Dropdown and NotificationsDropdown components more extensible 2015-09-04 12:15:11 +09:30
Toby Zerner
7269385786 Make a copy of props passed into a component
Prevents some rare errors where the props object is read-only, and is
generally safer.
2015-09-04 12:13:55 +09:30
Toby Zerner
2f8a449b74 Simplify notification markup 2015-09-04 12:12:48 +09:30
Toby Zerner
b3aa0298d5 Fix use of "new" keyword making eslint angry 2015-09-04 12:12:21 +09:30
Toby Zerner
e192402a42 Add item priorities 2015-09-04 12:11:45 +09:30
Toby Zerner
c81ceafb54 Clean up editorconfig, eslint, npm dependencies 2015-09-04 12:11:34 +09:30
Toby Zerner
93b6f11484 Merge pull request #418 from Luceos/registration_fix
call to $this-> assertValidPassword from static context
2015-09-04 10:46:35 +09:30
Daniel Klabbers
0413daab74 call to $this-> assertValidPassword from static context 2015-09-04 00:00:24 +02:00
Franz Liedke
f0c240f863 Add a first empty state to the discussion list 2015-09-03 09:59:33 +02:00
Franz Liedke
21dd516eaa Fix code style issues 2015-09-03 08:48:26 +02:00
Franz Liedke
3c9d851889 Check prerequisites in console installer, too 2015-09-03 08:42:16 +02:00
Franz Liedke
942db77416 Extract installation prerequisites into composable classes and use those in the web-based installer 2015-09-03 08:23:34 +02:00
Anton
04db806995 Update RouteCollection.php 2015-09-02 19:22:40 +03:00
Anton
f3bc7d1c23 Update RouteCollection::getPath
This version work faster - old code create closure at every calling getPath
2015-09-02 10:58:44 +03:00
Franz Liedke
bd47653377 Merge pull request #403 from mtotheikle/allow-extra-signup-data
Allow support for passing extra signup data to API
2015-09-01 18:28:53 +02:00
Michael Williams
07ed4d10c0 Allow support for passing extra signup data to API 2015-09-01 07:58:14 -07:00
Franz Liedke
25141c0f2f Merge pull request #402 from johannsa/master
Fixes enable extension in EntensionManager
2015-09-01 14:58:58 +02:00
Johann Rodríguez
e35bb9e400 Fix enable extension in EntensionManager 2015-09-01 12:09:11 +01:00
Franz Liedke
753a846e7a Check MySQL version when installing on console
Related to #364.
2015-09-01 08:02:07 +02:00
Franz Liedke
d3e57d77b4 Fix typehint 2015-09-01 07:49:06 +02:00
Toby Zerner
6e0bffe395 API: Add more locale registration APIs 2015-09-01 10:08:37 +09:30
Toby Zerner
eec4e97d65 Tidy up default extension metadata 2015-09-01 10:08:37 +09:30
Toby Zerner
bf83b36882 Remove redundant call 2015-09-01 10:08:37 +09:30
Toby Zerner
6aafe54ee7 Fix potential error when discussion doesn't exist
Not sure how this could be the case, but can't hurt to add the checks.
addresses #343
2015-09-01 10:08:36 +09:30
Franz Liedke
c91f8de1f5 Be more consistent in case AJAX does not work 2015-08-31 22:37:04 +02:00
Franz Liedke
5783dbe77b Try to fix Safari bug during installation 2015-08-31 22:35:12 +02:00
Franz Liedke
ab496eb8f8 Merge pull request #387 from rodymol123/master
Align select input properly
2015-08-31 22:04:46 +02:00
Rody Molenaar
6f13a246db Align select 2015-08-31 21:57:43 +02:00
Franz Liedke
4c34d0867d Add field for table prefix in web installer
Related to #269.
2015-08-31 09:10:27 +02:00
Toby Zerner
f2a3a0cb10 Require the PHP fileinfo extension
It's required for the intervention/image package
2015-08-31 15:29:20 +09:30
Toby Zerner
5b7527144c Permit trailing slashes in discussion/user URLs
closes #334
2015-08-31 14:43:42 +09:30
Toby Zerner
6c169499b5 Only migrate enabled extensions when upgrading
Also remove the Extension::install() and Extension::uninstall()
methods, because they add nothing that can't be done with migrations.
2015-08-31 14:35:52 +09:30
Toby Zerner
5e22458014 Installer: Prevent crash when views directory is not writable
Use plain PHP templates instead of Blade templates so there is nothing
that needs to be written.

closes #376
2015-08-31 14:25:16 +09:30
Toby Zerner
c72bdc8238 Fix Laravel 5.1 compat
closes #307
2015-08-31 14:07:11 +09:30
Toby Zerner
2438bbfd41 Reload post if user relationship isn't loaded
May fix #295, but haven't been able to reproduce/test.
2015-08-31 14:03:08 +09:30
Toby Zerner
5af5f1fc77 Properly style modal title bar on mobile
closes #286
2015-08-31 13:46:59 +09:30
Toby Zerner
e7f4e5060c Use GroupBadge component to display user badges
closes #277
2015-08-31 13:44:05 +09:30
Toby Zerner
bcc16a3329 Add target="_blank" and rel="nofollow" to all formatted links
closes #247
2015-08-31 13:36:54 +09:30
Toby Zerner
283abb88c2 Fix reply composer preview button
closes #238
2015-08-31 13:27:04 +09:30
Toby Zerner
af2307868a Fix JavaScript style 2015-08-31 13:19:51 +09:30
Toby Zerner
f9d724738c Add syntax highlighting for code blocks
We might consider extracting this into an extension, but TextFormatter
does syntax highlighting for code blocks by default in live previews
anyway.

closes #248
2015-08-31 13:17:16 +09:30
Toby Zerner
42e722d824 Fix incorrect translation output
app.trans returns a VirtualElement, so there's no need to wrap it in
m.trust.

closes #237
2015-08-31 12:41:36 +09:30
Toby Zerner
f5517fbd88 Validate password length
We can't do this using the ValidatesBeforeSave trait because the
password has been hashed by then. Instead, we must validate the
original password as it comes in.
2015-08-31 12:38:15 +09:30
Toby Zerner
6a0e3fcf2d Validate post length to prevent truncation
closes #235
2015-08-31 12:36:19 +09:30
Toby Zerner
0ae2d18f28 Extract base Page class 2015-08-31 12:05:33 +09:30
Toby Zerner
0474f410a4 Refactor start/endComputation into lazyRedraw method 2015-08-31 12:04:51 +09:30
Toby Zerner
9f28b4e8dc Require extensions directory to be writable 2015-08-31 11:22:57 +09:30
Toby Zerner
f44e9f5140 Remove <script> tags from plain post content
closes #362
2015-08-31 10:49:24 +09:30
Franz Liedke
3e14ef0714 Fix last commit 2015-08-30 15:02:48 +02:00
Franz Liedke
c999226449 Travis: Use Composer scripts 2015-08-30 15:00:01 +02:00
Franz Liedke
ba097dc147 Add Composer scripts 2015-08-30 14:59:10 +02:00
Franz Liedke
1d1cc9e443 Fix asset URL generation
This is important when Flarum is deployed in a subfolder.

Closes #291.
2015-08-29 22:38:31 +02:00
Franz Liedke
f5d2d2ff79 Installer: Check for openssl extension
Closes #296.
2015-08-29 22:07:50 +02:00
Franz Liedke
a04acca92e Allow Ctrl key for submitting posts, too
Closes #276.
2015-08-29 17:09:14 +02:00
Toby Zerner
4033319ed0 Merge pull request #338 from lbausch/discussion_validation
Add validation to limit discussion title length
2015-08-29 18:58:42 +09:30
Lorenz Bausch
a4fe6f3ce3 limit max title length to 80 characters 2015-08-29 11:26:18 +02:00
Lorenz Bausch
ae06b45bc1 remove executable flag from file 2015-08-29 11:23:05 +02:00
Toby Zerner
be33761950 Include the extension stub's .gitignore file 2015-08-29 18:29:33 +09:30
Toby Zerner
015aaaa899 Add CONTRIBUTING
(copied from flarum/flarum)
2015-08-29 18:29:19 +09:30
Toby Zerner
67f6b8599d Allow string primitives to be added to ItemLists 2015-08-29 15:25:36 +09:30
Toby Zerner
12d5e48b95 Add helpful hint on how to upgrade PHP 2015-08-29 14:44:55 +09:30
Toby Zerner
a41e3e66ce Merge pull request #299 from huytd/fix-wordwrap
Fix text overlap in search result with long content
2015-08-29 08:31:37 +09:30
Huy Tran
874c023f8a Fix text overlap in search result with long content 2015-08-28 15:18:17 -07:00
Toby Zerner
bb3c57f9a4 Fix default database name 2015-08-28 14:53:01 +09:30
Toby Zerner
98a79e957d Merge pull request #278 from huytd/https-web-font
Make Google Web Font work on both HTTP and HTTPS
2015-08-28 12:50:12 +09:30
Huy Tran
cf68c95fb8 Make Google Web Font work on both HTTP and HTTPS 2015-08-27 20:17:42 -07:00
Toby Zerner
d5074c5286 Use gd as the image driver
Presumably gd is more common than imagick, and we already check for it
during installation.
2015-08-28 05:41:25 +09:30
Toby Zerner
41019597d0 Require the PHP DOM extension 2015-08-28 05:30:27 +09:30
Toby Zerner
b689c9de3b Allow non-admins to reset their password
The EditUser command requires the actor to have the "edit" permission,
which is only granted to admins. We don't want to allow users to change
their own password via the API, though. So instead of dispatching the
command, we'll just update the user's password directly in the action.
2015-08-28 03:38:55 +09:30
Toby Zerner
baed659668 Fix reset password error 2015-08-28 02:16:28 +09:30
125 changed files with 2106 additions and 819 deletions

View File

@@ -12,21 +12,8 @@ insert_final_newline = true
indent_style = space
indent_size = 2
[*.js]
indent_style = space
indent_size = 2
[*.{css,less}]
indent_style = space
indent_size = 2
[*.html]
indent_style = space
indent_size = 2
[*.{diff,md}]
trim_trailing_whitespace = false
[*.php]
indent_style = space
indent_size = 4

View File

@@ -27,7 +27,12 @@
"$": true,
"moment": true
},
"plugins": [
"react"
],
"rules": {
"react/jsx-uses-vars": 1,
/**
* Strict mode
*/

1
.gitattributes vendored
View File

@@ -1,4 +1,5 @@
.gitattributes export-ignore
.gitignore export-ignore
stubs/extension/.gitignore -export-ignore
.gitmodules export-ignore
.travis.yml export-ignore

View File

@@ -14,8 +14,8 @@ before_script:
- php composer.phar install
script:
- vendor/bin/phpcs --standard=PSR2 -np src
- vendor/bin/phpspec run
- php composer.phar style
- php composer.phar test
notifications:
email:

44
CHANGELOG.md Normal file
View File

@@ -0,0 +1,44 @@
# Change Log
All notable changes to Flarum and its bundled extensions will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).
## [Unreleased][unreleased]
*nothing yet*
## [0.1.0-beta.2] - 2015-09-15
### Added
- Check prerequisites (PHP version, extensions, etc.) before installation (#364)
- Enforce maximum title and post length through validation (#53, #338)
- Ctrl+Enter submits posts (#276)
- Syntax highlighting for code blocks (#248)
- All links open in new window, receive rel=nofollow attribute (#247)
- Default build script for extensions (#438)
- Input validation in installer
### Changed
- Ask for admin password confirmation in installer (#405)
- Increased some text contrasts for accessibility (#390)
### Fixed
- Discussion list did not work with non-empty database prefix (#269, #380)
- Non-admins could not reset their password (#229)
- Requests ending with a slash resulted in a 404 (#334)
- In rare cases, posts did not load correctly (#295)
- Avatars did not show up when installed in a subfolder (#291)
- Installer crashed when views directory was not writable (#376)
- Table prefix could not be set in web installer (#269)
- Enabling an extension disabled all other extensions (#402)
- Invalid custom CSS could crash the application (#400)
- First posts could not be restored or deleted
- Several design bugs
- Set cookies to be HTTP-only
- Tags: Sometimes, tags could not be dragged for reordering in the admin panel (#341)
- Suspend: Use correct column name in when migrating database
- Lock: Check for correct permission when displaying lock control
- Likes: Allow liking permissions to be configured
## 0.1.0-beta - 2015-08-27
First Version
[unreleased]: https://github.com/flarum/core/compare/v0.1.0-beta.2...HEAD
[0.1.0-beta.2]: https://github.com/flarum/core/compare/v0.1.0-beta...v0.1.0-beta.2

13
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,13 @@
# Contributing to Flarum
Thanks for your interest in contributing to Flarum! Please read the [Contributing docs](http://flarum.org/docs/contributing) to learn how you can help.
## Contributor License Agreement
By contributing your code to Flarum you grant Toby Zerner a non-exclusive, irrevocable, worldwide, royalty-free, sublicenseable, transferable license under all of Your relevant intellectual property rights (including copyright, patent, and any other rights), to use, copy, prepare derivative works of, distribute and publicly perform and display the Contributions on any licensing terms, including without limitation: (a) open source licenses like the MIT license; and (b) binary, proprietary, or commercial licenses. Except for the licenses granted herein, You reserve all right, title, and interest in and to the Contribution.
You confirm that you are able to grant us these rights. You represent that You are legally entitled to grant the above license. If Your employer has rights to intellectual property that You create, You represent that You have received permission to make the Contributions on behalf of that employer, or that Your employer has waived such rights for the Contributions.
You represent that the Contributions are Your original works of authorship, and to Your knowledge, no other person claims, or has the right to claim, any right in any invention or patent related to the Contributions. You also represent that You are not legally obligated, whether by entering into an agreement or otherwise, in any way that conflicts with the terms of this license.
Toby Zerner acknowledges that, except as explicitly described in this Agreement, any Contribution which you provide is on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.

View File

@@ -26,13 +26,14 @@
"tobscure/json-api": "^0.1.1",
"oyejorge/less.php": "~1.5",
"intervention/image": "^2.3.0",
"s9e/text-formatter": "^0.1.0",
"s9e/text-formatter": "^0.3.2",
"psr/http-message": "^1.0",
"zendframework/zend-diactoros": "^1.1",
"nikic/fast-route": "^0.6",
"dflydev/fig-cookies": "^1.0",
"symfony/console": "^2.7",
"symfony/yaml": "^2.7"
"symfony/yaml": "^2.7",
"doctrine/dbal": "^2.5"
},
"require-dev": {
"squizlabs/php_codesniffer": "2.*",
@@ -45,5 +46,9 @@
"files": [
"src/helpers.php"
]
},
"scripts": {
"test": "phpspec run",
"style": "phpcs --standard=PSR2 -np src"
}
}

852
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,66 +0,0 @@
import Component from 'flarum/Component';
import humanTime from 'flarum/helpers/humanTime';
import avatar from 'flarum/helpers/avatar';
/**
* The `Activity` component represents a piece of activity of a user's activity
* feed. Subclasses should implement the `description` and `content` methods.
*
* ### Props
*
* - `activity`
*
* @abstract
*/
export default class Activity extends Component {
view() {
const activity = this.props.activity;
return (
<div className="Activity">
{avatar(this.user(), {className: 'Activity-avatar'})}
<div className="Activity-header">
<strong className="Activity-description">{this.description()}</strong>
{humanTime(this.time())}
</div>
{this.content()}
</div>
);
}
/**
* Get the user whose avatar should be displayed.
*
* @return {User}
* @abstract
*/
user() {
}
/**
* Get the time of the activity.
*
* @return {Date}
* @abstract
*/
time() {
}
/**
* Get the description of the activity.
*
* @return {VirtualElement}
*/
description() {
}
/**
* Get the content to show below the activity description.
*
* @return {VirtualElement}
*/
content() {
}
}

View File

@@ -39,7 +39,7 @@ export default class ChangeEmailModal extends Modal {
return (
<div className="Modal-body">
<div className="Form Form--centered">
<p className="helpText">{m.trust(app.trans('core.confirmation_email_sent', {email: <strong>{this.email()}</strong>}))}</p>
<p className="helpText">{app.trans('core.confirmation_email_sent', {email: <strong>{this.email()}</strong>})}</p>
<div className="Form-group">
<a href={'http://' + emailProviderName} className="Button Button--primary Button--block">
{app.trans('core.go_to', {location: emailProviderName})}

View File

@@ -1,4 +1,4 @@
/*global s9e*/
/*global s9e, hljs*/
import Post from 'flarum/components/Post';
import classList from 'flarum/utils/classList';
@@ -54,6 +54,49 @@ export default class CommentPost extends Post {
];
}
config(isInitialized, context) {
super.config(...arguments);
const contentHtml = this.isEditing() ? '' : this.props.post.contentHtml();
if (context.contentHtml !== contentHtml) {
if (typeof hljs === 'undefined') {
this.loadHljs();
} else {
this.$('pre code').each(function(i, elm) {
hljs.highlightBlock(elm);
});
}
}
context.contentHtml = contentHtml;
}
/**
* Load the highlight.js library and initialize highlighting when done.
*
* @private
*/
loadHljs() {
const head = document.getElementsByTagName('head')[0];
const stylesheet = document.createElement('link');
stylesheet.type = 'text/css';
stylesheet.rel = 'stylesheet';
stylesheet.href = '//cdnjs.cloudflare.com/ajax/libs/highlight.js/8.7/styles/default.min.css';
head.appendChild(stylesheet);
const script = document.createElement('script');
script.type = 'text/javascript';
script.onload = () => {
hljs._ = {};
hljs.initHighlighting();
};
script.async = true;
script.src = '//cdnjs.cloudflare.com/ajax/libs/highlight.js/8.7/highlight.min.js';
head.appendChild(script);
}
isEditing() {
return app.composer.component instanceof EditPostComposer &&
app.composer.component.props.post === this.props.post &&
@@ -66,8 +109,8 @@ export default class CommentPost extends Post {
return {
className: classList({
'CommentPost': true,
'hidden': post.isHidden(),
'edited': post.isEdited(),
'Post--hidden': post.isHidden(),
'Post--edited': post.isEdited(),
'revealContent': this.revealContent,
'editing': this.isEditing()
})

View File

@@ -2,6 +2,7 @@ import Component from 'flarum/Component';
import DiscussionListItem from 'flarum/components/DiscussionListItem';
import Button from 'flarum/components/Button';
import LoadingIndicator from 'flarum/components/LoadingIndicator';
import Placeholder from 'flarum/components/Placeholder';
/**
* The `DiscussionList` component displays a list of discussions.
@@ -53,6 +54,15 @@ export default class DiscussionList extends Component {
});
}
if (this.discussions.length === 0 && !this.loading) {
const text = 'Looks like there are no discussions here. Why don\'t you create a new one?';
return (
<div className="DiscussionList">
{Placeholder.component({text})}
</div>
);
}
return (
<div className="DiscussionList">
<ul className="DiscussionList-discussions">
@@ -179,12 +189,7 @@ export default class DiscussionList extends Component {
this.loading = false;
this.moreResults = !!results.payload.links.next;
// Since this may be called during the component's constructor, i.e. in the
// middle of a redraw, forcing another redraw would not bode well. Instead
// we start/end a computation so Mithril will only redraw if it isn't
// already doing so.
m.startComputation();
m.endComputation();
m.lazyRedraw();
return results;
}

View File

@@ -51,6 +51,7 @@ export default class DiscussionListItem extends Component {
const discussion = this.props.discussion;
const startUser = discussion.startUser();
const isUnread = discussion.isUnread();
const isRead = discussion.isRead();
const showUnread = !this.showRepliesCount() && isUnread;
const jumpTo = Math.min(discussion.lastPostNumber(), (discussion.readNumber() || 0) + 1);
const relevantPosts = this.props.params.q ? discussion.relevantPosts() : [];
@@ -71,7 +72,7 @@ export default class DiscussionListItem extends Component {
{icon('check')}
</a>
<div className={'DiscussionListItem-content Slidable-content' + (isUnread ? ' unread' : '')}>
<div className={'DiscussionListItem-content Slidable-content' + (isUnread ? ' unread' : '') + (isRead ? ' read' : '')}>
<a href={startUser ? app.route.user(startUser) : '#'}
className="DiscussionListItem-author"
title={extractText(app.trans('core.discussion_started', {user: startUser, ago: humanTime(discussion.startTime())}))}

View File

@@ -1,4 +1,4 @@
import Component from 'flarum/Component';
import Page from 'flarum/components/Page';
import ItemList from 'flarum/utils/ItemList';
import DiscussionHero from 'flarum/components/DiscussionHero';
import PostStream from 'flarum/components/PostStream';
@@ -6,15 +6,13 @@ import PostStreamScrubber from 'flarum/components/PostStreamScrubber';
import LoadingIndicator from 'flarum/components/LoadingIndicator';
import SplitDropdown from 'flarum/components/SplitDropdown';
import listItems from 'flarum/helpers/listItems';
import mixin from 'flarum/utils/mixin';
import evented from 'flarum/utils/evented';
import DiscussionControls from 'flarum/utils/DiscussionControls';
/**
* The `DiscussionPage` component displays a whole discussion page, including
* the discussion list pane, the hero, the posts, and the sidebar.
*/
export default class DiscussionPage extends mixin(Component, evented) {
export default class DiscussionPage extends Page {
constructor(...args) {
super(...args);
@@ -43,18 +41,14 @@ export default class DiscussionPage extends mixin(Component, evented) {
app.pane.enable();
app.pane.hide();
if (app.current instanceof DiscussionPage) {
if (app.previous instanceof DiscussionPage) {
m.redraw.strategy('diff');
}
}
// Push onto the history stack, but use a generalised key so that navigating
// to a few different discussions won't override the behaviour of the back
// button.
app.history.push('discussion');
app.current = this;
app.drawer.hide();
app.modal.close();
this.bodyClass = 'App--discussion';
}
onunload(e) {
@@ -67,9 +61,9 @@ export default class DiscussionPage extends mixin(Component, evented) {
if (idParam && idParam.split('-')[0] === this.discussion.id()) {
e.preventDefault();
const near = Number(m.route.param('near')) || 1;
const near = m.route.param('near') || '1';
if (near !== Number(this.near)) {
if (near !== String(this.near)) {
this.stream.goToNumber(near);
}
@@ -121,13 +115,6 @@ export default class DiscussionPage extends mixin(Component, evented) {
);
}
config(isInitialized, context) {
if (isInitialized) return;
$('#app').addClass('App--discussion');
context.onunload = () => $('#app').removeClass('App--discussion');
}
/**
* Clear and reload the discussion.
*/
@@ -141,20 +128,15 @@ export default class DiscussionPage extends mixin(Component, evented) {
// component for the first time on page load, then any calls to m.redraw
// will be ineffective and thus any configs (scroll code) will be run
// before stuff is drawn to the page.
setTimeout(this.init.bind(this, preloadedDiscussion));
setTimeout(this.show.bind(this, preloadedDiscussion));
} else {
const params = this.requestParams();
app.store.find('discussions', m.route.param('id').split('-')[0], params)
.then(this.init.bind(this));
.then(this.show.bind(this));
}
// Since this may be called during the component's constructor, i.e. in the
// middle of a redraw, forcing another redraw would not bode well. Instead
// we start/end a computation so Mithril will only redraw if it isn't
// already doing so.
m.startComputation();
m.endComputation();
m.lazyRedraw();
}
/**
@@ -174,7 +156,7 @@ export default class DiscussionPage extends mixin(Component, evented) {
*
* @param {Discussion} discussion
*/
init(discussion) {
show(discussion) {
this.discussion = discussion;
app.setTitle(discussion.title());
@@ -202,8 +184,6 @@ export default class DiscussionPage extends mixin(Component, evented) {
this.stream = new PostStream({discussion, includedPosts});
this.stream.on('positionChanged', this.positionChanged.bind(this));
this.stream.goToNumber(m.route.param('near') || includedPosts[0].number(), true);
this.trigger('loaded', discussion);
}
/**

View File

@@ -23,8 +23,8 @@ export default class DiscussionRenamedPost extends EventPost {
const newTitle = post.content()[1];
return {
old: <strong className="DiscussionRenamedPost-old">{oldTitle}</strong>,
new: <strong className="DiscussionRenamedPost-new">{newTitle}</strong>
'old': <strong className="DiscussionRenamedPost-old">{oldTitle}</strong>,
'new': <strong className="DiscussionRenamedPost-new">{newTitle}</strong>
};
}
}

View File

@@ -30,7 +30,7 @@ export default class HeaderSecondary extends Component {
items() {
const items = new ItemList();
items.add('search', app.search.render());
items.add('search', app.search.render(), 30);
if (Object.keys(app.locales).length > 1) {
const locales = [];
@@ -54,12 +54,12 @@ export default class HeaderSecondary extends Component {
items.add('locale', SelectDropdown.component({
children: locales,
buttonClassName: 'Button Button--link'
}));
}), 20);
}
if (app.session.user) {
items.add('notifications', NotificationsDropdown.component());
items.add('session', SessionDropdown.component());
items.add('notifications', NotificationsDropdown.component(), 10);
items.add('session', SessionDropdown.component(), 0);
} else {
if (app.forum.attribute('allowSignUp')) {
items.add('signUp',
@@ -67,7 +67,7 @@ export default class HeaderSecondary extends Component {
children: app.trans('core.sign_up'),
className: 'Button Button--link',
onclick: () => app.modal.show(new SignUpModal())
})
}), 10
);
}
@@ -76,7 +76,7 @@ export default class HeaderSecondary extends Component {
children: app.trans('core.log_in'),
className: 'Button Button--link',
onclick: () => app.modal.show(new LogInModal())
})
}), 0
);
}

View File

@@ -1,6 +1,6 @@
import Component from 'flarum/Component';
import { extend } from 'flarum/extend';
import Page from 'flarum/components/Page';
import ItemList from 'flarum/utils/ItemList';
import affixSidebar from 'flarum/utils/affixSidebar';
import listItems from 'flarum/helpers/listItems';
import DiscussionList from 'flarum/components/DiscussionList';
import WelcomeHero from 'flarum/components/WelcomeHero';
@@ -16,22 +16,22 @@ import SelectDropdown from 'flarum/components/SelectDropdown';
* The `IndexPage` component displays the index page, including the welcome
* hero, the sidebar, and the discussion list.
*/
export default class IndexPage extends Component {
export default class IndexPage extends Page {
constructor(...args) {
super(...args);
// If the user is returning from a discussion page, then take note of which
// discussion they have just visited. After the view is rendered, we will
// scroll down so that this discussion is in view.
if (app.current instanceof DiscussionPage) {
this.lastDiscussion = app.current.discussion;
if (app.previous instanceof DiscussionPage) {
this.lastDiscussion = app.previous.discussion;
}
// If the user is coming from the discussion list, then they have either
// just switched one of the parameters (filter, sort, search) or they
// probably want to refresh the results. We will clear the discussion list
// cache so that results are reloaded.
if (app.current instanceof IndexPage) {
if (app.previous instanceof IndexPage) {
app.cache.discussionList = null;
}
@@ -55,9 +55,8 @@ export default class IndexPage extends Component {
}
app.history.push('index');
app.current = this;
app.drawer.hide();
app.modal.close();
this.bodyClass = 'App--index';
}
onunload() {
@@ -71,7 +70,7 @@ export default class IndexPage extends Component {
<div className="IndexPage">
{this.hero()}
<div className="container">
<nav className="IndexPage-nav sideNav" config={affixSidebar}>
<nav className="IndexPage-nav sideNav">
<ul>{listItems(this.sidebarItems().toArray())}</ul>
</nav>
<div className="IndexPage-results sideNavOffset">
@@ -87,13 +86,11 @@ export default class IndexPage extends Component {
}
config(isInitialized, context) {
super.config(...arguments);
if (isInitialized) return;
$('#app').addClass('App--index');
context.onunload = () => {
$('#app').removeClass('App--index')
.css('min-height', '');
};
extend(context, 'onunload', () => $('#app').css('min-height', ''));
app.setTitle('');
app.setTitleCount(0);

View File

@@ -19,18 +19,21 @@ export default class Notification extends Component {
const href = this.href();
return (
<div className={'Notification Notification--' + notification.contentType() + ' ' + (!notification.isRead() ? 'unread' : '')}
onclick={this.markAsRead.bind(this)}>
<a href={href} config={href.indexOf('://') === -1 ? m.route : undefined}>
{avatar(notification.sender())}
{icon(this.icon(), {className: 'Notification-icon'})}
<span className="Notification-content">{this.content()}</span>
{humanTime(notification.time())}
<div className="Notification-excerpt">
{this.excerpt()}
</div>
</a>
</div>
<a className={'Notification Notification--' + notification.contentType() + ' ' + (!notification.isRead() ? 'unread' : '')}
href={href}
config={function(element, isInitialized) {
if (href.indexOf('://') === -1) m.route.apply(this, arguments);
if (!isInitialized) $(element).click(this.markAsRead.bind(this));
}}>
{avatar(notification.sender())}
{icon(this.icon(), {className: 'Notification-icon'})}
<span className="Notification-content">{this.content()}</span>
{humanTime(notification.time())}
<div className="Notification-excerpt">
{this.excerpt()}
</div>
</a>
);
}

View File

@@ -1,8 +1,18 @@
import Component from 'flarum/Component';
import Dropdown from 'flarum/components/Dropdown';
import icon from 'flarum/helpers/icon';
import NotificationList from 'flarum/components/NotificationList';
export default class NotificationsDropdown extends Component {
export default class NotificationsDropdown extends Dropdown {
static initProps(props) {
props.className = props.className || 'NotificationsDropdown';
props.buttonClassName = props.buttonClassName || 'Button Button--flat';
props.menuClassName = props.menuClassName || 'Dropdown-menu--right';
props.label = props.label || app.trans('core.notifications');
props.icon = props.icon || 'bell';
super.initProps(props);
}
constructor(...args) {
super(...args);
@@ -16,35 +26,51 @@ export default class NotificationsDropdown extends Component {
this.list = new NotificationList();
}
view() {
const user = app.session.user;
const unread = user.unreadNotificationsCount();
getButton() {
const unread = this.getUnreadCount();
const vdom = super.getButton();
vdom.attrs.className += (unread ? ' unread' : '');
vdom.attrs.onclick = this.onclick.bind(this);
return vdom;
}
getButtonContent() {
const unread = this.getUnreadCount();
return [
icon(this.props.icon, {className: 'Button-icon'}),
unread ? <span className="NotificationsDropdown-unread">{unread}</span> : '',
<span className="Button-label">{this.props.label}</span>
];
}
getMenu() {
return (
<div className="Dropdown NotificationsDropdown">
<a href="javascript:;"
className={'Dropdown-toggle Button Button--flat NotificationsDropdown-button' + (unread ? ' unread' : '')}
data-toggle="dropdown"
onclick={this.onclick.bind(this)}>
<span className="Button-icon">{unread || icon('bell')}</span>
<span className="Button-label">{app.trans('core.notifications')}</span>
</a>
<div className="Dropdown-menu Dropdown-menu--right" onclick={this.menuClick.bind(this)}>
{this.showing ? this.list.render() : ''}
</div>
<div className={'Dropdown-menu ' + this.props.menuClassName} onclick={this.menuClick.bind(this)}>
{this.showing ? this.list.render() : ''}
</div>
);
}
onclick() {
if (app.drawer.isOpen()) {
m.route(app.route('notifications'));
this.goToRoute();
} else {
this.showing = true;
this.list.load();
}
}
goToRoute() {
m.route(app.route('notifications'));
}
getUnreadCount() {
return app.session.user.unreadNotificationsCount();
}
menuClick(e) {
// Don't close the notifications dropdown if the user is opening a link in a
// new tab or window.

View File

@@ -1,21 +1,20 @@
import Component from 'flarum/Component';
import Page from 'flarum/components/Page';
import NotificationList from 'flarum/components/NotificationList';
/**
* The `NotificationsPage` component shows the notifications list. It is only
* used on mobile devices where the notifications dropdown is within the drawer.
*/
export default class NotificationsPage extends Component {
export default class NotificationsPage extends Page {
constructor(...args) {
super(...args);
app.current = this;
app.history.push('notifications');
app.drawer.hide();
app.modal.close();
this.list = new NotificationList();
this.list.load();
this.bodyClass = 'App--notifications';
}
view() {

View File

@@ -0,0 +1,35 @@
import Component from 'flarum/Component';
/**
* The `Page` component
*
* @abstract
*/
export default class Page extends Component {
constructor(...args) {
super(...args);
app.previous = app.current;
app.current = this;
app.drawer.hide();
app.modal.close();
/**
* A class name to apply to the body while the route is active.
*
* @type {String}
*/
this.bodyClass = '';
}
config(isInitialized, context) {
if (isInitialized) return;
if (this.bodyClass) {
$('#app').addClass(this.bodyClass);
context.onunload = () => $('#app').removeClass(this.bodyClass);
}
}
}

View File

@@ -38,7 +38,7 @@ class PostStream extends mixin(Component, evented) {
this.loadPageTimeouts = {};
this.pagesLoading = 0;
this.init(this.props.includedPosts);
this.show(this.props.includedPosts);
}
/**
@@ -153,7 +153,7 @@ class PostStream extends mixin(Component, evented) {
*
* @param {Post[]} posts
*/
init(posts) {
show(posts) {
this.visibleStart = posts.length ? this.discussion.postIds().indexOf(posts[0].id()) : 0;
this.visibleEnd = this.visibleStart + posts.length;
}
@@ -181,7 +181,7 @@ class PostStream extends mixin(Component, evented) {
.map(id => {
const post = app.store.getById('posts', id);
return post && post.discussion() ? post : null;
return post && post.discussion() && post.user() !== false ? post : null;
});
}
@@ -421,7 +421,7 @@ class PostStream extends mixin(Component, evented) {
return app.store.find('posts', {
filter: {discussion: this.discussion.id()},
page: {near: number}
}).then(this.init.bind(this));
}).then(this.show.bind(this));
}
/**
@@ -442,7 +442,7 @@ class PostStream extends mixin(Component, evented) {
this.reset(start, end);
return this.loadRange(start, end).then(this.init.bind(this));
return this.loadRange(start, end).then(this.show.bind(this));
}
/**

View File

@@ -80,8 +80,8 @@ export default class PostsUserPage extends UserPage {
* Initialize the component with a user, and trigger the loading of their
* activity feed.
*/
init(user) {
super.init(user);
show(user) {
super.show(user);
this.refresh();
}
@@ -95,9 +95,7 @@ export default class PostsUserPage extends UserPage {
this.loading = true;
this.posts = [];
// Redraw, but only if we're not in the middle of a route change.
m.startComputation();
m.endComputation();
m.lazyRedraw();
this.loadResults().then(this.parseResults.bind(this));
}

View File

@@ -16,7 +16,7 @@ export default class SettingsPage extends UserPage {
constructor(...args) {
super(...args);
this.init(app.session.user);
this.show(app.session.user);
app.setTitle(app.trans('core.settings'));
}

View File

@@ -173,11 +173,7 @@ export default class SignUpModal extends Modal {
this.loading = true;
const data = {
username: this.username(),
email: this.email(),
password: this.password()
};
const data = this.submitData();
app.store.createRecord('users').save(data).then(
user => {
@@ -191,4 +187,12 @@ export default class SignUpModal extends Modal {
}
);
}
submitData() {
return {
username: this.username(),
email: this.email(),
password: this.password()
};
}
}

View File

@@ -52,10 +52,13 @@ export default class TextEditor extends Component {
configTextarea(element, isInitialized) {
if (isInitialized) return;
$(element).bind('keydown', 'meta+return', () => {
var handler = () => {
this.onsubmit();
m.redraw();
});
};
$(element).bind('keydown', 'meta+return', handler);
$(element).bind('keydown', 'ctrl+return', handler);
}
/**

View File

@@ -1,4 +1,4 @@
import Component from 'flarum/Component';
import Page from 'flarum/components/Page';
import ItemList from 'flarum/utils/ItemList';
import affixSidebar from 'flarum/utils/affixSidebar';
import UserCard from 'flarum/components/UserCard';
@@ -15,7 +15,7 @@ import listItems from 'flarum/helpers/listItems';
*
* @abstract
*/
export default class UserPage extends Component {
export default class UserPage extends Page {
constructor(...args) {
super(...args);
@@ -27,9 +27,8 @@ export default class UserPage extends Component {
this.user = null;
app.history.push('user');
app.current = this;
app.drawer.hide();
app.modal.close();
this.bodyClass = 'App--user';
}
view() {
@@ -57,13 +56,6 @@ export default class UserPage extends Component {
);
}
config(isInitialized, context) {
if (isInitialized) return;
$('#app').addClass('App--user');
context.onunload = () => $('#app').removeClass('App--user');
}
/**
* Get the content to display in the user page.
*
@@ -79,7 +71,7 @@ export default class UserPage extends Component {
* @param {User} user
* @protected
*/
init(user) {
show(user) {
this.user = user;
app.setTitle(user.username());
@@ -98,13 +90,13 @@ export default class UserPage extends Component {
app.store.all('users').some(user => {
if (user.username().toLowerCase() === lowercaseUsername && user.joinTime()) {
this.init(user);
this.show(user);
return true;
}
});
if (!this.user) {
app.store.find('users', username).then(this.init.bind(this));
app.store.find('users', username).then(this.show.bind(this));
}
}

View File

@@ -34,9 +34,9 @@ export default function(app) {
* @return {String}
*/
app.route.discussion = (discussion, near) => {
return app.route(near > 1 ? 'discussion.near' : 'discussion', {
return app.route(near && near !== 1 ? 'discussion.near' : 'discussion', {
id: discussion.id() + '-' + discussion.slug(),
near: near > 1 ? near : undefined
near: near && near !== 1 ? near : undefined
});
};

View File

@@ -87,20 +87,18 @@ export default {
destructiveControls(post) {
const items = new ItemList();
if (post.number() !== 1) {
if (post.contentType() === 'comment' && !post.isHidden() && post.canEdit()) {
items.add('hide', Button.component({
icon: 'times',
children: app.trans('core.delete'),
onclick: this.hideAction.bind(post)
}));
} else if ((post.contentType() !== 'comment' || post.isHidden()) && post.canDelete()) {
items.add('delete', Button.component({
icon: 'times',
children: app.trans('core.delete_forever'),
onclick: this.deleteAction.bind(post)
}));
}
if (post.contentType() === 'comment' && !post.isHidden() && post.canEdit()) {
items.add('hide', Button.component({
icon: 'times',
children: app.trans('core.delete'),
onclick: this.hideAction.bind(post)
}));
} else if (post.number() !== 1 && (post.contentType() !== 'comment' || post.isHidden()) && post.canDelete()) {
items.add('delete', Button.component({
icon: 'times',
children: app.trans('core.delete_forever'),
onclick: this.deleteAction.bind(post)
}));
}
return items;

View File

@@ -53,6 +53,16 @@ export default class Component {
* @public
*/
this.element = null;
this.init();
}
/**
* Called when the component is constructed.
*
* @protected
*/
init() {
}
/**
@@ -156,15 +166,17 @@ export default class Component {
* @public
*/
static component(props = {}, children) {
if (children) props.children = children;
const componentProps = Object.assign({}, props);
this.initProps(props);
if (children) componentProps.children = children;
this.initProps(componentProps);
// Set up a function for Mithril to get the component's view. It will accept
// the component's controller (which happens to be the component itself, in
// our case), update its props with the ones supplied, and then render the view.
const view = (component) => {
component.props = props;
component.props = componentProps;
return component.render();
};
@@ -177,17 +189,17 @@ export default class Component {
// attach a reference to the props that were passed through and the
// component's class for reference.
const output = {
controller: this.bind(undefined, props),
controller: this.bind(undefined, componentProps),
view: view,
props: props,
props: componentProps,
component: this
};
// If a `key` prop was set, then we'll assume that we want that to actually
// show up as an attribute on the component object so that Mithril's key
// algorithm can be applied.
if (props.key) {
output.attrs = {key: props.key};
if (componentProps.key) {
output.attrs = {key: componentProps.key};
}
return output;

View File

@@ -16,6 +16,7 @@ export default class Session {
* The token that was used for authentication.
*
* @type {String|null}
* @public
*/
this.token = token;
}
@@ -26,6 +27,7 @@ export default class Session {
* @param {String} identification The username/email.
* @param {String} password
* @return {Promise}
* @public
*/
login(identification, password) {
return app.request({
@@ -38,6 +40,8 @@ export default class Session {
/**
* Log the user out.
*
* @public
*/
logout() {
window.location = app.forum.attribute('baseUrl') + '/logout?token=' + this.token;
@@ -48,8 +52,11 @@ export default class Session {
* XMLHttpRequest object.
*
* @param {XMLHttpRequest} xhr
* @public
*/
authorize(xhr) {
xhr.setRequestHeader('Authorization', 'Token ' + this.token);
if (this.token) {
xhr.setRequestHeader('Authorization', 'Token ' + this.token);
}
}
}

View File

@@ -51,7 +51,7 @@ export default class Translator {
// If this translation has multiple options and a 'count' has been provided
// in the input, we'll work out which option to choose using the `plural`
// method.
if (typeof translation === 'object' && typeof input.count !== 'undefined') {
if (translation && typeof translation === 'object' && typeof input.count !== 'undefined') {
translation = translation[this.plural(extractText(input.count))];
}

View File

@@ -23,20 +23,18 @@ export default class Dropdown extends Component {
props.className = props.className || '';
props.buttonClassName = props.buttonClassName || '';
props.contentClassName = props.contentClassName || '';
props.menuClassName = props.menuClassName || '';
props.label = props.label || app.trans('core.controls');
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'caret-down';
}
view() {
const items = listItems(this.props.children);
const items = this.props.children ? listItems(this.props.children) : [];
return (
<div className={'ButtonGroup Dropdown dropdown ' + this.props.className + ' itemCount' + items.length}>
{this.getButton()}
<ul className={'Dropdown-menu dropdown-menu ' + this.props.menuClassName}>
{items}
</ul>
{this.getMenu(items)}
</div>
);
}
@@ -94,4 +92,12 @@ export default class Dropdown extends Component {
this.props.caretIcon ? icon(this.props.caretIcon, {className: 'Button-caret'}) : ''
];
}
getMenu(items) {
return (
<ul className={'Dropdown-menu dropdown-menu ' + this.props.menuClassName}>
{items}
</ul>
);
}
}

View File

@@ -129,7 +129,7 @@ export default class Modal extends Component {
m.redraw();
if (errors) {
this.$('form [name=' + errors[0].path + ']').select();
this.$('form [name=' + errors[0].source.pointer.replace('/data/attributes/', '') + ']').select();
} else {
this.$('form :input:first').select();
}

View File

@@ -81,7 +81,7 @@ export default class ModalManager extends Component {
clear() {
this.component = null;
m.redraw();
m.lazyRedraw();
}
/**

View File

@@ -0,0 +1,19 @@
import Component from 'flarum/Component';
/**
* The `Placeholder` component displays a muted text with some call to action,
* usually used as an empty state.
*
* ### Props
*
* - `text`
*/
export default class Placeholder extends Component {
view() {
return (
<div className="Placeholder">
<p>{this.props.text}</p>
</div>
);
}
}

View File

@@ -23,10 +23,12 @@ function withoutUnnecessarySeparators(items) {
* The `listItems` helper wraps a collection of components in <li> tags,
* stripping out any unnecessary `Separator` components.
*
* @param {Array} items
* @param {*} items
* @return {Array}
*/
export default function listItems(items) {
if (!(items instanceof Array)) items = [items];
return withoutUnnecessarySeparators(items).map(item => {
const isListItem = item.component && item.component.isListItem;
const active = item.component && item.component.isActive && item.component.isActive(item.props);

View File

@@ -6,7 +6,7 @@
* @return {Object}
*/
export default function username(user) {
const name = (user && user.username()) || '[deleted]';
const name = (user && user.username()) || app.trans('deleted');
return <span className="username">{name}</span>;
}

View File

@@ -25,6 +25,7 @@ export default class Discussion extends mixin(Model, {
readTime: Model.attribute('readTime', Model.transformDate),
readNumber: Model.attribute('readNumber'),
isUnread: computed('unreadCount', unreadCount => !!unreadCount),
isRead: computed('unreadCount', unreadCount => app.session.user && !unreadCount),
canReply: Model.attribute('canReply'),
canRename: Model.attribute('canRename'),
@@ -61,7 +62,7 @@ export default class Discussion extends mixin(Model, {
const user = app.session.user;
if (user && user.readTime() < this.lastTime()) {
return Math.max(0, this.lastPostNumber() - (this.readNumber() || 0))
return Math.max(0, this.lastPostNumber() - (this.readNumber() || 0));
}
return 0;

View File

@@ -5,7 +5,7 @@ import mixin from 'flarum/utils/mixin';
import stringToColor from 'flarum/utils/stringToColor';
import ItemList from 'flarum/utils/ItemList';
import computed from 'flarum/utils/computed';
import Badge from 'flarum/components/Badge';
import GroupBadge from 'flarum/components/GroupBadge';
export default class User extends mixin(Model, {
username: Model.attribute('username'),
@@ -69,13 +69,7 @@ export default class User extends mixin(Model, {
groups.forEach(group => {
const name = group.nameSingular();
items.add('group' + group.id(),
Badge.component({
label: app.trans('core.group_' + name.toLowerCase(), undefined, name),
icon: group.icon(),
style: {backgroundColor: group.color()}
})
);
items.add('group' + group.id(), GroupBadge.component({group}));
});
}

View File

@@ -50,6 +50,8 @@ export default class ItemList {
for (const i in this) {
if (this.hasOwnProperty(i) && this[i] instanceof Item) {
this[i].content = Object(this[i].content);
this[i].content.itemName = i;
items.push(this[i]);
this[i].key = items.length;

View File

@@ -21,7 +21,6 @@ export default class SubtreeRetainer {
* @param {...callbacks} callbacks Functions returning data to keep track of.
*/
constructor(...callbacks) {
this.invalidate();
this.callbacks = callbacks;
this.data = {};
}

View File

@@ -13,5 +13,15 @@ export default function patchMithril(global) {
Object.keys(mo).forEach(key => m[key] = mo[key]);
/**
* Redraw only if not in the middle of a computation (e.g. a route change).
*
* @return {void}
*/
m.lazyRedraw = function() {
m.startComputation();
m.endComputation();
};
global.m = m;
}

View File

@@ -41,7 +41,12 @@ export function getPlainContent(string) {
return dom.text();
}
getPlainContent.removeSelectors = ['blockquote'];
/**
* An array of DOM selectors to remove when getting plain content.
*
* @type {Array}
*/
getPlainContent.removeSelectors = ['blockquote', 'script'];
/**
* Make a string's first character uppercase.

View File

@@ -13,11 +13,15 @@
> li {
border-bottom: 1px solid @control-bg;
}
.Post {
padding-top: 10px;
}
}
.PostsUserPage-discussion {
font-weight: bold;
margin-top: 15px;
margin-bottom: -15px;
margin-bottom: 10px;
position: relative;
z-index: 1;

View File

@@ -44,8 +44,11 @@
overflow: hidden;
text-overflow: ellipsis;
.read & {
color: mix(@heading-color, @body-bg, 55%);
}
.unread & {
font-weight: bold;
font-weight: 600;
}
}
.DiscussionListItem-info {
@@ -56,6 +59,12 @@
> li {
display: inline;
opacity: 0.7;
.transition(opacity 0.2s);
.DiscussionListItem:hover &, .DiscussionListItem.active & {
opacity: 1;
}
}
.username {
font-weight: bold;

View File

@@ -76,19 +76,17 @@
padding: 0;
}
.Notification {
> a {
display: block;
padding: 8px 15px 8px 70px;
color: @muted-color;
overflow: hidden;
display: block;
padding: 8px 15px 8px 70px;
color: @muted-color !important; // required to override .light-contents applied to header
overflow: hidden;
.unread& {
background: @control-bg;
}
&:hover {
text-decoration: none;
background: @control-bg;
}
.unread& {
background: @control-bg;
}
&:hover {
text-decoration: none;
background: @control-bg;
}
.Avatar {
.Avatar--size(24px);

View File

@@ -10,7 +10,7 @@
}
}
& .Dropdown-toggle .Button-label {
margin-left: 10px;
margin-left: 7px;
}
}
@media @tablet-up {
@@ -24,21 +24,19 @@
}
}
.NotificationsDropdown-button.unread .Button-icon {
display: inline-block;
border-radius: 12px;
height: 24px;
width: 24px;
text-align: center;
padding: 2px 0;
font-weight: bold;
margin: -2px 0;
background: @primary-color;
color: @body-bg;
font-size: 13px;
vertical-align: 0;
& when (@config-colored-header = true) {
background: #fff;
}
.NotificationsDropdown .Dropdown-toggle.unread .Button-icon {
color: @header-color;
}
.NotificationsDropdown-unread {
position: absolute;
top: 1px;
left: 17px;
background: @header-color;
color: @header-bg;
font-size: 11px;
font-weight: bold;
padding: 2px 5px 3px;
line-height: 1em;
border-radius: 10px;
border: 1px solid @header-bg;
}

View File

@@ -2,10 +2,12 @@
// Posts
.Post {
padding: 30px 0;
padding: 30px 20px;
margin: -1px -20px;
transition: 0.2s box-shadow, top 0.2s, opacity 0.2s;
position: relative;
top: 0;
border-radius: @border-radius;
&.editing {
top: 5px;
@@ -114,6 +116,11 @@
color: #666;
font-size: 90%;
border-radius: @border-radius;
.hljs {
padding: 0;
background: none;
}
}
h1 {
font-size: 160%;
@@ -131,13 +138,13 @@
font-size: 100%;
font-weight: bold;
}
img {
img, iframe {
max-width: 100%;
}
}
.Post.hidden {
.Post-header, .Post-header a, .Post-user h3, .Post-user h3 a {
.Post--hidden {
.Post-header, .Post-header a, .PostUser h3, .PostUser h3 a {
color: @muted-more-color;
}
.Post-body, .Post-footer, h3 .Avatar, .PostUser-badges {
@@ -262,6 +269,10 @@
padding-left: 50px;
line-height: 1.7em;
.PostPreview-excerpt {
word-wrap: break-word;
}
.Avatar {
float: left;
margin-left: -50px;
@@ -319,7 +330,7 @@
@media @tablet-up {
.Post {
padding-left: 90px;
padding-left: 20px + 90px;
.Post-controls {
opacity: 0;

View File

@@ -126,6 +126,9 @@
& .icon {
font-size: 14px;
}
&.online .fa-circle {
color: @online-user-circle-color;
}
&.online .icon {
font-size: 12px;
}

View File

@@ -27,8 +27,7 @@
&[disabled],
&[readonly],
fieldset[disabled] & {
// background-color: @input-bg-disabled;
opacity: 1; // iOS fix for unreadable disabled content; see https://github.com/twbs/bootstrap/issues/11655
opacity: 0.5;
}
&[disabled],

View File

@@ -151,7 +151,7 @@
}
&:before {
content: " ";
// .App-header();
.header-background();
}
}
.Modal {

View File

@@ -0,0 +1,9 @@
.Placeholder {
margin-top: 40px;
p {
font-size: 1.4em;
color: @muted-more-color;
text-align: center;
}
}

View File

@@ -9,6 +9,7 @@
-moz-appearance: none;
padding-right: 30px;
cursor: pointer;
line-height: 1;
}
}
.Select-caret {

View File

@@ -1,4 +1,4 @@
@import url(http://fonts.googleapis.com/css?family=Open+Sans:400italic,700italic,400,700,600);
@import url(//fonts.googleapis.com/css?family=Open+Sans:400italic,700italic,400,700,600);
@import "font-awesome.less";
@fa-font-path: "../../assets/fonts";
@@ -22,9 +22,9 @@
@import "LoadingIndicator.less";
@import "Modal.less";
@import "Navigation.less";
@import "Placeholder.less";
@import "Search.less";
@import "Select.less";
@import "Tooltip.less";
@import "variables.less";

View File

@@ -24,11 +24,11 @@
@secondary-color: @config-secondary-color;
@body-bg: #fff;
@text-color: #333;
@text-color: #111;
@link-color: saturate(@primary-color, 10%);
@heading-color: @text-color;
@muted-color: hsl(@secondary-hue, min(25%, @secondary-sat), 63%);
@muted-more-color: #bbb;
@muted-color: hsl(@secondary-hue, min(25%, @secondary-sat), 55%);
@muted-more-color: #aaa;
@shadow-color: rgba(0, 0, 0, 0.35);
@control-bg: hsl(@secondary-hue, min(50%, @secondary-sat), 93%);
@@ -130,3 +130,5 @@
@tooltip-bg: rgba(0, 0, 0, 0.9);
@tooltip-color: #fff;
@online-user-circle-color: #7FBA00;

View File

@@ -1,6 +1,5 @@
core:
account: Account
activity: Activity
administration: Administration
alert: Alert
all_discussions: All Discussions
@@ -50,7 +49,6 @@ core:
group_mods: Mods
invalid_login: Your login details were incorrect.
joined: "Joined {ago}"
joined_the_forum: Joined the forum
load_more: Load More
log_in: Log In
log_in_to_reply: Log In to Reply
@@ -71,7 +69,6 @@ core:
post_edited: "{username} edited {ago}"
post_number: "Post #{number}"
post_reply: Post Reply
posted_a_reply: Posted a reply
posts: Posts
powered_by_flarum: Powered by Flarum
privacy: Privacy
@@ -96,7 +93,6 @@ core:
sort_relevance: Relevance
sort_top: Top
start_a_discussion: Start a Discussion
started_a_discussion: Started a discussion
unread_posts: "{count} unread"
upload: Upload
username: Username

View File

@@ -0,0 +1,38 @@
<?php
/*
* 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.
*/
use Flarum\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
class CreateApiKeysTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$this->schema->create('api_keys', function (Blueprint $table) {
$table->string('id', 100)->primary();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
$this->schema->drop('api_keys');
}
}

View File

@@ -13,7 +13,7 @@ namespace Flarum\Api\Actions;
use Flarum\Api\Request;
use Zend\Diactoros\Response\EmptyResponse;
abstract class DeleteAction extends JsonApiAction
abstract class DeleteAction implements Action
{
/**
* Delegate deletion of the resource, and return a 204 No Content
@@ -22,7 +22,7 @@ abstract class DeleteAction extends JsonApiAction
* @param \Flarum\Api\Request $request
* @return \Psr\Http\Message\ResponseInterface
*/
public function respond(Request $request)
public function handle(Request $request)
{
$this->delete($request);

View File

@@ -10,13 +10,13 @@
namespace Flarum\Api\Actions\Extensions;
use Flarum\Api\Actions\JsonApiAction;
use Flarum\Api\Actions\Action;
use Flarum\Api\Request;
use Illuminate\Contracts\Bus\Dispatcher;
use Flarum\Core\Exceptions\PermissionDeniedException;
use Flarum\Support\ExtensionManager;
class UpdateAction extends JsonApiAction
class UpdateAction implements Action
{
protected $extensions;
@@ -25,7 +25,7 @@ class UpdateAction extends JsonApiAction
$this->extensions = $extensions;
}
protected function respond(Request $request)
public function handle(Request $request)
{
if (! $request->actor->isAdmin()) {
throw new PermissionDeniedException;

View File

@@ -16,7 +16,7 @@ use Flarum\Core\Users\Commands\RequestPasswordReset;
use Illuminate\Contracts\Bus\Dispatcher;
use Zend\Diactoros\Response\EmptyResponse;
class ForgotAction extends JsonApiAction
class ForgotAction implements Action
{
protected $users;
@@ -34,7 +34,7 @@ class ForgotAction extends JsonApiAction
* @param \Flarum\Api\Request $request
* @return \Psr\Http\Message\ResponseInterface
*/
public function respond(Request $request)
public function handle(Request $request)
{
$email = $request->get('email');

View File

@@ -1,63 +0,0 @@
<?php
/*
* 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.
*/
namespace Flarum\Api\Actions;
use Flarum\Api\Request;
use Illuminate\Contracts\Validation\ValidationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Flarum\Core\Exceptions\ValidationFailureException;
use Flarum\Core\Exceptions\PermissionDeniedException;
use Zend\Diactoros\Response\JsonResponse;
abstract class JsonApiAction implements Action
{
/**
* Handle an API request and return an API response, handling any relevant
* (API-related) exceptions that are thrown.
*
* @param Request $request
* @return \Psr\Http\Message\ResponseInterface
*/
public function handle(Request $request)
{
// TODO: This is gross. Move this error handling code to middleware?
try {
return $this->respond($request);
} catch (ValidationException $e) {
$errors = [];
foreach ($e->errors()->toArray() as $field => $messages) {
$errors[] = [
'detail' => implode("\n", $messages),
'path' => $field
];
}
return new JsonResponse(['errors' => $errors], 422);
} catch (\Flarum\Core\Exceptions\ValidationException $e) {
$errors = [];
foreach ($e->getMessages() as $path => $detail) {
$errors[] = compact('path', 'detail');
}
return new JsonResponse(['errors' => $errors], 422);
} catch (PermissionDeniedException $e) {
return new JsonResponse(null, 401);
} catch (ModelNotFoundException $e) {
return new JsonResponse(null, 404);
}
}
/**
* Handle an API request and return an API response.
*
* @param Request $request
* @return \Psr\Http\Message\ResponseInterface
*/
abstract protected function respond(Request $request);
}

View File

@@ -19,7 +19,7 @@ use Tobscure\JsonApi\Document;
use Tobscure\JsonApi\SerializerInterface;
use Zend\Diactoros\Response\JsonResponse;
abstract class SerializeAction extends JsonApiAction
abstract class SerializeAction implements Action
{
/**
* The name of the serializer class to output results with.
@@ -77,7 +77,7 @@ abstract class SerializeAction extends JsonApiAction
* @param Request $request
* @return JsonResponse
*/
public function respond(Request $request)
public function handle(Request $request)
{
$request = $this->buildJsonApiRequest($request);
$document = new Document();

View File

@@ -18,7 +18,7 @@ use Flarum\Events\UserEmailChangeWasRequested;
use Illuminate\Contracts\Bus\Dispatcher;
use Zend\Diactoros\Response\JsonResponse;
class TokenAction extends JsonApiAction
class TokenAction implements Action
{
protected $users;
@@ -37,7 +37,7 @@ class TokenAction extends JsonApiAction
* @return \Psr\Http\Message\ResponseInterface
* @throws PermissionDeniedException
*/
public function respond(Request $request)
public function handle(Request $request)
{
$identification = $request->get('identification');
$password = $request->get('password');

57
src/Api/ApiKey.php Normal file
View File

@@ -0,0 +1,57 @@
<?php
/*
* 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.
*/
namespace Flarum\Api;
use Flarum\Core\Model;
use DateTime;
/**
* @todo document database columns with @property
*/
class ApiKey extends Model
{
/**
* {@inheritdoc}
*/
protected $table = 'api_keys';
/**
* Use a custom primary key for this model.
*
* @var bool
*/
public $incrementing = false;
/**
* Generate an API key.
*
* @return static
*/
public static function generate()
{
$key = new static;
$key->id = str_random(40);
return $key;
}
/**
* Get the given key only if it is valid.
*
* @param string $key
* @return static|null
*/
public static function valid($key)
{
return static::where('id', $key)->first();
}
}

View File

@@ -12,6 +12,8 @@ namespace Flarum\Api;
use Flarum\Core\Users\User;
use Illuminate\Contracts\Container\Container;
use Exception;
use Flarum\Api\Middleware\JsonApiErrors;
class Client
{
@@ -38,10 +40,16 @@ class Client
*/
public function send(User $actor, $actionClass, array $input = [])
{
/** @var \Flarum\Api\Actions\JsonApiAction $action */
/** @var \Flarum\Api\Actions\Action $action */
$action = $this->container->make($actionClass);
$response = $action->handle(new Request($input, $actor));
try {
$response = $action->handle(new Request($input, $actor));
} catch (Exception $e) {
$middleware = new JsonApiErrors();
$response = $middleware->handle($e);
}
return new Response($response);
}

View File

@@ -10,35 +10,65 @@
namespace Flarum\Api\Middleware;
use Flarum\Core\Exceptions\JsonApiSerializable;
use Illuminate\Contracts\Validation\ValidationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Zend\Diactoros\Response\JsonResponse;
use Zend\Stratigility\ErrorMiddlewareInterface;
use Flarum\Core;
use Exception;
class JsonApiErrors implements ErrorMiddlewareInterface
{
/**
* {@inheritdoc}
*/
public function __invoke($error, Request $request, Response $response, callable $out = null)
public function __invoke($e, Request $request, Response $response, callable $out = null)
{
$errorObject = [
'title' => $error->getMessage(),
];
return $this->handle($e);
}
$status = 500;
public function handle(Exception $e)
{
if ($e instanceof JsonApiSerializable) {
$status = $e->getStatusCode();
// If it seems to be a valid HTTP status code, we pass on the
// exception's status.
$errorCode = $error->getCode();
if (is_int($errorCode) && $errorCode >= 400 && $errorCode < 600) {
$status = $errorCode;
$errors = $e->getErrors();
} else if ($e instanceof ValidationException) {
$status = 422;
$errors = $e->errors()->toArray();
$errors = array_map(function ($field, $messages) {
return [
'detail' => implode("\n", $messages),
'source' => ['pointer' => '/data/attributes/' . $field],
];
}, array_keys($errors), $errors);
} else if ($e instanceof ModelNotFoundException) {
$status = 404;
$errors = [];
} else {
$status = 500;
$error = [
'code' => $status,
'title' => 'Internal Server Error'
];
if (Core::inDebugMode()) {
$error['detail'] = (string) $e;
}
$errors = [$error];
}
// JSON API errors must be collected in an array under the
// "errors" key in the top level of the document
$data = [
'errors' => [$errorObject]
'errors' => $errors,
];
return new JsonResponse($data, $status);

View File

@@ -11,6 +11,8 @@
namespace Flarum\Api\Middleware;
use Flarum\Api\AccessToken;
use Flarum\Api\ApiKey;
use Flarum\Core\Users\User;
use Illuminate\Contracts\Container\Container;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
@@ -42,13 +44,23 @@ class LoginWithHeader implements MiddlewareInterface
public function __invoke(Request $request, Response $response, callable $out = null)
{
$header = $request->getHeaderLine('authorization');
if (starts_with($header, $this->prefix) &&
($token = substr($header, strlen($this->prefix))) &&
($accessToken = AccessToken::valid($token))
) {
$this->app->instance('flarum.actor', $user = $accessToken->user);
$user->updateLastSeen()->save();
$parts = explode(';', $header);
if (isset($parts[0]) && starts_with($parts[0], $this->prefix)) {
$token = substr($parts[0], strlen($this->prefix));
if ($accessToken = AccessToken::valid($token)) {
$this->app->instance('flarum.actor', $user = $accessToken->user);
$user->updateLastSeen()->save();
} elseif (isset($parts[1]) && ($apiKey = ApiKey::valid($token))) {
$userParts = explode('=', trim($parts[1]));
if (isset($userParts[0]) && $userParts[0] === 'userId') {
$this->app->instance('flarum.actor', $user = User::find($userParts[1]));
}
}
}
return $out ? $out($request, $response) : $response;

View File

@@ -22,7 +22,7 @@ class Request
public $input;
/**
* @var Guest
* @var User
*/
public $actor;

View File

@@ -11,6 +11,7 @@
namespace Flarum\Assets;
use Less_Parser;
use Less_Exception_Parser;
class LessCompiler extends RevisionCompiler
{
@@ -28,7 +29,11 @@ class LessCompiler extends RevisionCompiler
}
foreach ($this->strings as $callback) {
$parser->parse($callback());
try {
$parser->parse($callback());
} catch (Less_Exception_Parser $e) {
// TODO: log an error somewhere?
}
}
return $parser->getCss();

View File

@@ -14,6 +14,7 @@ use Illuminate\Contracts\Container\Container;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Question\Question;
use Flarum\Core\Application;
class GenerateExtensionCommand extends Command
{
@@ -83,12 +84,17 @@ class GenerateExtensionCommand extends Command
'version' => '0.1.0',
'author' => [
'name' => $authorName,
'email' => $authorEmail
'email' => $authorEmail,
'homepage' => ''
],
'license' => $license,
'require' => [
'php' => '>=5.4.0',
'flarum' => '>0.1.0'
'flarum' => '>'.Application::VERSION
],
'icon' => [
'name' => '',
'backgroundColor' => '',
'color' => ''
]
];

View File

@@ -68,9 +68,13 @@ class UpgradeCommand extends Command
$migrator = $extensions->getMigrator();
foreach ($extensions->getInfo() as $extension) {
if (! $extensions->isEnabled($extension->name)) {
continue;
}
$this->info('Upgrading extension: '.$extension->name);
$extensions->enable($extension->name);
$extensions->migrate($extension->name);
foreach ($migrator->getNotes() as $note) {
$this->info($note);

View File

@@ -27,7 +27,7 @@ class Application extends Container implements ApplicationContract
*
* @var string
*/
const VERSION = '0.1.0-beta';
const VERSION = '0.1.0-beta.2';
/**
* The base path for the Laravel installation.

2
src/Core/Discussions/Discussion.php Executable file → Normal file
View File

@@ -46,7 +46,7 @@ class Discussion extends Model
* @var array
*/
protected $rules = [
'title' => 'required',
'title' => 'required|max:80',
'start_time' => 'required|date',
'comments_count' => 'integer',
'participants_count' => 'integer',

View File

@@ -13,7 +13,9 @@ namespace Flarum\Core\Discussions;
use Flarum\Core\Search\GambitManager;
use Flarum\Core\Users\User;
use Flarum\Events\ModelAllow;
use Flarum\Events\ScopeModelVisibility;
use Flarum\Events\RegisterDiscussionGambits;
use Flarum\Events\ScopeEmptyDiscussionVisibility;
use Flarum\Support\ServiceProvider;
use Flarum\Extend;
use Illuminate\Contracts\Container\Container;
@@ -53,6 +55,19 @@ class DiscussionsServiceProvider extends ServiceProvider
}
}
});
$events->listen(ScopeModelVisibility::class, function (ScopeModelVisibility $event) {
if ($event->model instanceof Discussion) {
if (! $event->actor->hasPermission('discussion.editPosts')) {
$event->query->where(function ($query) use ($event) {
$query->where('comments_count', '>', '0')
->orWhere('start_user_id', $event->actor->id);
event(new ScopeEmptyDiscussionVisibility($query, $event->actor));
});
}
}
});
}
/**

View File

@@ -37,10 +37,12 @@ class DiscussionMetadataUpdater
{
$discussion = $event->post->discussion;
$discussion->comments_count++;
$discussion->setLastPost($event->post);
$discussion->refreshParticipantsCount();
$discussion->save();
if ($discussion && $discussion->exists) {
$discussion->comments_count++;
$discussion->setLastPost($event->post);
$discussion->refreshParticipantsCount();
$discussion->save();
}
}
/**
@@ -66,10 +68,12 @@ class DiscussionMetadataUpdater
{
$discussion = $event->post->discussion;
$discussion->refreshCommentsCount();
$discussion->refreshParticipantsCount();
$discussion->refreshLastPost();
$discussion->save();
if ($discussion && $discussion->exists) {
$discussion->refreshCommentsCount();
$discussion->refreshParticipantsCount();
$discussion->refreshLastPost();
$discussion->save();
}
}
/**
@@ -79,7 +83,7 @@ class DiscussionMetadataUpdater
{
$discussion = $post->discussion;
if ($discussion->exists) {
if ($discussion && $discussion->exists) {
$discussion->refreshCommentsCount();
$discussion->refreshParticipantsCount();

View File

@@ -12,6 +12,21 @@ namespace Flarum\Core\Exceptions;
use Exception;
class InvalidConfirmationTokenException extends Exception
class InvalidConfirmationTokenException extends Exception implements JsonApiSerializable
{
/**
* {@inheritdoc}
*/
public function getStatusCode()
{
return 403;
}
/**
* {@inheritdoc}
*/
public function getErrors()
{
return ['code' => 'invalid_confirmation_token'];
}
}

View File

@@ -0,0 +1,29 @@
<?php
/*
* 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.
*/
namespace Flarum\Core\Exceptions;
interface JsonApiSerializable
{
/**
* Return the HTTP status code to be used for this exception.
*
* @return int
*/
public function getStatusCode();
/**
* Return an array of errors, formatted as JSON-API error objects.
*
* @see http://jsonapi.org/format/#error-objects
* @return array
*/
public function getErrors();
}

View File

@@ -12,6 +12,26 @@ namespace Flarum\Core\Exceptions;
use Exception;
class PermissionDeniedException extends Exception
class PermissionDeniedException extends Exception implements JsonApiSerializable
{
/**
* Return the HTTP status code to be used for this exception.
*
* @return int
*/
public function getStatusCode()
{
return 401;
}
/**
* Return an array of errors, formatted as JSON-API error objects.
*
* @see http://jsonapi.org/format/#error-objects
* @return array
*/
public function getErrors()
{
return [];
}
}

View File

@@ -12,17 +12,39 @@ namespace Flarum\Core\Exceptions;
use Exception;
class ValidationException extends Exception
class ValidationException extends Exception implements JsonApiSerializable
{
protected $messages;
public function __construct(array $messages)
{
$this->messages = $messages;
parent::__construct(implode("\n", $messages));
}
public function getMessages()
{
return $this->messages;
}
/**
* {@inheritdoc}
*/
public function getStatusCode()
{
return 422;
}
/**
* {@inheritdoc}
*/
public function getErrors()
{
return array_map(function ($path, $detail) {
$source = ['pointer' => '/data/attributes/' . $path];
return compact('source', 'detail');
}, array_keys($this->messages), $this->messages);
}
}

View File

@@ -42,6 +42,15 @@ class Formatter
event(new FormatterConfigurator($configurator));
$dom = $configurator->tags['URL']->template->asDOM();
foreach ($dom->getElementsByTagName('a') as $a) {
$a->setAttribute('target', '_blank');
$a->setAttribute('rel', 'nofollow');
}
$dom->saveChanges();
return $configurator;
}

View File

@@ -10,14 +10,8 @@
namespace Flarum\Core;
use Flarum\Core\Exceptions\PermissionDeniedException;
use Flarum\Core\Users\User;
use Flarum\Events\ModelAllow;
use Flarum\Events\ModelDates;
use Flarum\Events\ModelRelationship;
use Flarum\Events\ScopeModelVisibility;
use Illuminate\Contracts\Validation\Factory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model as Eloquent;
use Illuminate\Database\Eloquent\Relations\Relation;
use LogicException;
@@ -77,7 +71,7 @@ abstract class Model extends Eloquent
// If a custom relation with this key has been set up, then we will load
// and return results from the query and hydrate the relationship's
// value on the "relationships" array.
if ($relation = $this->getCustomRelation($key)) {
if (! $this->relationLoaded($key) && ($relation = $this->getCustomRelation($key))) {
if (! $relation instanceof Relation) {
throw new LogicException('Relationship method must return an object of type '
. 'Illuminate\Database\Eloquent\Relations\Relation');

View File

@@ -100,7 +100,7 @@ class NotificationSyncer
// removed from this collection by the above loop. Un-delete the
// existing records that we want to keep.
if (count($toDelete)) {
$this->setDeleted($toDelete->lists('id'), true);
$this->setDeleted($toDelete->lists('id')->all(), true);
}
if (count($toUndelete)) {

View File

@@ -87,15 +87,11 @@ class CommentPost extends Post
* @param User $actor
* @return $this
*/
public function hide(User $actor)
public function hide(User $actor = null)
{
if ($this->number == 1) {
throw new DomainException('Cannot hide the first post of a discussion');
}
if (! $this->hide_time) {
$this->hide_time = time();
$this->hide_user_id = $actor->id;
$this->hide_user_id = $actor ? $actor->id : null;
$this->raise(new PostWasHidden($this));
}
@@ -110,10 +106,6 @@ class CommentPost extends Post
*/
public function restore()
{
if ($this->number == 1) {
throw new DomainException('Cannot restore the first post of a discussion');
}
if ($this->hide_time !== null) {
$this->hide_time = null;
$this->hide_user_id = null;

View File

@@ -43,7 +43,7 @@ class Post extends Model
protected $rules = [
'discussion_id' => 'required|integer',
'time' => 'required|date',
'content' => 'required',
'content' => 'required|max:65535',
'number' => 'integer',
'user_id' => 'integer',
'edit_time' => 'date',
@@ -120,7 +120,7 @@ class Post extends Model
if ($discussion) {
$this->setRelation('discussion', $discussion);
return (bool) $discussion->postsVisibleTo($user)->find($this->id)->count();
return (bool) $discussion->postsVisibleTo($user)->where('id', $this->id)->count();
}
return false;

View File

@@ -44,7 +44,7 @@ class PostsServiceProvider extends ServiceProvider
$actor = $event->actor;
if ($action === 'view' &&
(! $post->hide_user_id || $post->hide_user_id == $actor->id || $post->can($actor, 'edit'))) {
(! $post->hide_time || $post->user_id == $actor->id || $post->can($actor, 'edit'))) {
return true;
}
@@ -55,7 +55,7 @@ class PostsServiceProvider extends ServiceProvider
if ($post->discussion->can($actor, 'editPosts')) {
return true;
}
if ($post->user_id == $actor->id && (! $post->hide_user_id || $post->hide_user_id == $actor->id)) {
if ($post->user_id == $actor->id && (! $post->hide_time || $post->hide_user_id == $actor->id)) {
$allowEditing = $settings->get('allow_post_editing');
if ($allowEditing === '-1' ||
@@ -80,8 +80,8 @@ class PostsServiceProvider extends ServiceProvider
if (! $event->discussion->can($user, 'editPosts')) {
$event->query->where(function ($query) use ($user) {
$query->whereNull('hide_user_id')
->orWhere('hide_user_id', $user->id);
$query->whereNull('hide_time')
->orWhere('user_id', $user->id);
});
}
});

View File

@@ -30,6 +30,7 @@ class RegisterUserHandler
/**
* @param RegisterUser $command
* @return User
* @throws PermissionDeniedException
*/
public function handle(RegisterUser $command)
{

View File

@@ -65,7 +65,7 @@ class UploadAvatarHandler
$tmpFile = tempnam(sys_get_temp_dir(), 'avatar');
$command->file->moveTo($tmpFile);
$manager = new ImageManager(['driver' => 'imagick']);
$manager = new ImageManager();
$manager->make($tmpFile)->fit(100, 100)->save();
event(new AvatarWillBeSaved($user, $actor, $tmpFile));

View File

@@ -10,7 +10,8 @@
namespace Flarum\Core\Users\Listeners;
use Flarum\Core\Users\User;
use Flarum\Core\Posts\Post;
use Flarum\Core\Discussions\Discussion;
use Flarum\Events\PostWasPosted;
use Flarum\Events\PostWasDeleted;
use Flarum\Events\PostWasHidden;
@@ -39,7 +40,7 @@ class UserMetadataUpdater
*/
public function whenPostWasPosted(PostWasPosted $event)
{
$this->updateCommentsCount($event->post->user, 1);
$this->updateCommentsCount($event->post, 1);
}
/**
@@ -47,9 +48,7 @@ class UserMetadataUpdater
*/
public function whenPostWasDeleted(PostWasDeleted $event)
{
if ($event->post->user->exists) {
$this->updateCommentsCount($event->post->user, -1);
}
$this->updateCommentsCount($event->post, -1);
}
/**
@@ -57,7 +56,7 @@ class UserMetadataUpdater
*/
public function whenPostWasHidden(PostWasHidden $event)
{
$this->updateCommentsCount($event->post->user, -1);
$this->updateCommentsCount($event->post, -1);
}
/**
@@ -65,7 +64,7 @@ class UserMetadataUpdater
*/
public function whenPostWasRestored(PostWasRestored $event)
{
$this->updateCommentsCount($event->post->user, 1);
$this->updateCommentsCount($event->post, 1);
}
/**
@@ -73,7 +72,7 @@ class UserMetadataUpdater
*/
public function whenDiscussionWasStarted(DiscussionWasStarted $event)
{
$this->updateDiscussionsCount($event->discussion->startUser, 1);
$this->updateDiscussionsCount($event->discussion, 1);
}
/**
@@ -81,26 +80,34 @@ class UserMetadataUpdater
*/
public function whenDiscussionWasDeleted(DiscussionWasDeleted $event)
{
$this->updateDiscussionsCount($event->discussion->startUser, -1);
$this->updateDiscussionsCount($event->discussion, -1);
}
/**
* @param User $user
* @param Post $post
* @param int $amount
*/
protected function updateCommentsCount(User $user, $amount)
protected function updateCommentsCount(Post $post, $amount)
{
$user->comments_count += $amount;
$user->save();
$user = $post->user;
if ($user && $user->exists) {
$user->comments_count += $amount;
$user->save();
}
}
/**
* @param User $user
* @param Discussion $discussion
* @param int $amount
*/
protected function updateDiscussionsCount(User $user, $amount)
protected function updateDiscussionsCount(Discussion $discussion, $amount)
{
$user->discussions_count += $amount;
$user->save();
$user = $discussion->startUser;
if ($user && $user->exists) {
$user->discussions_count += $amount;
$user->save();
}
}
}

View File

@@ -32,6 +32,7 @@ use Flarum\Core\Support\Locked;
use Flarum\Core\Support\VisibleScope;
use Flarum\Core\Support\EventGenerator;
use Flarum\Core\Support\ValidatesBeforeSave;
use Flarum\Core\Exceptions\ValidationException;
/**
* @todo document database columns with @property
@@ -76,7 +77,7 @@ class User extends Model
/**
* An array of permissions that this user has.
*
* @var array|null
* @var string[]|null
*/
protected $permissions = null;
@@ -149,6 +150,8 @@ class User extends Model
{
$user = new static;
$user->assertValidPassword($password);
$user->username = $username;
$user->email = $email;
$user->password = $password;
@@ -225,6 +228,8 @@ class User extends Model
*/
public function changePassword($password)
{
$this->assertValidPassword($password);
$this->password = $password;
$this->raise(new UserPasswordWasChanged($this));
@@ -232,6 +237,20 @@ class User extends Model
return $this;
}
/**
* Validate password input.
*
* @param string $password
* @return void
* @throws \Flarum\Core\Exceptions\ValidationException
*/
protected function assertValidPassword($password)
{
if (strlen($password) < 8) {
throw new ValidationException(['password' => 'Password must be at least 8 characters']);
}
}
/**
* Set the password attribute, storing it as a hash.
*
@@ -306,7 +325,7 @@ class User extends Model
{
$urlGenerator = app('Flarum\Http\UrlGeneratorInterface');
return $this->avatar_path ? $urlGenerator->toAsset('assets/avatars/'.$this->avatar_path) : null;
return $this->avatar_path ? $urlGenerator->toAsset('avatars/'.$this->avatar_path) : null;
}
/**
@@ -359,12 +378,38 @@ class User extends Model
}
if (is_null($this->permissions)) {
$this->permissions = $this->permissions()->lists('permission')->all();
$this->permissions = $this->getPermissions();
}
return in_array($permission, $this->permissions);
}
/**
* Check whether the user has a permission that is like the given string,
* based on their groups.
*
* @param string $match
* @return boolean
*/
public function hasPermissionLike($match)
{
if ($this->isAdmin()) {
return true;
}
if (is_null($this->permissions)) {
$this->permissions = $this->getPermissions();
}
foreach ($this->permissions as $permission) {
if (substr($permission, -strlen($match)) === $match) {
return true;
}
}
return false;
}
/**
* Get the notification types that should be alerted to this user, according
* to their preferences.
@@ -576,6 +621,16 @@ class User extends Model
return Permission::whereIn('group_id', $groupIds);
}
/**
* Get a list of permissions that the user has.
*
* @return string[]
*/
public function getPermissions()
{
return $this->permissions()->lists('permission')->all();
}
/**
* Define the relationship with the user's access tokens.
*

View File

@@ -27,8 +27,23 @@ class RegisterLocales
$this->manager = $manager;
}
public function addLocale($locale, $name)
{
$this->manager->addLocale($locale, $name);
}
public function addTranslations($locale, $file)
{
$this->manager->addTranslations($locale, $file);
}
public function addJsFile($locale, $file)
{
$this->manager->addJsFile($locale, $file);
}
public function addConfig($locale, $file)
{
$this->manager->addConfig($locale, $file);
}
}

View File

@@ -0,0 +1,40 @@
<?php
/*
* 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.
*/
namespace Flarum\Events;
use Flarum\Core\Users\User;
use Illuminate\Database\Eloquent\Builder;
/**
* The `ScopeEmptyDiscussionVisibility` event
*/
class ScopeEmptyDiscussionVisibility
{
/**
* @var Builder
*/
public $query;
/**
* @var User
*/
public $actor;
/**
* @param Builder $query
* @param User $actor
*/
public function __construct(Builder $query, User $actor)
{
$this->query = $query;
$this->actor = $actor;
}
}

View File

@@ -10,15 +10,27 @@
namespace Flarum\Events;
use Flarum\Api\Actions\Action;
use Flarum\Api\JsonApiRequest;
class WillSerializeData
{
/**
* @var Action
*/
public $action;
/**
* @var mixed
*/
public $data;
/**
* @var JsonApiRequest
*/
public $request;
public function __construct($action, &$data, $request)
public function __construct(Action $action, &$data, JsonApiRequest $request)
{
$this->action = $action;
$this->data = &$data;

View File

@@ -14,10 +14,24 @@ use Flarum\Core\Users\PasswordToken;
use Flarum\Support\HtmlAction;
use Flarum\Core\Exceptions\InvalidConfirmationTokenException;
use Psr\Http\Message\ServerRequestInterface as Request;
use Illuminate\Contracts\View\Factory;
use DateTime;
class ResetPasswordAction extends HtmlAction
{
/**
* @var Factory
*/
protected $view;
/**
* @param Factory $view
*/
public function __construct(Factory $view)
{
$this->view = $view;
}
/**
* @param Request $request
* @param array $routeParams
@@ -33,6 +47,6 @@ class ResetPasswordAction extends HtmlAction
throw new InvalidConfirmationTokenException;
}
return view('flarum::reset')->with('token', $token->id);
return $this->view->make('flarum::reset')->with('token', $token->id);
}
}

View File

@@ -13,24 +13,10 @@ namespace Flarum\Forum\Actions;
use Flarum\Core\Users\PasswordToken;
use Flarum\Core\Users\Commands\EditUser;
use Flarum\Support\Action;
use Illuminate\Contracts\Bus\Dispatcher;
use Psr\Http\Message\ServerRequestInterface as Request;
class SavePasswordAction extends Action
{
/**
* @var Dispatcher
*/
protected $bus;
/**
* @param Dispatcher $bus
*/
public function __construct(Dispatcher $bus)
{
$this->bus = $bus;
}
/**
* @param Request $request
* @param array $routeParams
@@ -49,9 +35,8 @@ class SavePasswordAction extends Action
return $this->redirectTo('/reset/'.$token->id); // TODO: Use UrlGenerator
}
$this->bus->dispatch(
new EditUser($token->user_id, $token->user, ['attributes' => ['password' => $password]])
);
$token->user->changePassword($password);
$token->user->save();
$token->delete();

View File

@@ -24,6 +24,7 @@ trait WritesRememberCookie
SetCookie::create('flarum_remember', $token)
->withMaxAge(14 * 24 * 60 * 60)
->withPath('/')
->withHttpOnly(true)
);
}
@@ -35,6 +36,7 @@ trait WritesRememberCookie
SetCookie::create('flarum_remember')
->withMaxAge(-2628000)
->withPath('/')
->withHttpOnly(true)
);
}
}

View File

@@ -67,13 +67,13 @@ class ForumServiceProvider extends ServiceProvider
);
$routes->get(
'/d/{id:\d+(?:-[^/]*)?}[/{near}]',
'/d/{id:\d+(?:-[^/]*)?}[/{near:[^/]*}]',
'flarum.forum.discussion',
$this->action('Flarum\Forum\Actions\DiscussionAction')
);
$routes->get(
'/u/{username}[/{filter}]',
'/u/{username}[/{filter:[^/]*}]',
'flarum.forum.user',
$this->action('Flarum\Forum\Actions\ClientAction')
);

View File

@@ -1,5 +1,4 @@
<?php
/*
* This file is part of Flarum.
*
@@ -80,18 +79,18 @@ class RouteCollection
return $this->dataGenerator->getData();
}
public function getPath($name, $parameters = [])
protected function fixPathPart(&$part, $key, array $parameters)
{
if (is_array($part) && array_key_exists($part[0], $parameters)) {
$part = $parameters[$part[0]];
}
}
public function getPath($name, array $parameters = [])
{
$parts = $this->reverse[$name][0];
array_walk($parts, [$this, 'fixPathPart'], $parameters);
$path = implode('', array_map(function ($part) use ($parameters) {
if (is_array($part)) {
$part = $parameters[$part[0]];
}
return $part;
}, $parts));
$path = '/' . ltrim($path, '/');
return $path;
return '/' . ltrim(implode('', $parts), '/');
}
}

View File

@@ -11,6 +11,8 @@
namespace Flarum\Http;
use Flarum\Core;
class UrlGenerator implements UrlGeneratorInterface
{
protected $routes;
@@ -26,12 +28,11 @@ class UrlGenerator implements UrlGeneratorInterface
$path = $this->routes->getPath($name, $parameters);
$path = ltrim($path, '/');
// TODO: Prepend real base URL
return "/$path";
return Core::url() . "/$path";
}
public function toAsset($path)
{
return "/$path";
return Core::url() . "/assets/$path";
}
}

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