diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index fad36a3ad..e16029402 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -8,4 +8,4 @@ jobs: with: enable_backend_testing: true backend_directory: . - monorepo_tests: "framework/core extensions/akismet extensions/approval extensions/flags extensions/likes extensions/mentions extensions/nicknames extensions/statistics extensions/sticky extensions/subscriptions extensions/suspend extensions/tags" + monorepo_tests: "framework/core extensions/akismet extensions/approval extensions/flags extensions/likes extensions/mentions extensions/nicknames extensions/statistics extensions/sticky extensions/subscriptions extensions/suspend extensions/tags extensions/messages" diff --git a/composer.json b/composer.json index 9c0481ba4..1358721ef 100644 --- a/composer.json +++ b/composer.json @@ -53,6 +53,7 @@ "Flarum\\Subscriptions\\": "extensions/subscriptions/src", "Flarum\\Suspend\\": "extensions/suspend/src", "Flarum\\Tags\\": "extensions/tags/src", + "Flarum\\Messages\\": "extensions/messages/src", "Flarum\\PHPStan\\": "php-packages/phpstan/src", "Flarum\\Testing\\": "php-packages/testing/src" }, @@ -77,6 +78,7 @@ "Flarum\\Subscriptions\\Tests\\": "extensions/subscriptions/tests", "Flarum\\Suspend\\Tests\\": "extensions/suspend/tests", "Flarum\\Tags\\Tests\\": "extensions/tags/tests", + "Flarum\\Messages\\Tests\\": "extensions/messages/tests", "Flarum\\Testing\\Tests\\": "php-packages/testing/tests" } }, @@ -101,6 +103,7 @@ "flarum/subscriptions": "self.version", "flarum/suspend": "self.version", "flarum/tags": "self.version", + "flarum/messages": "self.version", "flarum/phpstan": "self.version", "flarum/testing": "self.version" }, @@ -196,7 +199,8 @@ "extensions/sticky", "extensions/subscriptions", "extensions/suspend", - "extensions/tags" + "extensions/tags", + "extensions/messages" ], "branch-alias": { "dev-main": "2.x-dev" diff --git a/extensions/akismet/LICENSE b/extensions/akismet/LICENSE index 54ac29ab2..bb6e15d87 100644 --- a/extensions/akismet/LICENSE +++ b/extensions/akismet/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019-2021 Stichting Flarum (Flarum Foundation) +Copyright (c) 2019-2024 Stichting Flarum (Flarum Foundation) Copyright (c) 2014-2019 Toby Zerner (toby.zerner@gmail.com) Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/extensions/approval/LICENSE b/extensions/approval/LICENSE index 54ac29ab2..bb6e15d87 100644 --- a/extensions/approval/LICENSE +++ b/extensions/approval/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019-2021 Stichting Flarum (Flarum Foundation) +Copyright (c) 2019-2024 Stichting Flarum (Flarum Foundation) Copyright (c) 2014-2019 Toby Zerner (toby.zerner@gmail.com) Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/extensions/bbcode/LICENSE b/extensions/bbcode/LICENSE index 54ac29ab2..bb6e15d87 100644 --- a/extensions/bbcode/LICENSE +++ b/extensions/bbcode/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019-2021 Stichting Flarum (Flarum Foundation) +Copyright (c) 2019-2024 Stichting Flarum (Flarum Foundation) Copyright (c) 2014-2019 Toby Zerner (toby.zerner@gmail.com) Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/extensions/embed/LICENSE b/extensions/embed/LICENSE index 54ac29ab2..bb6e15d87 100644 --- a/extensions/embed/LICENSE +++ b/extensions/embed/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019-2021 Stichting Flarum (Flarum Foundation) +Copyright (c) 2019-2024 Stichting Flarum (Flarum Foundation) Copyright (c) 2014-2019 Toby Zerner (toby.zerner@gmail.com) Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/extensions/emoji/LICENSE b/extensions/emoji/LICENSE index 54ac29ab2..bb6e15d87 100644 --- a/extensions/emoji/LICENSE +++ b/extensions/emoji/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019-2021 Stichting Flarum (Flarum Foundation) +Copyright (c) 2019-2024 Stichting Flarum (Flarum Foundation) Copyright (c) 2014-2019 Toby Zerner (toby.zerner@gmail.com) Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/extensions/flags/LICENSE b/extensions/flags/LICENSE index 54ac29ab2..bb6e15d87 100644 --- a/extensions/flags/LICENSE +++ b/extensions/flags/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019-2021 Stichting Flarum (Flarum Foundation) +Copyright (c) 2019-2024 Stichting Flarum (Flarum Foundation) Copyright (c) 2014-2019 Toby Zerner (toby.zerner@gmail.com) Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/extensions/flags/js/src/forum/components/FlagList.tsx b/extensions/flags/js/src/forum/components/FlagList.tsx index eb11b10aa..4b433e399 100644 --- a/extensions/flags/js/src/forum/components/FlagList.tsx +++ b/extensions/flags/js/src/forum/components/FlagList.tsx @@ -10,6 +10,7 @@ import type Post from 'flarum/common/models/Post'; import type FlagListState from '../states/FlagListState'; import type Flag from '../models/Flag'; import { Page } from 'flarum/common/states/PaginatedListState'; +import ItemList from 'flarum/common/utils/ItemList'; export interface IFlagListAttrs extends ComponentAttrs { state: FlagListState; @@ -27,6 +28,7 @@ export default class FlagList) => { diff --git a/extensions/lang-english/LICENSE b/extensions/lang-english/LICENSE index 54ac29ab2..bb6e15d87 100644 --- a/extensions/lang-english/LICENSE +++ b/extensions/lang-english/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019-2021 Stichting Flarum (Flarum Foundation) +Copyright (c) 2019-2024 Stichting Flarum (Flarum Foundation) Copyright (c) 2014-2019 Toby Zerner (toby.zerner@gmail.com) Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/extensions/likes/LICENSE b/extensions/likes/LICENSE index 54ac29ab2..bb6e15d87 100644 --- a/extensions/likes/LICENSE +++ b/extensions/likes/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019-2021 Stichting Flarum (Flarum Foundation) +Copyright (c) 2019-2024 Stichting Flarum (Flarum Foundation) Copyright (c) 2014-2019 Toby Zerner (toby.zerner@gmail.com) Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/extensions/likes/src/Api/PostResourceFields.php b/extensions/likes/src/Api/PostResourceFields.php index 9f16b286f..ad4a3f403 100644 --- a/extensions/likes/src/Api/PostResourceFields.php +++ b/extensions/likes/src/Api/PostResourceFields.php @@ -14,7 +14,7 @@ use Flarum\Api\Schema; use Flarum\Likes\Event\PostWasLiked; use Flarum\Likes\Event\PostWasUnliked; use Flarum\Post\Post; -use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Query\Expression; class PostResourceFields @@ -51,7 +51,7 @@ class PostResourceFields Schema\Relationship\ToMany::make('likes') ->type('users') ->includable() - ->scope(function (Builder $query, Context $context) { + ->scope(function (BelongsToMany $query, Context $context) { $actor = $context->getActor(); $grammar = $query->getQuery()->getGrammar(); diff --git a/extensions/likes/src/Notification/PostLikedBlueprint.php b/extensions/likes/src/Notification/PostLikedBlueprint.php index b5b9854eb..bbb00c62a 100644 --- a/extensions/likes/src/Notification/PostLikedBlueprint.php +++ b/extensions/likes/src/Notification/PostLikedBlueprint.php @@ -10,11 +10,12 @@ namespace Flarum\Likes\Notification; use Flarum\Database\AbstractModel; +use Flarum\Notification\AlertableInterface; use Flarum\Notification\Blueprint\BlueprintInterface; use Flarum\Post\Post; use Flarum\User\User; -class PostLikedBlueprint implements BlueprintInterface +class PostLikedBlueprint implements BlueprintInterface, AlertableInterface { public function __construct( public Post $post, diff --git a/extensions/lock/LICENSE b/extensions/lock/LICENSE index 54ac29ab2..bb6e15d87 100644 --- a/extensions/lock/LICENSE +++ b/extensions/lock/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019-2021 Stichting Flarum (Flarum Foundation) +Copyright (c) 2019-2024 Stichting Flarum (Flarum Foundation) Copyright (c) 2014-2019 Toby Zerner (toby.zerner@gmail.com) Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/extensions/lock/src/Notification/DiscussionLockedBlueprint.php b/extensions/lock/src/Notification/DiscussionLockedBlueprint.php index e9614cdae..0424043bc 100644 --- a/extensions/lock/src/Notification/DiscussionLockedBlueprint.php +++ b/extensions/lock/src/Notification/DiscussionLockedBlueprint.php @@ -12,10 +12,11 @@ namespace Flarum\Lock\Notification; use Flarum\Database\AbstractModel; use Flarum\Discussion\Discussion; use Flarum\Lock\Post\DiscussionLockedPost; +use Flarum\Notification\AlertableInterface; use Flarum\Notification\Blueprint\BlueprintInterface; use Flarum\User\User; -class DiscussionLockedBlueprint implements BlueprintInterface +class DiscussionLockedBlueprint implements BlueprintInterface, AlertableInterface { public function __construct( protected DiscussionLockedPost $post diff --git a/extensions/markdown/LICENSE b/extensions/markdown/LICENSE index 5d2e42a64..c3d3b78ad 100644 --- a/extensions/markdown/LICENSE +++ b/extensions/markdown/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019-2021 Stichting Flarum (Flarum Foundation) +Copyright (c) 2019-2024 Stichting Flarum (Flarum Foundation) Copyright (c) 2014-2019 Toby Zerner (toby.zerner@gmail.com) Copyright (c) 2017-2018 GitHub, Inc. diff --git a/extensions/mentions/LICENSE b/extensions/mentions/LICENSE index 54ac29ab2..bb6e15d87 100644 --- a/extensions/mentions/LICENSE +++ b/extensions/mentions/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019-2021 Stichting Flarum (Flarum Foundation) +Copyright (c) 2019-2024 Stichting Flarum (Flarum Foundation) Copyright (c) 2014-2019 Toby Zerner (toby.zerner@gmail.com) Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/extensions/mentions/src/Api/PostResourceFields.php b/extensions/mentions/src/Api/PostResourceFields.php index 64b800eaf..3793acca6 100644 --- a/extensions/mentions/src/Api/PostResourceFields.php +++ b/extensions/mentions/src/Api/PostResourceFields.php @@ -9,8 +9,10 @@ namespace Flarum\Mentions\Api; +use Flarum\Api\Context; use Flarum\Api\Schema; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; class PostResourceFields { @@ -20,12 +22,14 @@ class PostResourceFields { return [ Schema\Integer::make('mentionedByCount') - ->countRelation('mentionedBy'), + ->countRelation('mentionedBy', function (Builder $query, Context $context) { + $query->whereVisibleTo($context->getActor()); + }), Schema\Relationship\ToMany::make('mentionedBy') ->type('posts') ->includable() - ->scope(fn (Builder $query) => $query->oldest('id')->limit(static::$maxMentionedBy)), + ->scope(fn (BelongsToMany $query) => $query->oldest('id')->limit(static::$maxMentionedBy)), Schema\Relationship\ToMany::make('mentionsPosts') ->type('posts'), Schema\Relationship\ToMany::make('mentionsUsers') diff --git a/extensions/mentions/src/Notification/GroupMentionedBlueprint.php b/extensions/mentions/src/Notification/GroupMentionedBlueprint.php index f70c9f485..327894ee9 100644 --- a/extensions/mentions/src/Notification/GroupMentionedBlueprint.php +++ b/extensions/mentions/src/Notification/GroupMentionedBlueprint.php @@ -11,12 +11,13 @@ namespace Flarum\Mentions\Notification; use Flarum\Database\AbstractModel; use Flarum\Locale\TranslatorInterface; +use Flarum\Notification\AlertableInterface; use Flarum\Notification\Blueprint\BlueprintInterface; use Flarum\Notification\MailableInterface; use Flarum\Post\Post; use Flarum\User\User; -class GroupMentionedBlueprint implements BlueprintInterface, MailableInterface +class GroupMentionedBlueprint implements BlueprintInterface, AlertableInterface, MailableInterface { public function __construct( public Post $post diff --git a/extensions/mentions/src/Notification/PostMentionedBlueprint.php b/extensions/mentions/src/Notification/PostMentionedBlueprint.php index 81594d8d9..71e00055d 100644 --- a/extensions/mentions/src/Notification/PostMentionedBlueprint.php +++ b/extensions/mentions/src/Notification/PostMentionedBlueprint.php @@ -11,12 +11,13 @@ namespace Flarum\Mentions\Notification; use Flarum\Database\AbstractModel; use Flarum\Locale\TranslatorInterface; +use Flarum\Notification\AlertableInterface; use Flarum\Notification\Blueprint\BlueprintInterface; use Flarum\Notification\MailableInterface; use Flarum\Post\Post; use Flarum\User\User; -class PostMentionedBlueprint implements BlueprintInterface, MailableInterface +class PostMentionedBlueprint implements BlueprintInterface, AlertableInterface, MailableInterface { public function __construct( public Post $post, diff --git a/extensions/mentions/src/Notification/UserMentionedBlueprint.php b/extensions/mentions/src/Notification/UserMentionedBlueprint.php index acdf2ab6f..6c90038dc 100644 --- a/extensions/mentions/src/Notification/UserMentionedBlueprint.php +++ b/extensions/mentions/src/Notification/UserMentionedBlueprint.php @@ -11,12 +11,13 @@ namespace Flarum\Mentions\Notification; use Flarum\Database\AbstractModel; use Flarum\Locale\TranslatorInterface; +use Flarum\Notification\AlertableInterface; use Flarum\Notification\Blueprint\BlueprintInterface; use Flarum\Notification\MailableInterface; use Flarum\Post\Post; use Flarum\User\User; -class UserMentionedBlueprint implements BlueprintInterface, MailableInterface +class UserMentionedBlueprint implements BlueprintInterface, AlertableInterface, MailableInterface { public function __construct( public Post $post diff --git a/extensions/messages/.editorconfig b/extensions/messages/.editorconfig new file mode 100644 index 000000000..a61a3ab36 --- /dev/null +++ b/extensions/messages/.editorconfig @@ -0,0 +1,19 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +indent_size = 2 + +[*.{diff,md}] +trim_trailing_whitespace = false + +[*.{php,xml,json}] +indent_size = 4 diff --git a/extensions/messages/.gitattributes b/extensions/messages/.gitattributes new file mode 100644 index 000000000..71b028f4d --- /dev/null +++ b/extensions/messages/.gitattributes @@ -0,0 +1,20 @@ +**/.gitattributes export-ignore +**/.gitignore export-ignore +**/.gitmodules export-ignore +**/.github export-ignore +**/.travis export-ignore +**/.travis.yml export-ignore +**/.editorconfig export-ignore +**/.styleci.yml export-ignore + +**/phpunit.xml export-ignore +**/tests export-ignore + +**/js/dist/**/* -diff +**/js/dist/**/* linguist-generated +**/js/dist-typings/**/* -diff +**/js/dist-typings/**/* linguist-generated +**/js/yarn.lock -diff +**/js/package-lock.json -diff + +* text=auto eol=lf diff --git a/extensions/messages/.gitignore b/extensions/messages/.gitignore new file mode 100644 index 000000000..e60e51206 --- /dev/null +++ b/extensions/messages/.gitignore @@ -0,0 +1,13 @@ +/vendor +composer.lock +composer.phar + +.DS_Store +Thumbs.db +tests/.phpunit.cache +tests/.phpunit.result.cache +/tests/integration/tmp +.vagrant +.idea/* +.vscode +js/coverage-ts diff --git a/extensions/messages/LICENSE b/extensions/messages/LICENSE new file mode 100644 index 000000000..bb6e15d87 --- /dev/null +++ b/extensions/messages/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2019-2024 Stichting Flarum (Flarum Foundation) +Copyright (c) 2014-2019 Toby Zerner (toby.zerner@gmail.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/messages/composer.json b/extensions/messages/composer.json new file mode 100644 index 000000000..506995447 --- /dev/null +++ b/extensions/messages/composer.json @@ -0,0 +1,80 @@ +{ + "name": "flarum/messages", + "description": "Private messaging ", + "keywords": [ + "flarum" + ], + "type": "flarum-extension", + "license": "MIT", + "require": { + "flarum/core": "^2.0" + }, + "authors": [ + { + "name": "Flarum", + "email": "info@flarum.org", + "role": "Developer" + } + ], + "autoload": { + "psr-4": { + "Flarum\\Messages\\": "src/" + } + }, + "extra": { + "flarum-extension": { + "title": "Messages", + "category": "feature", + "icon": { + "name": "fas fa-envelope-open", + "color": "#ffffff", + "backgroundColor": "#9b34c7" + } + }, + "flarum-cli": { + "modules": { + "admin": true, + "forum": true, + "js": true, + "jsCommon": true, + "css": true, + "locale": true, + "gitConf": true, + "githubActions": true, + "prettier": true, + "typescript": true, + "bundlewatch": false, + "frontendTesting": true, + "backendTesting": true, + "phpstan": false, + "editorConfig": true, + "styleci": true + } + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "autoload-dev": { + "psr-4": { + "Flarum\\Messages\\Tests\\": "tests/" + } + }, + "scripts": { + "test": [ + "@test:unit", + "@test:integration" + ], + "test:unit": "phpunit -c tests/phpunit.unit.xml", + "test:integration": "phpunit -c tests/phpunit.integration.xml", + "test:setup": "@php tests/integration/setup.php" + }, + "scripts-descriptions": { + "test": "Runs all tests.", + "test:unit": "Runs all unit tests.", + "test:integration": "Runs all integration tests.", + "test:setup": "Sets up a database for use with integration tests. Execute this only once." + }, + "require-dev": { + "flarum/testing": "^2.0" + } +} diff --git a/extensions/messages/extend.php b/extensions/messages/extend.php new file mode 100644 index 000000000..a82883169 --- /dev/null +++ b/extensions/messages/extend.php @@ -0,0 +1,87 @@ +js(__DIR__.'/js/dist/forum.js') + ->css(__DIR__.'/less/forum.less') + ->jsDirectory(__DIR__.'/js/dist/forum') + ->route('/messages', 'messages') + ->route('/messages/dialog/{id:\d+}', 'messages.dialog'), + + (new Extend\Frontend('admin')) + ->js(__DIR__.'/js/dist/admin.js') + ->css(__DIR__.'/less/admin.less'), + + new Extend\Locales(__DIR__.'/locale'), + + (new Extend\View())->namespace('flarum-messages', __DIR__.'/views'), + + (new Extend\Model(User::class)) + ->belongsToMany('dialogs', Dialog::class, 'dialog_user') + ->hasMany('dialogMessages', DialogMessage::class, 'user_id'), + + (new Extend\ModelVisibility(Dialog::class)) + ->scope(Access\ScopeDialogVisibility::class), + + (new Extend\ModelVisibility(DialogMessage::class)) + ->scope(Access\ScopeDialogMessageVisibility::class), + + new Extend\ApiResource(Api\Resource\DialogResource::class), + + new Extend\ApiResource(Api\Resource\DialogMessageResource::class), + + (new Extend\ApiResource(Resource\UserResource::class)) + ->fields(fn () => [ + Schema\Boolean::make('canSendAnyMessage') + ->get(fn (object $model, Context $context) => $context->getActor()->can('sendAnyMessage')), + Schema\Integer::make('messageCount') + ->get(function (object $model, Context $context) { + return Dialog::whereVisibleTo($context->getActor()) + ->whereHas('users', function (Builder $query) use ($context) { + $query->where('dialog_user.user_id', $context->getActor()->id) + ->whereColumn('dialog_user.last_read_message_id', '<', 'dialogs.last_message_id'); + })->count(); + }), + ]), + + (new Extend\Middleware('api')) + ->add(PopulateDialogWithActor::class), + + (new Extend\Policy()) + ->modelPolicy(Dialog::class, Access\DialogPolicy::class) + ->modelPolicy(DialogMessage::class, Access\DialogMessagePolicy::class) + ->globalPolicy(Access\GlobalPolicy::class), + + (new Extend\SearchDriver(DatabaseSearchDriver::class)) + ->addSearcher(Dialog::class, Search\DialogSearcher::class) + ->addSearcher(DialogMessage::class, Search\DialogMessageSearcher::class) + ->addFilter(Search\DialogMessageSearcher::class, DialogMessage\Filter\DialogFilter::class) + ->addFilter(Search\DialogSearcher::class, Dialog\Filter\UnreadFilter::class), + + (new Extend\ServiceProvider()) + ->register(DialogServiceProvider::class), + + (new Extend\Notification()) + ->type(Notification\MessageReceivedBlueprint::class, ['email']), + + (new Extend\Event()) + ->listen(DialogMessage\Event\Created::class, Listener\SendNotificationWhenMessageSent::class), +]; diff --git a/extensions/messages/js/.gitignore b/extensions/messages/js/.gitignore new file mode 100644 index 000000000..adc90f312 --- /dev/null +++ b/extensions/messages/js/.gitignore @@ -0,0 +1,9 @@ +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +node_modules diff --git a/extensions/messages/js/admin.ts b/extensions/messages/js/admin.ts new file mode 100644 index 000000000..1f9e14ab9 --- /dev/null +++ b/extensions/messages/js/admin.ts @@ -0,0 +1,2 @@ +export * from './src/common'; +export * from './src/admin'; diff --git a/extensions/messages/js/forum.ts b/extensions/messages/js/forum.ts new file mode 100644 index 000000000..f49b87108 --- /dev/null +++ b/extensions/messages/js/forum.ts @@ -0,0 +1,2 @@ +export * from './src/common'; +export * from './src/forum'; diff --git a/extensions/messages/js/jest.config.cjs b/extensions/messages/js/jest.config.cjs new file mode 100644 index 000000000..a2d3d0f14 --- /dev/null +++ b/extensions/messages/js/jest.config.cjs @@ -0,0 +1 @@ +module.exports = require('@flarum/jest-config')({}); diff --git a/extensions/messages/js/package.json b/extensions/messages/js/package.json new file mode 100644 index 000000000..cfcfab940 --- /dev/null +++ b/extensions/messages/js/package.json @@ -0,0 +1,31 @@ +{ + "name": "@flarum/messages", + "private": true, + "version": "0.0.0", + "devDependencies": { + "flarum-webpack-config": "^3.0.0", + "webpack": "^5.65.0", + "webpack-cli": "^4.9.1", + "prettier": "^2.5.1", + "@flarum/prettier-config": "^1.0.0", + "flarum-tsconfig": "^1.0.2", + "typescript": "^4.5.4", + "typescript-coverage-report": "^0.6.1", + "@flarum/jest-config": "^1.0.1" + }, + "scripts": { + "dev": "webpack --mode development --watch", + "build": "webpack --mode production", + "analyze": "cross-env ANALYZER=true yarn run build", + "format": "prettier --write src", + "format-check": "prettier --check src", + "clean-typings": "npx rimraf dist-typings && mkdir dist-typings", + "build-typings": "yarn run clean-typings && ([ -e src/@types ] && cp -r src/@types dist-typings/@types || true) && tsc && yarn run post-build-typings", + "post-build-typings": "find dist-typings -type f -name '*.d.ts' -print0 | xargs -0 sed -i 's,../src/@types,@types,g'", + "check-typings": "tsc --noEmit --emitDeclarationOnly false", + "check-typings-coverage": "typescript-coverage-report", + "test": "yarn node --experimental-vm-modules $(yarn bin jest)" + }, + "prettier": "@flarum/prettier-config", + "type": "module" +} diff --git a/extensions/messages/js/src/@types/shims.d.ts b/extensions/messages/js/src/@types/shims.d.ts new file mode 100644 index 000000000..42e42031b --- /dev/null +++ b/extensions/messages/js/src/@types/shims.d.ts @@ -0,0 +1,21 @@ +import type Dialog from '../common/models/Dialog'; +import DialogListState from '../forum/states/DialogListState'; + +declare module 'flarum/forum/routes' { + export interface ForumRoutes { + dialog: (tag: Dialog) => string; + } +} + +declare module 'flarum/forum/ForumApplication' { + export default interface ForumApplication { + dialogs: DialogListState; + dropdownDialogs: DialogListState; + } +} + +declare module 'flarum/forum/states/ComposerState' { + export default interface ComposerState { + composingMessageTo(dialog: Dialog): boolean; + } +} diff --git a/extensions/messages/js/src/admin/extend.ts b/extensions/messages/js/src/admin/extend.ts new file mode 100644 index 000000000..f3e3a9c00 --- /dev/null +++ b/extensions/messages/js/src/admin/extend.ts @@ -0,0 +1,8 @@ +import Extend from 'flarum/common/extenders'; +import commonExtend from '../common/extend'; + +export default [ + ...commonExtend, + + // Add your admin extenders here +]; diff --git a/extensions/messages/js/src/admin/index.ts b/extensions/messages/js/src/admin/index.ts new file mode 100644 index 000000000..bd8bc3864 --- /dev/null +++ b/extensions/messages/js/src/admin/index.ts @@ -0,0 +1,16 @@ +import app from 'flarum/admin/app'; + +export { default as extend } from './extend'; + +app.initializers.add('flarum-messages', () => { + app.extensionData.for('flarum-messages').registerPermission( + { + icon: 'fas fa-envelope-open-text', + label: app.translator.trans('flarum-messages.admin.permissions.send_messages'), + permission: 'dialog.sendMessage', + allowGuest: false, + }, + 'start', + 98 + ); +}); diff --git a/extensions/messages/js/src/common/extend.ts b/extensions/messages/js/src/common/extend.ts new file mode 100644 index 000000000..fc325472d --- /dev/null +++ b/extensions/messages/js/src/common/extend.ts @@ -0,0 +1,9 @@ +import DialogMessage from './models/DialogMessage'; +import Dialog from './models/Dialog'; +import Extend from 'flarum/common/extenders'; + +export default [ + new Extend.Store() + .add('dialogs', Dialog) // + .add('dialog-messages', DialogMessage), // +]; diff --git a/extensions/messages/js/src/common/index.ts b/extensions/messages/js/src/common/index.ts new file mode 100644 index 000000000..7646bbd17 --- /dev/null +++ b/extensions/messages/js/src/common/index.ts @@ -0,0 +1 @@ +export default null; diff --git a/extensions/messages/js/src/common/models/Dialog.ts b/extensions/messages/js/src/common/models/Dialog.ts new file mode 100644 index 000000000..1d54ebb82 --- /dev/null +++ b/extensions/messages/js/src/common/models/Dialog.ts @@ -0,0 +1,45 @@ +import Model from 'flarum/common/Model'; +import User from 'flarum/common/models/User'; +import DialogMessage from './DialogMessage'; +import app from 'flarum/common/app'; + +export default class Dialog extends Model { + title() { + return Model.attribute('title').call(this); + } + type() { + return Model.attribute('type').call(this); + } + lastMessageAt() { + return Model.attribute('lastMessageAt', Model.transformDate).call(this); + } + createdAt() { + return Model.attribute('createdAt', Model.transformDate).call(this); + } + + users() { + return Model.hasMany('users').call(this); + } + firstMessage() { + return Model.hasOne('firstMessage').call(this); + } + lastMessage() { + return Model.hasOne('lastMessage').call(this); + } + + unreadCount() { + return Model.attribute('unreadCount').call(this); + } + lastReadMessageId() { + return Model.attribute('lastReadMessageId').call(this); + } + lastReadAt() { + return Model.attribute('lastReadAt', Model.transformDate).call(this); + } + + recipient() { + let users = this.users(); + + return !users ? null : users.find((user) => user && user.id() !== app.session.user!.id()); + } +} diff --git a/extensions/messages/js/src/common/models/DialogMessage.ts b/extensions/messages/js/src/common/models/DialogMessage.ts new file mode 100644 index 000000000..3dcab66a9 --- /dev/null +++ b/extensions/messages/js/src/common/models/DialogMessage.ts @@ -0,0 +1,36 @@ +import Model from 'flarum/common/Model'; +import computed from 'flarum/common/utils/computed'; +import { getPlainContent } from 'flarum/common/utils/string'; +import type Dialog from './Dialog'; +import type User from 'flarum/common/models/User'; + +export default class DialogMessage extends Model { + content() { + return Model.attribute('content').call(this); + } + contentHtml() { + return Model.attribute('contentHtml').call(this); + } + renderFailed() { + return Model.attribute('renderFailed').call(this); + } + contentPlain() { + return computed('contentHtml', (content) => { + if (typeof content === 'string') { + return getPlainContent(content); + } + + return content as null | undefined; + }).call(this); + } + createdAt() { + return Model.attribute('createdAt', Model.transformDate).call(this); + } + + dialog() { + return Model.hasOne('dialog').call(this); + } + user() { + return Model.hasOne('user').call(this); + } +} diff --git a/extensions/messages/js/src/forum/components/DetailsModal.tsx b/extensions/messages/js/src/forum/components/DetailsModal.tsx new file mode 100644 index 000000000..c92f246c5 --- /dev/null +++ b/extensions/messages/js/src/forum/components/DetailsModal.tsx @@ -0,0 +1,65 @@ +import app from 'flarum/forum/app'; +import Modal, { type IInternalModalAttrs } from 'flarum/common/components/Modal'; +import type Dialog from '../../common/models/Dialog'; +import type User from 'flarum/common/models/User'; +import ItemList from 'flarum/common/utils/ItemList'; +import Mithril from 'mithril'; +import Avatar from 'flarum/common/components/Avatar'; +import fullTime from 'flarum/common/helpers/fullTime'; +import username from 'flarum/common/helpers/username'; +import Link from 'flarum/common/components/Link'; +import listItems from 'flarum/common/helpers/listItems'; + +export interface IDetailsModalAttrs extends IInternalModalAttrs { + dialog: Dialog; +} + +export default class DetailsModal extends Modal { + className() { + return 'Modal--small Modal--flat DetailsModal'; + } + + title() { + return app.translator.trans('flarum-messages.forum.dialog_section.details_modal.title'); + } + + content() { + let recipients = (this.attrs.dialog.users() || []).filter(Boolean) as User[]; + + return ( +
+
+
{app.translator.trans('flarum-messages.forum.dialog_section.details_modal.recipients')}
+
+ {recipients?.map((recipient: User) => { + return ( +
+ + + {username(recipient)} + +
{listItems(recipient.badges().toArray())}
+
+ ); + })} +
+
+ {this.infoItems().toArray()} +
+ ); + } + + infoItems() { + const items = new ItemList(); + + items.add( + 'created', +
+
{app.translator.trans('flarum-messages.forum.dialog_section.details_modal.created_at')}
+
{fullTime(this.attrs.dialog.createdAt())}
+
+ ); + + return items; + } +} diff --git a/extensions/messages/js/src/forum/components/DialogDropdownList.tsx b/extensions/messages/js/src/forum/components/DialogDropdownList.tsx new file mode 100644 index 000000000..84ec13afb --- /dev/null +++ b/extensions/messages/js/src/forum/components/DialogDropdownList.tsx @@ -0,0 +1,76 @@ +import app from 'flarum/forum/app'; +import Component from 'flarum/common/Component'; +import type { ComponentAttrs } from 'flarum/common/Component'; +import HeaderList from 'flarum/forum/components/HeaderList'; +import type Mithril from 'mithril'; +import DialogListState from '../states/DialogListState'; +import DialogList from './DialogList'; +import LinkButton from 'flarum/common/components/LinkButton'; +import ItemList from 'flarum/common/utils/ItemList'; +import Tooltip from 'flarum/common/components/Tooltip'; +import Button from 'flarum/common/components/Button'; + +export interface IDialogListDropdownAttrs extends ComponentAttrs { + state: DialogListState; +} + +export default class DialogDropdownList extends Component< + CustomAttrs, + DialogListState +> { + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + } + + view() { + const state = this.attrs.state; + + return ( + state.hasNext() && !state.isLoadingNext() && state.loadNext()} + footer={() => ( +

+ + {app.translator.trans('flarum-messages.forum.dialog_list.view_all')} + +

+ )} + > +
{this.content()}
+
+ ); + } + + controlItems() { + const items = new ItemList(); + const state = this.attrs.state; + + if (app.session.user!.attribute('messageCount') > 0) { + items.add( + 'mark_all_as_read', + + + + )} + + ); + } +} diff --git a/extensions/messages/js/src/forum/components/DialogListItem.tsx b/extensions/messages/js/src/forum/components/DialogListItem.tsx new file mode 100644 index 000000000..4a7c33465 --- /dev/null +++ b/extensions/messages/js/src/forum/components/DialogListItem.tsx @@ -0,0 +1,88 @@ +import Component, { type ComponentAttrs } from 'flarum/common/Component'; +import Mithril from 'mithril'; +import classList from 'flarum/common/utils/classList'; +import Link from 'flarum/common/components/Link'; +import app from 'flarum/forum/app'; +import Avatar from 'flarum/common/components/Avatar'; +import username from 'flarum/common/helpers/username'; +import humanTime from 'flarum/common/helpers/humanTime'; +import ItemList from 'flarum/common/utils/ItemList'; +import Button from 'flarum/common/components/Button'; +import type Dialog from '../../common/models/Dialog'; +import { ModelIdentifier } from 'flarum/common/Model'; + +export interface IDialogListItemAttrs extends ComponentAttrs { + dialog: Dialog; + active?: boolean; + actions?: boolean; +} + +export default class DialogListItem extends Component { + view(vnode: Mithril.Vnode) { + const dialog = this.attrs.dialog; + + const recipient = dialog.recipient(); + const lastMessage = dialog.lastMessage(); + + return ( +
  • + +
    + + {!!dialog.unreadCount() &&
    {dialog.unreadCount()}
    } +
    +
    +
    + {username(recipient)} + {humanTime(dialog.lastMessageAt()!)} + {this.attrs.actions &&
    {this.actionItems().toArray()}
    } +
    +
    {lastMessage ? lastMessage.contentPlain()?.slice(0, 80) : ''}
    +
    + +
  • + ); + } + + actionItems(): ItemList { + const items = new ItemList(); + + items.add( + 'markAsRead', + + ); + + return items; + } +} diff --git a/extensions/messages/js/src/forum/components/DialogsDropdown.tsx b/extensions/messages/js/src/forum/components/DialogsDropdown.tsx new file mode 100644 index 000000000..0b93dae5e --- /dev/null +++ b/extensions/messages/js/src/forum/components/DialogsDropdown.tsx @@ -0,0 +1,43 @@ +import app from 'flarum/forum/app'; +import HeaderDropdown from 'flarum/forum/components/HeaderDropdown'; +import type { IHeaderDropdownAttrs } from 'flarum/forum/components/HeaderDropdown'; +import classList from 'flarum/common/utils/classList'; +import LoadingIndicator from 'flarum/common/components/LoadingIndicator'; + +export interface IDialogsDropdownAttrs extends IHeaderDropdownAttrs {} + +export default class DialogsDropdown extends HeaderDropdown { + protected DialogDropdownList: any = null; + + static initAttrs(attrs: IDialogsDropdownAttrs) { + attrs.className = classList('DialogsDropdown', attrs.className); + attrs.label = attrs.label || app.translator.trans('flarum-messages.forum.header.dropdown_tooltip'); + attrs.icon = attrs.icon || 'fas fa-envelope'; + + super.initAttrs(attrs); + } + + getContent() { + if (!this.DialogDropdownList) { + import('./DialogDropdownList').then((DialogDropdownList) => { + this.DialogDropdownList = DialogDropdownList.default; + }); + + return ; + } + + return ; + } + + goToRoute() { + m.route.set(app.route('dialogs')); + } + + getUnreadCount() { + return app.session.user!.attribute('messageCount'); + } + + getNewCount() { + return app.session.user!.attribute('messageCount'); + } +} diff --git a/extensions/messages/js/src/forum/components/Message.tsx b/extensions/messages/js/src/forum/components/Message.tsx new file mode 100644 index 000000000..cf352f893 --- /dev/null +++ b/extensions/messages/js/src/forum/components/Message.tsx @@ -0,0 +1,112 @@ +import app from 'flarum/forum/app'; +import ItemList from 'flarum/common/utils/ItemList'; +import Mithril from 'mithril'; +import AbstractPost, { type IAbstractPostAttrs } from 'flarum/forum/components/AbstractPost'; +import type User from 'flarum/common/models/User'; +import DialogMessage from '../../common/models/DialogMessage'; +import Avatar from 'flarum/common/components/Avatar'; +import Comment from 'flarum/forum/components/Comment'; +import PostUser from 'flarum/forum/components/PostUser'; +import PostMeta from 'flarum/forum/components/PostMeta'; +import classList from 'flarum/common/utils/classList'; + +export interface IMessageAttrs extends IAbstractPostAttrs { + message: DialogMessage; +} + +/** + * The `Post` component displays a single post. The basic post template just + * includes a controls dropdown; subclasses must implement `content` and `attrs` + * methods. + */ +export default abstract class Message extends AbstractPost { + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + } + + user(): User | null | false { + return this.attrs.message.user(); + } + + controls(): Mithril.Children[] { + return []; + } + + freshness(): Date { + return this.attrs.message.freshness; + } + + createdByStarter(): boolean { + return false; + } + + onbeforeupdate(vnode: Mithril.VnodeDOM) { + return super.onbeforeupdate(vnode); + } + + onupdate(vnode: Mithril.VnodeDOM) { + super.onupdate(vnode); + } + + elementAttrs() { + const message = this.attrs.message; + const attrs = super.elementAttrs(); + + attrs.className = classList(attrs.className || null, 'Message', { + 'Post--renderFailed': message.renderFailed(), + revealContent: false, + editing: false, + }); + + return attrs; + } + + header(): Mithril.Children { + return super.header(); + } + + content(): Mithril.Children[] { + return super + .content() + .concat([ + , + ]); + } + + classes(existing?: string): string[] { + return super.classes(existing); + } + + actionItems(): ItemList { + return super.actionItems(); + } + + footerItems(): ItemList { + return super.footerItems(); + } + + sideItems(): ItemList { + return super.sideItems(); + } + + avatar(): Mithril.Children { + return this.attrs.message.user() ? : ''; + } + + headerItems() { + const items = new ItemList(); + const message = this.attrs.message; + + items.add('user', , 100); + items.add('meta', ); + + return items; + } +} diff --git a/extensions/messages/js/src/forum/components/MessageComposer.tsx b/extensions/messages/js/src/forum/components/MessageComposer.tsx new file mode 100644 index 000000000..2c41aec97 --- /dev/null +++ b/extensions/messages/js/src/forum/components/MessageComposer.tsx @@ -0,0 +1,143 @@ +import app from 'flarum/forum/app'; +import ComposerBody, { IComposerBodyAttrs } from 'flarum/forum/components/ComposerBody'; +import extractText from 'flarum/common/utils/extractText'; +import Stream from 'flarum/common/utils/Stream'; +import type User from 'flarum/common/models/User'; +import type Mithril from 'mithril'; +import Button from 'flarum/common/components/Button'; +import UserSelectionModal from 'flarum/common/components/UserSelectionModal'; +import DialogMessage from '../../common/models/DialogMessage'; +import Avatar from 'flarum/common/components/Avatar'; +import Tooltip from 'flarum/common/components/Tooltip'; +import type Dialog from '../../common/models/Dialog'; + +export interface IMessageComposerAttrs extends IComposerBodyAttrs { + replyingTo?: Dialog; + onsubmit?: (message: DialogMessage) => void; + recipients?: User[]; +} + +/** + * The `MessageComposer` component displays the composer content for sending + * a new message. It adds a selection field as a header control so the user can + * enter the recipient(s) of their message. + */ +export default class MessageComposer extends ComposerBody { + protected recipients!: Stream; + + static focusOnSelector = () => '.TextEditor-editor'; + + static initAttrs(attrs: IMessageComposerAttrs) { + super.initAttrs(attrs); + + attrs.placeholder = attrs.placeholder || extractText(app.translator.trans('flarum-messages.forum.composer.placeholder', {}, true)); + attrs.submitLabel = attrs.submitLabel || app.translator.trans('flarum-messages.forum.composer.submit_button', {}, true); + attrs.confirmExit = attrs.confirmExit || extractText(app.translator.trans('flarum-messages.forum.composer.discard_confirmation', {}, true)); + attrs.className = 'ComposerBody--message'; + } + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + + let users = this.attrs.replyingTo?.users() || this.attrs.recipients || []; + + if (users) { + users = users.filter((user) => user && user.id() !== app.session.user!.id()); + } + + this.composer.fields.recipients = this.composer.fields.recipients || Stream(users); + + this.recipients = this.composer.fields.recipients; + } + + headerItems() { + const items = super.headerItems(); + + items.add( + 'recipients', +
    + {!this.attrs.replyingTo && ( + + )} + {!!this.recipients().length && ( +
    {app.translator.trans('flarum-messages.forum.composer.to')}
    + )} +
      + {this.recipients().map((user) => ( +
    • + + + +
    • + ))} +
    +
    , + 100 + ); + + return items; + } + + /** + * Get the data to submit to the server when the discussion is saved. + */ + data(): Record { + const data: any = { + content: this.composer.fields.content(), + }; + + if (this.attrs.replyingTo) { + data.relationships = { + dialog: { + data: { + id: this.attrs.replyingTo.id(), + type: 'dialogs', + }, + }, + }; + } else { + data.users = this.recipients().map((user) => ({ + id: user.id(), + })); + } + + return data; + } + + onsubmit() { + this.loading = true; + + const data = this.data(); + + app.store + .createRecord('dialog-messages') + .save(data, { + params: { + include: ['dialog'], + }, + }) + .then((message) => { + this.composer.hide(); + // @todo: app.dialogs.refresh(); + // @ts-ignore + m.route.set(app.route('dialog', { id: message.data.relationships!.dialog.data.id })); + this.attrs.onsubmit?.(message); + }, this.loaded.bind(this)); + } +} diff --git a/extensions/messages/js/src/forum/components/MessageStream.tsx b/extensions/messages/js/src/forum/components/MessageStream.tsx new file mode 100644 index 000000000..a5ea07fb0 --- /dev/null +++ b/extensions/messages/js/src/forum/components/MessageStream.tsx @@ -0,0 +1,238 @@ +import app from 'flarum/forum/app'; +import Component, { type ComponentAttrs } from 'flarum/common/Component'; +import Mithril from 'mithril'; +import LoadingIndicator from 'flarum/common/components/LoadingIndicator'; +import MessageStreamState from '../states/MessageStreamState'; +import DialogMessage from '../../common/models/DialogMessage'; +import Stream from 'flarum/common/utils/Stream'; +import Button from 'flarum/common/components/Button'; +import { ModelIdentifier } from 'flarum/common/Model'; +import ScrollListener from 'flarum/common/utils/ScrollListener'; +import Dialog from '../../common/models/Dialog'; +import Message from './Message'; + +export interface IDialogStreamAttrs extends ComponentAttrs { + dialog: Dialog; + state: MessageStreamState; +} + +export default class MessageStream extends Component { + protected replyPlaceholderComponent = Stream(null); + protected loadingPostComponent = Stream(null); + protected scrollListener!: ScrollListener; + protected initialToBottomScroll = false; + protected lastTime: Date | null = null; + protected checkedRead = false; + protected markingAsRead = false; + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + + // We need the lazy ReplyPlaceholder and LoadingPost components to be loaded. + Promise.all([import('flarum/forum/components/ReplyPlaceholder'), import('flarum/forum/components/LoadingPost')]).then( + ([ReplyPlaceholder, LoadingPost]) => { + this.replyPlaceholderComponent(ReplyPlaceholder.default); + this.loadingPostComponent(LoadingPost.default); + } + ); + } + + oncreate(vnode: Mithril.VnodeDOM) { + super.oncreate(vnode); + + this.scrollListener = new ScrollListener(this.onscroll.bind(this), this.element); + + setTimeout(() => { + this.scrollListener.start(); + this.element.addEventListener('scrollend', this.markAsRead.bind(this)); + }); + } + + onupdate(vnode: Mithril.VnodeDOM) { + super.onupdate(vnode); + + // @todo: for future versions, consider using the post stream scrubber to scroll through the messages. (big task..) + // @todo: introduce read status, to jump to the first unread message instead. + if (!this.initialToBottomScroll && !this.attrs.state.isLoading()) { + this.scrollToBottom(); + this.initialToBottomScroll = true; + } + + if (this.initialToBottomScroll && !this.checkedRead) { + this.markAsRead(); + this.checkedRead = true; + } + } + + onremove(vnode: Mithril.VnodeDOM) { + super.onremove(vnode); + + this.scrollListener.stop(); + } + + view() { + return
    {this.attrs.state.isLoading() ? : this.content()}
    ; + } + + content() { + const items: Mithril.Children[] = []; + + const messages = this.attrs.state.getAllItems().sort((a, b) => a.createdAt().getTime() - b.createdAt().getTime()); + + const ReplyPlaceholder = this.replyPlaceholderComponent(); + const LoadingPost = this.loadingPostComponent(); + + if (messages[0].id() !== (this.attrs.dialog.data.relationships?.firstMessage.data as ModelIdentifier).id) { + items.push( +
    + +
    + ); + + if (LoadingPost) { + items.push( +
    + +
    + ); + } + } + + messages.forEach((message, index) => items.push(this.messageItem(message, index))); + + if (ReplyPlaceholder) { + items.push( +
    + { + import('flarum/forum/components/ComposerBody').then(() => { + app.composer + .load(() => import('./MessageComposer'), { + user: app.session.user, + replyingTo: this.attrs.dialog, + onsubmit: (message: DialogMessage) => { + this.attrs.state.push(message); + setTimeout(() => this.scrollToBottom(), 50); + }, + }) + .then(() => app.composer.show()); + }); + }} + composingReply={() => app.composer.composingMessageTo(this.attrs.dialog)} + /> +
    + ); + } + + return items; + } + + messageItem(message: DialogMessage, index: number) { + return ( +
    + {this.timeGap(message)} + +
    + ); + } + + timeGap(message: DialogMessage): Mithril.Children { + if (message.id() === (this.attrs.dialog.data.relationships?.firstMessage.data as ModelIdentifier).id) { + this.lastTime = message.createdAt()!; + + return ( +
    + {app.translator.trans('flarum-messages.forum.messages_page.stream.start_of_the_conversation')} +
    + ); + } + + const lastTime = this.lastTime; + const dt = message.createdAt().getTime() - (lastTime?.getTime() || 0); + this.lastTime = message.createdAt()!; + + if (lastTime && dt > 1000 * 60 * 60 * 24 * 4) { + return ( +
    + {/* @ts-ignore */} + + {app.translator.trans('flarum-messages.forum.messages_page.stream.time_lapsed_text', { period: dayjs().add(dt, 'ms').fromNow(true) })} + +
    + ); + } + + return null; + } + + onscroll() { + this.whileMaintainingScroll(() => { + if (this.element.scrollTop <= 80 && this.attrs.state.hasNext()) { + return this.attrs.state.loadNext(); + } + + if (this.element.scrollTop + this.element.clientHeight === this.element.scrollHeight && this.attrs.state.hasPrev()) { + return this.attrs.state.loadPrev(); + } + + return null; + }); + } + + scrollToBottom() { + this.element.scrollTop = this.element.scrollHeight; + } + + whileMaintainingScroll(callback: () => null | Promise) { + const scrollTop = this.element.scrollTop; + const scrollHeight = this.element.scrollHeight; + + const result = callback(); + + if (result instanceof Promise) { + result.then(() => { + requestAnimationFrame(() => { + this.element.scrollTop = this.element.scrollHeight - scrollHeight + scrollTop; + }); + }); + } + } + + markAsRead(): void { + const lastVisibleId = Number( + this.$('.MessageStream-item[data-id]') + .filter((_, $el) => { + if (this.element.scrollHeight <= this.element.clientHeight) { + return true; + } + + return this.$().offset()!.top + this.element.clientHeight > $($el).offset()!.top; + }) + .last() + .data('id') + ); + + if (lastVisibleId && app.session.user && lastVisibleId > (this.attrs.dialog.lastReadMessageId() || 0) && !this.markingAsRead) { + this.markingAsRead = true; + + this.attrs.dialog.save({ lastReadMessageId: lastVisibleId }).finally(() => { + this.markingAsRead = false; + + if (this.attrs.dialog.unreadCount() === 0) { + app.session.user!.pushAttributes({ + messageCount: (app.session.user!.attribute('messageCount') ?? 1) - 1, + }); + } + + m.redraw(); + }); + } + } +} diff --git a/extensions/messages/js/src/forum/components/MessagesPage.tsx b/extensions/messages/js/src/forum/components/MessagesPage.tsx new file mode 100644 index 000000000..e75c64994 --- /dev/null +++ b/extensions/messages/js/src/forum/components/MessagesPage.tsx @@ -0,0 +1,204 @@ +import app from 'flarum/forum/app'; +import Page, { IPageAttrs } from 'flarum/common/components/Page'; +import PageStructure from 'flarum/forum/components/PageStructure'; +import Mithril from 'mithril'; +import Icon from 'flarum/common/components/Icon'; +import DialogList from './DialogList'; +import Dialog from '../../common/models/Dialog'; +import LoadingIndicator from 'flarum/common/components/LoadingIndicator'; +import Stream from 'flarum/common/utils/Stream'; +import InfoTile from 'flarum/common/components/InfoTile'; +import MessagesSidebar from './MessagesSidebar'; +import DialogSection from './DialogSection'; +import listItems from 'flarum/common/helpers/listItems'; +import ItemList from 'flarum/common/utils/ItemList'; +import Dropdown from 'flarum/common/components/Dropdown'; +import Button from 'flarum/common/components/Button'; + +export interface IMessagesPageAttrs extends IPageAttrs {} + +export default class MessagesPage extends Page { + protected selectedDialog = Stream(null); + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + + if (!app.session.user) { + m.route.set(app.route('index')); + return; + } + + app.current.set('noTagsList', true); + + if (!app.dialogs.hasItems()) { + app.dialogs.refresh().then(async () => { + if (app.dialogs.hasItems()) { + await this.initDialog(); + } + }); + } else { + this.initDialog(); + } + } + + dialogRequestParams() { + return { + include: 'users.groups', + }; + } + + protected async initDialog() { + const dialogId = m.route.param('id'); + + const title = app.translator.trans('flarum-messages.forum.messages_page.title', {}, true); + + let dialog: Dialog | null; + + if (dialogId) { + dialog = + app.store.getById('dialogs', dialogId) || ((await app.store.find('dialogs', dialogId, this.dialogRequestParams())) as Dialog); + } else { + dialog = app.dialogs.getAllItems()[0]; + } + + this.selectedDialog(dialog); + + if (dialog) { + app.setTitle(dialog.title()); + app.history.push('dialog', dialog.title()); + } else { + app.setTitle(title); + app.history.push('messages', title); + } + + m.redraw(); + } + + onupdate(vnode: Mithril.VnodeDOM) { + super.onupdate(vnode); + + // Scroll the dialog list to the active dialog item if present and not visible. + const dialogElement = this.element.querySelector('.DialogListItem.active'); + const container = this.element.querySelector('.DialogList')!; + + if (dialogElement && $(container).offset()!.top + container.clientHeight <= $(dialogElement).offset()!.top) { + dialogElement.scrollIntoView(); + } + } + + view() { + return ( + }> + {app.dialogs.isLoading() ? ( + + ) : !app.dialogs.hasItems() ? ( + {app.translator.trans('flarum-messages.forum.messages_page.empty_text')} + ) : ( +
    +
    +
    +
      {listItems(this.viewItems().toArray())}
    +
      {listItems(this.actionItems().toArray())}
    +
    + +
    + {this.selectedDialog() ? ( + + ) : ( + + )} +
    + )} +
    + ); + } + + hero(): Mithril.Children { + return ( +
    +
    +
    +

    + {app.translator.trans('flarum-messages.forum.messages_page.hero.title')} +

    +
    {app.translator.trans('flarum-messages.forum.messages_page.hero.subtitle')}
    +
    +
    +
    + ); + } + + /** + * Build an item list for the part of the toolbar which is concerned with how + * the results are displayed. By default this is just a select box to change + * the way discussions are sorted. + */ + viewItems() { + const items = new ItemList(); + const sortMap = app.dialogs.sortMap(); + + const sortOptions = Object.keys(sortMap).reduce((acc: any, sortId) => { + const sort = sortMap[sortId]; + acc[sortId] = typeof sort !== 'string' ? sort.label : app.translator.trans(`flarum-messages.forum.index_sort.${sortId}_button`); + return acc; + }, {}); + + items.add( + 'sort', + + {Object.keys(sortOptions).map((value) => { + const label = sortOptions[value]; + const active = (app.dialogs.getParams().sort || Object.keys(sortMap)[0]) === value; + + return ( + + ); + })} + + ); + + return items; + } + + /** + * Build an item list for the part of the toolbar which is about taking action + * on the results. By default this is just a "mark all as read" button. + */ + actionItems() { + const items = new ItemList(); + + items.add( + 'refresh', + , + 10 + ); + + return items; + } + + /** + * Open the composer for a new message. + */ + newMessageAction(): Promise { + return import('flarum/forum/components/ComposerBody').then(() => { + app.composer + .load(() => import('./MessageComposer'), { + user: app.session.user, + onsubmit: () => { + app.dialogs.refresh(); + }, + }) + .then(() => app.composer.show()); + + return app.composer; + }); + } +} diff --git a/extensions/messages/js/src/forum/extend.ts b/extensions/messages/js/src/forum/extend.ts new file mode 100644 index 000000000..f5ac30b31 --- /dev/null +++ b/extensions/messages/js/src/forum/extend.ts @@ -0,0 +1,13 @@ +import app from 'flarum/forum/app'; +import Extend from 'flarum/common/extenders'; +import commonExtend from '../common/extend'; +import type Dialog from '../common/models/Dialog'; + +export default [ + ...commonExtend, + + new Extend.Routes() // + .add('messages', '/messages', () => import('./components/MessagesPage')) + .add('dialog', '/messages/dialog/:id', () => import('./components/MessagesPage')) + .helper('dialog', (dialog: Dialog) => app.route('dialog', { id: dialog.id() })), +]; diff --git a/extensions/messages/js/src/forum/index.tsx b/extensions/messages/js/src/forum/index.tsx new file mode 100644 index 000000000..3ea6c0228 --- /dev/null +++ b/extensions/messages/js/src/forum/index.tsx @@ -0,0 +1,87 @@ +import app from 'flarum/forum/app'; +import { extend } from 'flarum/common/extend'; +import IndexSidebar from 'flarum/forum/components/IndexSidebar'; +import LinkButton from 'flarum/common/components/LinkButton'; +import HeaderSecondary from 'flarum/forum/components/HeaderSecondary'; +import UserControls from 'flarum/forum/utils/UserControls'; +import Button from 'flarum/common/components/Button'; +import type Dialog from '../common/models/Dialog'; +import DialogsDropdown from './components/DialogsDropdown'; +import DialogListState from './states/DialogListState'; + +export { default as extend } from './extend'; + +app.initializers.add('flarum-messages', () => { + app.dialogs = new DialogListState({}, 1); + app.dropdownDialogs = new DialogListState( + { + filter: { + unread: true, + }, + }, + 1, + 5 + ); + + app.composer.composingMessageTo = function (dialog: Dialog) { + const MessageComposer = flarum.reg.checkModule('flarum-messages', 'forum/components/MessageComposer'); + + if (!MessageComposer) return false; + + return this.isVisible() && this.bodyMatches(MessageComposer, { dialog }); + }; + + extend(IndexSidebar.prototype, 'navItems', function (items) { + if (app.session.user) { + items.add( + 'messages', + + {app.translator.trans('flarum-messages.forum.index.messages_link')} + , + 95 + ); + } + }); + + extend(HeaderSecondary.prototype, 'items', function (items) { + if (app.session.user?.attribute('canSendAnyMessage')) { + items.add('messages', , 15); + } + }); + + // @ts-ignore + extend(UserControls, 'userControls', (items, user) => { + if (app.session.user?.attribute('canSendAnyMessage')) { + items.add( + 'sendMessage', + + ); + } + }); + + extend('flarum/forum/components/NotificationGrid', 'notificationTypes', function (items) { + items.add('messageReceived', { + name: 'messageReceived', + icon: 'fas fa-envelope', + label: app.translator.trans('flarum-messages.forum.settings.notify_message_received_label'), + }); + }); +}); diff --git a/extensions/messages/js/src/forum/states/DialogListState.ts b/extensions/messages/js/src/forum/states/DialogListState.ts new file mode 100644 index 000000000..34f2e1e75 --- /dev/null +++ b/extensions/messages/js/src/forum/states/DialogListState.ts @@ -0,0 +1,75 @@ +import app from 'flarum/forum/app'; +import PaginatedListState, { PaginatedListParams, type SortMap } from 'flarum/common/states/PaginatedListState'; +import Dialog from '../../common/models/Dialog'; +import { type PaginatedListRequestParams } from 'flarum/common/states/PaginatedListState'; + +export interface DialogListParams extends PaginatedListParams { + sort?: string; +} + +export default class DialogListState

    extends PaginatedListState { + protected lastCount: number = 0; + + constructor(params: P, page: number = 1, perPage: null | number = null) { + super(params, page, perPage); + } + + get type(): string { + return 'dialogs'; + } + + public getAllItems(): Dialog[] { + return super.getAllItems(); + } + + requestParams(): PaginatedListRequestParams { + const params = { + include: ['lastMessage', 'users.groups'], + filter: this.params.filter || {}, + sort: this.currentSort() || this.sortValue(Object.values(this.sortMap())[0]), + }; + + return params; + } + + sortMap(): SortMap { + const map: any = {}; + + map.latest = '-lastMessageAt'; + map.newest = '-createdAt'; + map.oldest = 'createdAt'; + + return map; + } + + load(): Promise { + if (app.session.user?.attribute('messageCount') !== this.lastCount) { + this.pages = []; + this.location = { page: 1 }; + + this.lastCount = app.session.user?.attribute('messageCount') || 0; + } + + if (this.pages.length > 0) { + return Promise.resolve(); + } + + return super.loadNext(); + } + + markAllAsRead() { + return app + .request({ + method: 'POST', + url: app.forum.attribute('apiUrl') + '/dialogs/read', + }) + .then(() => { + app.dialogs.getAllItems().forEach((dialog: Dialog) => { + dialog.pushAttributes({ unreadCount: 0 }); + }); + app.session.user!.pushAttributes({ messageCount: 0 }); + app.dropdownDialogs.clear(); + m.redraw(); + }); + } +} diff --git a/extensions/messages/js/src/forum/states/MessageStreamState.ts b/extensions/messages/js/src/forum/states/MessageStreamState.ts new file mode 100644 index 000000000..1c196073f --- /dev/null +++ b/extensions/messages/js/src/forum/states/MessageStreamState.ts @@ -0,0 +1,20 @@ +import PaginatedListState, { PaginatedListParams } from 'flarum/common/states/PaginatedListState'; +import DialogMessage from '../../common/models/DialogMessage'; + +export interface MessageStreamParams extends PaginatedListParams { + // +} + +export default class MessageStreamState

    extends PaginatedListState { + constructor(params: P, page: number = 1) { + super(params, page, null); + } + + get type(): string { + return 'dialog-messages'; + } + + public getAllItems(): DialogMessage[] { + return super.getAllItems(); + } +} diff --git a/extensions/messages/js/tests/integration/.gitkeep b/extensions/messages/js/tests/integration/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/extensions/messages/js/tests/unit/.gitkeep b/extensions/messages/js/tests/unit/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/extensions/messages/js/tsconfig.json b/extensions/messages/js/tsconfig.json new file mode 100644 index 000000000..0df7d654b --- /dev/null +++ b/extensions/messages/js/tsconfig.json @@ -0,0 +1,15 @@ +{ + // Use Flarum's tsconfig as a starting point + "extends": "flarum-tsconfig", + // This will match all .ts, .tsx, .d.ts, .js, .jsx files in your `src` folder + // and also tells your Typescript server to read core's global typings for + // access to `dayjs` and `$` in the global namespace. + "include": ["src/**/*", "../../../framework/core/js/dist-typings/@types/**/*", "@types/**/*"], + "compilerOptions": { + // This will output typings to `dist-typings` + "declarationDir": "./dist-typings", + "paths": { + "flarum/*": ["../../../framework/core/js/dist-typings/*"] + } + } +} diff --git a/extensions/messages/js/tsconfig.test.json b/extensions/messages/js/tsconfig.test.json new file mode 100644 index 000000000..be7e3082a --- /dev/null +++ b/extensions/messages/js/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "include": ["tests/**/*"], + "files": ["../../../node_modules/@flarum/jest-config/shims.d.ts"], + "compilerOptions": { + "strict": false, + "noImplicitReturns": false, + "noImplicitAny": false + } +} diff --git a/extensions/messages/js/webpack.config.cjs b/extensions/messages/js/webpack.config.cjs new file mode 100644 index 000000000..ef35ea006 --- /dev/null +++ b/extensions/messages/js/webpack.config.cjs @@ -0,0 +1 @@ +module.exports = require('flarum-webpack-config')(); diff --git a/extensions/messages/less/admin.less b/extensions/messages/less/admin.less new file mode 100644 index 000000000..e69de29bb diff --git a/extensions/messages/less/forum.less b/extensions/messages/less/forum.less new file mode 100644 index 000000000..7550eadab --- /dev/null +++ b/extensions/messages/less/forum.less @@ -0,0 +1,242 @@ +.MessagesPage-sidebar { + flex-shrink: 0; + width: 280px; +} + +.MessagesPage-content { + display: flex; + gap: 32px; + + .Avatar { + --size: 40px; + } +} + +.MessageComposer-recipients { + display: flex; + align-items: center; + gap: 12px; + color: var(--control-color); + + .Avatar { + --size: 24px; + } + + &-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + gap: 4px; + } +} + +.DialogList-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; + + .Avatar { + --size: 42px; + } + + .DialogDropdownList & { + gap: 0 + } +} + +.DialogListItem { + .CompactActions(); + + &-button { + display: flex; + align-items: center; + gap: 12px; + color: var(--text-color); + text-decoration: none; + padding: 10px 12px; + border-radius: var(--border-radius); + + &:hover, &.active { + text-decoration: none; + background-color: var(--control-bg); + + .DialogListItem-lastMessage { + color: var(--control-color); + } + } + + .DialogDropdownList & { + border-radius: 0; + } + } + + &-title { + font-weight: 500; + font-size: 14px; + display: flex; + justify-content: space-between; + white-space: nowrap; + align-items: center; + + .username { + max-width: 56%; + overflow: hidden; + text-overflow: ellipsis; + } + } + + &-content { + overflow: hidden; + flex-grow: 1; + } + + &-avatar { + flex-shrink: 0; + position: relative; + + .Bubble { + top: -4px; + right: -4px; + left: unset; + height: 18px; + min-width: 18px; + } + } + + &-lastMessage { + color: var(--muted-more-color); + font-size: 0.8rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &--unread &-button:not(:hover) { + background-color: var(--control-body-bg-mix); + } + + &--unread &-title { + font-weight: bold; + } + + &--unread &-lastMessage { + color: var(--control-color); + } + + time { + font-size: 11px; + line-height: 19px; + font-weight: bold; + text-transform: uppercase; + color: var(--control-color); + } + + .DialogDropdownList &-title { + justify-content: flex-start; + gap: 10px; + } + + .DialogDropdownList &-actions { + margin-inline-start: auto; + } +} + +.DialogSection { + flex-grow: 1; + padding-inline-start: 32px; + + &-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 16px; + padding-bottom: 16px; + border-bottom: 1px solid var(--control-bg); + + a { + color: var(--text-color); + } + + &-actions { + margin-inline-start: auto; + } + + &-info { + display: flex; + align-items: center; + gap: 12px; + } + } +} + +.Message { + padding-right: 0; + + .PostUser-badges { + margin-left: -72px; + } +} + +.MessageStream { + --avatar-column-width: 55px; + padding-inline-end: 0.5rem; + margin-inline-start: -32px; + padding-inline-start: 32px; + padding-bottom: 1px; + mask-image: linear-gradient(180deg, transparent 0%, var(--body-bg) 3%, var(--body-bg) 97%, transparent 100%); +} + +.MessageStream, .DialogList { + max-height: calc(100vh - var(--header-height) - 140px - 235px); + overflow: auto; +} + +.DialogList-loadMore { + text-align: center; + margin-top: 10px; +} + +.DetailsModal-infoGroups { + display: flex; + flex-direction: column; + gap: 16px; +} + +.DetailsModal-info { + &-title { + text-transform: uppercase; + font-weight: bold; + color: var(--muted-more-color); + margin-bottom: 8px; + } + + &-content { + color: var(--control-color); + } +} + +.DetailsModal-recipient { + display: flex; + align-items: center; + gap: 8px; + + .Avatar { + --size: 38px; + } + + a { + color: var(--text-color); + font-size: 14px; + font-weight: bold; + } +} + +.DetailsModal-recipients-list { + display: flex; + flex-direction: column; + gap: 8px; +} diff --git a/extensions/messages/locale/en.yml b/extensions/messages/locale/en.yml new file mode 100644 index 000000000..95ae3cfa6 --- /dev/null +++ b/extensions/messages/locale/en.yml @@ -0,0 +1,89 @@ +flarum-messages: + + # Translations in this namespace are used by the admin interface. + admin: + permissions: + send_messages: Send private messages + + # Translations in this namespace are used by the forum user interface. + forum: + composer: + discard_confirmation: You have not sent your message. Are you sure you want to discard it? + placeholder: Write a message... + recipients: Recipients + submit_button: Send message + to: "To:" + + dialog_list: + load_more_button: => core.ref.load_more + mark_as_read_tooltip: Mark as read + title: Messages + view_all: View all messages + + dialog_section: + controls: + details_button: Details + controls_toggle_label: Dialog control actions + details_modal: + created_at: Creation date + recipients: Participants + title: Conversation details + + index: + messages_link: Messages + + index_sort: + latest_button: Latest + newest_button: Newest + oldest_button: Oldest + + messages_page: + empty_text: You have no messages yet. When you send or receive messages, they + will appear here. + hero: + title: Messages + subtitle: Your private conversations with other users + mark_all_as_read_tooltip: Mark all as read + new_message_button: Send a Message + refresh_tooltip: Refresh + stream: + load_previous_button: Load previous messages + start_of_the_conversation: Start of the conversation + time_lapsed_text: => core.forum.post_stream.time_lapsed_text + title: Messages + + recipient_selection_modal: + title: Select the recipients of this message + + settings: + notify_message_received_label: Someone sends me a message + + user_controls: + send_message_button: Send a message + + notifications: + message_received_text: Message Received notification from {user} + + # Translations in this namespace are used in emails sent by the forum. + email: + + # These translations are used in emails sent when a user is mentioned + message_received: + subject: "{user_display_name} sent you a message" + plain: + body: | + {user_display_name} sent you a new message. + + {url} + + --- + + {content} + html: + body: "{user_display_name} sent you a [new message]({url})." + + # Translations in this namespace are used by the forum and admin interfaces. + lib: + + dialog: + title: Messaging {username} diff --git a/extensions/messages/migrations/2024_09_01_000000_create_dialogs_table.php b/extensions/messages/migrations/2024_09_01_000000_create_dialogs_table.php new file mode 100644 index 000000000..1e99209ff --- /dev/null +++ b/extensions/messages/migrations/2024_09_01_000000_create_dialogs_table.php @@ -0,0 +1,25 @@ +bigIncrements('id'); + $table->unsignedBigInteger('first_message_id')->nullable(); + $table->unsignedBigInteger('last_message_id')->nullable(); + $table->dateTime('last_message_at')->nullable(); + $table->unsignedInteger('last_message_user_id')->nullable(); + $table->foreign('last_message_user_id')->references('id')->on('users')->nullOnDelete(); + $table->string('type'); + $table->timestamps(); + } +); diff --git a/extensions/messages/migrations/2024_09_01_000001_create_dialog_messages_table.php b/extensions/messages/migrations/2024_09_01_000001_create_dialog_messages_table.php new file mode 100644 index 000000000..20657e5ad --- /dev/null +++ b/extensions/messages/migrations/2024_09_01_000001_create_dialog_messages_table.php @@ -0,0 +1,23 @@ +bigIncrements('id'); + $table->foreignId('dialog_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('user_id')->nullable(); + $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); + $table->text('content'); + $table->timestamps(); + } +); diff --git a/extensions/messages/migrations/2024_09_01_000002_create_dialog_user_table.php b/extensions/messages/migrations/2024_09_01_000002_create_dialog_user_table.php new file mode 100644 index 000000000..6ffe56e5a --- /dev/null +++ b/extensions/messages/migrations/2024_09_01_000002_create_dialog_user_table.php @@ -0,0 +1,24 @@ +id(); + $table->foreignId('dialog_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('user_id'); + $table->dateTime('joined_at'); + $table->unsignedBigInteger('last_read_message_id')->default(0); + $table->dateTime('last_read_at')->nullable(); + $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); + } +); diff --git a/extensions/messages/migrations/2024_09_01_000003_add_dialog_message_foreign_keys_to_dialogs.php b/extensions/messages/migrations/2024_09_01_000003_add_dialog_message_foreign_keys_to_dialogs.php new file mode 100644 index 000000000..e8a7fa04e --- /dev/null +++ b/extensions/messages/migrations/2024_09_01_000003_add_dialog_message_foreign_keys_to_dialogs.php @@ -0,0 +1,26 @@ + function (Builder $schema) { + $schema->table('dialogs', function (Blueprint $table) { + $table->foreign('first_message_id')->references('id')->on('dialog_messages')->nullOnDelete(); + $table->foreign('last_message_id')->references('id')->on('dialog_messages')->nullOnDelete(); + }); + }, + 'down' => function (Builder $schema) { + $schema->table('dialogs', function (Blueprint $table) { + $table->dropForeign(['first_message_id']); + $table->dropForeign(['last_message_id']); + }); + } +]; diff --git a/extensions/messages/migrations/2024_09_03_000000_seed_permissions.php b/extensions/messages/migrations/2024_09_03_000000_seed_permissions.php new file mode 100644 index 000000000..e18479a4e --- /dev/null +++ b/extensions/messages/migrations/2024_09_03_000000_seed_permissions.php @@ -0,0 +1,15 @@ + Group::MEMBER_ID, +]); diff --git a/extensions/messages/src/Access/DialogMessagePolicy.php b/extensions/messages/src/Access/DialogMessagePolicy.php new file mode 100644 index 000000000..f49c8b0a5 --- /dev/null +++ b/extensions/messages/src/Access/DialogMessagePolicy.php @@ -0,0 +1,22 @@ +where('id', $dialog->id)->exists(); + } + + public function sendMessage(User $actor, Dialog $dialog): bool + { + return $this->view($actor, $dialog) && $actor->hasPermission('dialog.sendMessage'); + } +} diff --git a/extensions/messages/src/Access/GlobalPolicy.php b/extensions/messages/src/Access/GlobalPolicy.php new file mode 100644 index 000000000..a4fd4208f --- /dev/null +++ b/extensions/messages/src/Access/GlobalPolicy.php @@ -0,0 +1,21 @@ +hasPermission('dialog.sendMessage'); + } +} diff --git a/extensions/messages/src/Access/ScopeDialogMessageVisibility.php b/extensions/messages/src/Access/ScopeDialogMessageVisibility.php new file mode 100644 index 000000000..e0815ff8a --- /dev/null +++ b/extensions/messages/src/Access/ScopeDialogMessageVisibility.php @@ -0,0 +1,23 @@ +whereHas('dialog', function (Builder $query) use ($actor) { + $query->whereVisibleTo($actor); + }); + } +} diff --git a/extensions/messages/src/Access/ScopeDialogVisibility.php b/extensions/messages/src/Access/ScopeDialogVisibility.php new file mode 100644 index 000000000..825c20d13 --- /dev/null +++ b/extensions/messages/src/Access/ScopeDialogVisibility.php @@ -0,0 +1,23 @@ +whereHas('users', function (Builder $query) use ($actor) { + $query->where('user_id', $actor->id); + }); + } +} diff --git a/extensions/messages/src/Api/Resource/DialogMessageResource.php b/extensions/messages/src/Api/Resource/DialogMessageResource.php new file mode 100644 index 000000000..a54cf1298 --- /dev/null +++ b/extensions/messages/src/Api/Resource/DialogMessageResource.php @@ -0,0 +1,226 @@ + + */ +class DialogMessageResource extends Resource\AbstractDatabaseResource +{ + public function __construct( + protected Translator $translator, + protected LogReporter $log, + protected Dispatcher $bus, + ) { + } + + public function type(): string + { + return 'dialog-messages'; + } + + public function model(): string + { + return DialogMessage::class; + } + + public function scope(Builder $query, OriginalContext $context): void + { + $query->whereVisibleTo($context->getActor()); + } + + public function endpoints(): array + { + return [ + Endpoint\Create::make() + ->authenticated() + ->visible(function (Context $context): bool { + $actor = $context->getActor(); + + $dialogId = (int) Arr::get($context->body(), 'data.relationships.dialog.data.id'); + + // If this is a new dialog instance, the user must have permission to + // start new dialogs. Otherwise, they must have access to send messages in + // this dialog. + if ($dialogId) { + $dialog = Dialog::whereVisibleTo($context->getActor())->findOrFail($dialogId); + + return $actor->can('sendMessage', $dialog); + } else { + return $actor->can('sendAnyMessage'); + } + }), + Endpoint\Index::make() + ->authenticated() + ->paginate(), + ]; + } + + public function fields(): array + { + return [ + + Schema\Str::make('content') + ->requiredOnCreate() + ->writableOnCreate() + ->hidden() + ->minLength(1) + ->maxLength(63000) + ->set(function (DialogMessage $post, string $value, Context $context) { + $post->setContentAttribute($value, $context->getActor()); + }), + Schema\Str::make('contentHtml') + ->get(function (DialogMessage $post, Context $context) { + try { + $rendered = $post->formatContent($context->request); + $post->setAttribute('renderFailed', false); + } catch (Exception $e) { + $rendered = $this->translator->trans('core.lib.error.render_failed_message'); + $this->log->report($e); + $post->setAttribute('renderFailed', true); + } + + return $rendered; + }), + Schema\Boolean::make('renderFailed'), + Schema\DateTime::make('createdAt'), + + // Write-only. + Schema\Arr::make('users') + ->requiredOnCreateWithout(['relationships.dialog']) + ->writableOnCreate() + ->hidden() + ->items(1) + ->set(fn () => null), + + Schema\Relationship\ToOne::make('user') + ->type('users') + ->includable(), + Schema\Relationship\ToOne::make('dialog') + ->type('dialogs') + ->includable() + ->writableOnCreate() + ->requiredOnCreateWithout(['attributes.users']), + + ]; + } + + public function sorts(): array + { + return [ + SortColumn::make('createdAt'), + ]; + } + + /** + * @inheritDoc + */ + public function creating(object $model, OriginalContext $context): ?object + { + $model->user_id = $context->getActor()->id; + $data = $context->body()['data'] ?? []; + + $this->events->dispatch( + new DialogMessage\Event\Creating($model, $data) + ); + + if (! $model->dialog_id) { + $context->getActor()->assertCan('sendAnyMessage'); + + $users = array_filter(Arr::pluck($data['attributes']['users'] ?? [], 'id'), fn (mixed $id) => $id && $id != $model->user_id); + + if (empty($users)) { + throw new ValidationException([ + 'users' => str_replace(':attribute', 'users', $this->translator->trans('validation.required')), + ]); + } + + $dialog = Dialog::for($model, $users); + + $model->dialog()->associate($dialog); + + $users[] = $model->user_id; + + $dialog->users()->syncWithPivotValues(array_unique($users), [ + 'joined_at' => Carbon::now(), + ]); + } + + return parent::creating($model, $context); + } + + /** + * @inheritDoc + */ + public function created(object $model, OriginalContext $context): ?object + { + if ($model->dialog->last_message_id !== $model->id) { + $model->dialog->setLastMessage($model); + } + + if (! $model->dialog->first_message_id) { + $model->dialog->setFirstMessage($model); + } + + $model->dialog->isDirty() && $model->dialog->save(); + + $this->bus->dispatch( + new ReadDialog($model->dialog_id, $context->getActor(), $model->id) + ); + + $this->events->dispatch( + new DialogMessage\Event\Created($model) + ); + + return parent::created($model, $context); + } + + /** + * @inheritDoc + */ + public function updating(object $model, OriginalContext $context): ?object + { + $this->events->dispatch( + new DialogMessage\Event\Updating($model, $context->body()['data'] ?? []) + ); + + return parent::updating($model, $context); + } + + /** + * @inheritDoc + */ + public function updated(object $model, OriginalContext $context): ?object + { + $this->events->dispatch( + new DialogMessage\Event\Updated($model) + ); + + return parent::updated($model, $context); + } +} diff --git a/extensions/messages/src/Api/Resource/DialogResource.php b/extensions/messages/src/Api/Resource/DialogResource.php new file mode 100644 index 000000000..f6f5a6ed9 --- /dev/null +++ b/extensions/messages/src/Api/Resource/DialogResource.php @@ -0,0 +1,168 @@ + + */ +class DialogResource extends Resource\AbstractDatabaseResource +{ + public function __construct( + protected Translator $translator, + protected Dispatcher $bus, + ) { + } + + public function type(): string + { + return 'dialogs'; + } + + public function model(): string + { + return Dialog::class; + } + + public function scope(Builder $query, OriginalContext $context): void + { + $query->whereVisibleTo($context->getActor()); + } + + public function endpoints(): array + { + return [ + Endpoint\Show::make() + ->authenticated() + ->eagerLoad('state'), + Endpoint\Update::make() + ->authenticated() + ->eagerLoad('state'), + Endpoint\Endpoint::make('read') + ->route('POST', '/read') + ->authenticated() + ->action(function (Context $context) { + $connection = UserDialogState::query()->getConnection(); + $grammar = UserDialogState::query()->getGrammar(); + + $table = $grammar->wrapTable('dialogs'); + $column = $grammar->wrap('last_message_id'); + + UserDialogState::query() + ->where('dialog_user.user_id', $context->getActor()->id) + ->update([ + 'last_read_message_id' => $connection->raw('('.$grammar->compileSelect( + Dialog::query() + ->select('last_message_id') + ->from('dialogs') + ->whereColumn('dialogs.id', 'dialog_user.dialog_id') + ->toBase() + ).')'), + 'last_read_at' => Carbon::now(), + ]); + }) + ->response(fn () => new EmptyResponse(204)), + Endpoint\Index::make() + ->authenticated() + ->paginate() + ->eagerLoad(['users', 'state']), + ]; + } + + public function fields(): array + { + return [ + + Schema\Str::make('title') + ->get(function (Dialog $dialog, Context $context) { + return $this->translator->trans('flarum-messages.lib.dialog.title', [ + '{username}' => $dialog->recipient($context->getActor())->display_name, + ]); + }), + Schema\Str::make('type') + ->minLength(3) + ->maxLength(255) + ->in(Dialog::$types), + Schema\DateTime::make('lastMessageAt'), + Schema\DateTime::make('createdAt'), + + Schema\Integer::make('unreadCount') + ->countRelation('messages', function (Builder $query, Context $context) { + $query->leftJoin('dialog_user', 'dialog_messages.dialog_id', '=', 'dialog_user.dialog_id') + ->where('dialog_user.user_id', $context->getActor()->id) + ->whereColumn('dialog_messages.id', '>', 'dialog_user.last_read_message_id') + ->groupBy('dialog_messages.dialog_id'); + }), + Schema\DateTime::make('lastReadAt') + ->visible(fn (Dialog $dialog) => $dialog->state !== null) + ->get(function (Dialog $dialog) { + return $dialog->state->last_read_at; + }), + Schema\Integer::make('lastReadMessageId') + ->visible(fn (Dialog $dialog) => $dialog->state !== null) + ->get(function (Dialog $dialog) { + return $dialog->state?->last_read_message_id; + }) + ->writableOnUpdate() + ->set(function (Dialog $dialog, int $value, Context $context) { + if ($readNumber = Arr::get($context->body(), 'data.attributes.lastReadMessageId')) { + $dialog->afterSave(function (Dialog $dialog) use ($readNumber, $context) { + $this->bus->dispatch( + new ReadDialog($dialog->id, $context->getActor(), $readNumber) + ); + }); + } + }), + + Schema\Relationship\ToMany::make('messages') + ->type('dialog-messages'), + Schema\Relationship\ToMany::make('users') + ->type('users') + ->scope(fn (BelongsToMany $query) => $query->limit(5)) + ->includable(), + Schema\Relationship\ToOne::make('firstMessage') + ->type('dialog-messages') + ->includable(), + Schema\Relationship\ToOne::make('lastMessage') + ->type('dialog-messages') + ->includable(), + Schema\Relationship\ToOne::make('lastMessageUser') + ->type('users') + ->includable(), + ]; + } + + public function sorts(): array + { + return [ + SortColumn::make('createdAt') + ->ascendingAlias('oldest') + ->descendingAlias('newest'), + SortColumn::make('lastMessageAt') + ->descendingAlias('latest'), + ]; + } +} diff --git a/extensions/messages/src/Command/ReadDialog.php b/extensions/messages/src/Command/ReadDialog.php new file mode 100644 index 000000000..0265800e0 --- /dev/null +++ b/extensions/messages/src/Command/ReadDialog.php @@ -0,0 +1,22 @@ +actor; + + $actor->assertRegistered(); + + /** @var Dialog $dialog */ + $dialog = Dialog::whereVisibleTo($actor)->findOrFail($command->dialogId); + + /** @var UserDialogState $state */ + $state = $dialog->state($actor)->first(); + $state->read($command->lastReadMessageId); + + $this->events->dispatch( + new UserDataSaving($state) + ); + + $state->save(); + + $this->dispatchEventsFor($state); + + return $state; + } +} diff --git a/extensions/messages/src/Dialog.php b/extensions/messages/src/Dialog.php new file mode 100644 index 000000000..c8b95c82a --- /dev/null +++ b/extensions/messages/src/Dialog.php @@ -0,0 +1,127 @@ + $messages + * @property-read \Illuminate\Database\Eloquent\Collection $users + * @property-read DialogMessage|null $firstMessage + * @property-read DialogMessage|null $lastMessage + * @property-read User|null $lastMessageUser + * @property-read UserDialogState|null $state + */ +class Dialog extends AbstractModel +{ + use EventGeneratorTrait; + use ScopeVisibilityTrait; + + protected $table = 'dialogs'; + + public $timestamps = true; + + public static array $types = ['direct']; + + protected $guarded = []; + + protected static ?User $stateUser = null; + + public function messages(): HasMany + { + return $this->hasMany(DialogMessage::class); + } + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'dialog_user'); + } + + public function firstMessage(): BelongsTo + { + return $this->belongsTo(DialogMessage::class, 'first_message_id'); + } + + public function lastMessage(): BelongsTo + { + return $this->belongsTo(DialogMessage::class, 'last_message_id'); + } + + public function lastMessageUser(): BelongsTo + { + return $this->belongsTo(User::class, 'last_message_user_id'); + } + + public function state(?User $user = null): HasOne + { + $user = $user ?: static::$stateUser; + + return $this->hasOne(UserDialogState::class)->where('user_id', $user?->id); + } + + public function setFirstMessage(DialogMessage $message): static + { + $this->created_at = $message->created_at; + $this->first_message_id = $message->id; + + return $this; + } + + public function setLastMessage(DialogMessage $message): static + { + $this->last_message_at = $message->created_at; + $this->last_message_user_id = $message->user_id; + $this->last_message_id = $message->id; + + return $this; + } + + public static function for(DialogMessage $model, array $users): self + { + $otherUserId = array_values(array_diff($users, [$model->user_id]))[0] ?? null; + + if (! $otherUserId) { + throw new InvalidArgumentException('Dialog must have at least two users'); + } + + return self::query() + ->whereRelation('users', 'user_id', $model->user_id) + ->whereRelation('users', 'user_id', $otherUserId) + ->firstOrCreate([ + 'type' => 'direct', + ]); + } + + public function recipient(?User $actor): ?User + { + return $this->users->first(fn (User $user) => $user->id !== $actor?->id); + } + + public static function setStateUser(User $user): void + { + static::$stateUser = $user; + } +} diff --git a/extensions/messages/src/Dialog/Event/UserDataSaving.php b/extensions/messages/src/Dialog/Event/UserDataSaving.php new file mode 100644 index 000000000..7278170f5 --- /dev/null +++ b/extensions/messages/src/Dialog/Event/UserDataSaving.php @@ -0,0 +1,20 @@ + + */ +class UnreadFilter implements FilterInterface +{ + public function getFilterKey(): string + { + return 'unread'; + } + + public function filter(SearchState $state, string|array $value, bool $negate): void + { + $state->getQuery()->whereHas('users', function (Builder $query) use ($state) { + $query + ->where('dialog_user.user_id', $state->getActor()->id) + ->whereColumn('dialog_user.last_read_message_id', '<', 'dialogs.last_message_id'); + }); + } +} diff --git a/extensions/messages/src/DialogMessage.php b/extensions/messages/src/DialogMessage.php new file mode 100644 index 000000000..94277772a --- /dev/null +++ b/extensions/messages/src/DialogMessage.php @@ -0,0 +1,51 @@ +belongsTo(Dialog::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/extensions/messages/src/DialogMessage/Event/Created.php b/extensions/messages/src/DialogMessage/Event/Created.php new file mode 100644 index 000000000..9548a0f91 --- /dev/null +++ b/extensions/messages/src/DialogMessage/Event/Created.php @@ -0,0 +1,20 @@ + + */ +class DialogFilter implements FilterInterface +{ + public function getFilterKey(): string + { + return 'dialog'; + } + + public function filter(SearchState $state, string|array $value, bool $negate): void + { + $state->getQuery()->where('dialog_id', $value, $negate ? '!=' : '='); + } +} diff --git a/extensions/messages/src/DialogServiceProvider.php b/extensions/messages/src/DialogServiceProvider.php new file mode 100644 index 000000000..02c4a3298 --- /dev/null +++ b/extensions/messages/src/DialogServiceProvider.php @@ -0,0 +1,26 @@ +handle($request); + } +} diff --git a/extensions/messages/src/Job/SendMessageNotificationsJob.php b/extensions/messages/src/Job/SendMessageNotificationsJob.php new file mode 100644 index 000000000..bff3abf36 --- /dev/null +++ b/extensions/messages/src/Job/SendMessageNotificationsJob.php @@ -0,0 +1,40 @@ +whereIn('id', function (Builder $query) { + $query->select('dialog_user.user_id') + ->from('dialog_user') + ->where('dialog_user.dialog_id', $this->message->dialog_id); + }) + ->where('id', '!=', $this->message->user_id) + ->get() + ->all(); + + $notifications->sync(new MessageReceivedBlueprint($this->message), $users); + } +} diff --git a/extensions/messages/src/Listener/SendNotificationWhenMessageSent.php b/extensions/messages/src/Listener/SendNotificationWhenMessageSent.php new file mode 100644 index 000000000..01082f37a --- /dev/null +++ b/extensions/messages/src/Listener/SendNotificationWhenMessageSent.php @@ -0,0 +1,27 @@ +queue->push(new Job\SendMessageNotificationsJob($event->message)); + } +} diff --git a/extensions/messages/src/Notification/MessageReceivedBlueprint.php b/extensions/messages/src/Notification/MessageReceivedBlueprint.php new file mode 100644 index 000000000..23b4052c6 --- /dev/null +++ b/extensions/messages/src/Notification/MessageReceivedBlueprint.php @@ -0,0 +1,65 @@ +message->user; + } + + public function getSubject(): ?AbstractModel + { + return $this->message; + } + + public function getData(): array + { + return []; + } + + public function getEmailViews(): array + { + return [ + 'text' => 'flarum-messages::emails.plain.messageReceived', + 'html' => 'flarum-messages::emails.html.messageReceived' + ]; + } + + public function getEmailSubject(TranslatorInterface $translator): string + { + return $translator->trans('flarum-messages.email.message_received.subject', [ + '{user_display_name}' => $this->message->user->display_name, + ]); + } + + public static function getType(): string + { + return 'messageReceived'; + } + + public static function getSubjectModel(): string + { + return DialogMessage::class; + } +} diff --git a/extensions/messages/src/Search/DialogMessageSearcher.php b/extensions/messages/src/Search/DialogMessageSearcher.php new file mode 100644 index 000000000..c738a82e4 --- /dev/null +++ b/extensions/messages/src/Search/DialogMessageSearcher.php @@ -0,0 +1,23 @@ + 'integer', + 'dialog_id' => 'integer', + 'joined_at' => 'datetime', + 'last_read_message_id' => 'integer' + ]; + + public function dialog(): BelongsTo + { + return $this->belongsTo(Dialog::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function read(int $messageId): static + { + if ($messageId > $this->last_read_message_id) { + $this->last_read_message_id = $messageId; + $this->last_read_at = Carbon::now(); + + $this->raise(new UserRead($this)); + } + + return $this; + } +} diff --git a/extensions/messages/tests/fixtures/.gitkeep b/extensions/messages/tests/fixtures/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/extensions/messages/tests/integration/api/ListTest.php b/extensions/messages/tests/integration/api/ListTest.php new file mode 100644 index 000000000..2265f1474 --- /dev/null +++ b/extensions/messages/tests/integration/api/ListTest.php @@ -0,0 +1,128 @@ +extension('flarum-messages'); + + $this->prepareDatabase([ + User::class => [ + ['id' => 3, 'username' => 'astarion'], + ['id' => 4, 'username' => 'gale'], + ['id' => 5, 'username' => 'karlach'], + ], + Dialog::class => [ + ['id' => 102, 'type' => 'direct'], + ['id' => 103, 'type' => 'direct'], + ['id' => 104, 'type' => 'direct'], + ], + DialogMessage::class => [ + ['id' => 102, 'dialog_id' => 102, 'user_id' => 3, 'content' => 'Hello, Gale!'], + ['id' => 103, 'dialog_id' => 102, 'user_id' => 4, 'content' => 'Hello, Astarion!'], + ['id' => 104, 'dialog_id' => 103, 'user_id' => 3, 'content' => 'Hello, Karlach!'], + ['id' => 105, 'dialog_id' => 103, 'user_id' => 5, 'content' => 'Hello, Astarion!'], + ['id' => 106, 'dialog_id' => 104, 'user_id' => 4, 'content' => 'Hello, Karlach!'], + ['id' => 107, 'dialog_id' => 104, 'user_id' => 5, 'content' => 'Hello, Gale!'], + ], + 'dialog_user' => [ + ['dialog_id' => 102, 'user_id' => 3, 'joined_at' => Carbon::now()], + ['dialog_id' => 102, 'user_id' => 4, 'joined_at' => Carbon::now()], + ['dialog_id' => 103, 'user_id' => 3, 'joined_at' => Carbon::now()], + ['dialog_id' => 103, 'user_id' => 5, 'joined_at' => Carbon::now()], + ['dialog_id' => 104, 'user_id' => 4, 'joined_at' => Carbon::now()], + ['dialog_id' => 104, 'user_id' => 5, 'joined_at' => Carbon::now()], + ], + ]); + } + + #[DataProvider('dialogsAccessProvider')] + public function test_can_list_accessible_dialogs(int $actorId, array $visibleDialogs): void + { + $response = $this->send( + $this->request('GET', '/api/dialogs', [ + 'authenticatedAs' => $actorId, + ])->withQueryParams(['include' => 'users']) + ); + + $json = $response->getBody()->getContents(); + $prettyJson = json_encode($json, JSON_PRETTY_PRINT); + + $this->assertEquals(200, $response->getStatusCode(), $prettyJson); + $this->assertJson($json); + + $data = json_decode($json, true)['data']; + + $this->assertCount(count($visibleDialogs), $data); + + foreach ($visibleDialogs as $dialogId) { + $ids = array_column($data, 'id'); + $this->assertContains((string) $dialogId, $ids, json_encode($ids, JSON_PRETTY_PRINT)); + } + } + + public static function dialogsAccessProvider(): array + { + return [ + 'Astarion can see dialogs with Gale and Karlach' => [3, [102, 103]], + 'Gale can see dialogs with Astarion and Karlach' => [4, [102, 104]], + 'Karlach can see dialogs with Astarion and Gale' => [5, [103, 104]], + ]; + } + + #[DataProvider('dialogMessagesAccessProvider')] + public function test_can_list_accessible_dialog_messages(int $actorId, array $visibleDialogMessages): void + { + $response = $this->send( + $this->request('GET', '/api/dialog-messages', [ + 'authenticatedAs' => $actorId, + ])->withQueryParams(['include' => 'dialog']), + ); + + $json = $response->getBody()->getContents(); + $prettyJson = json_encode($json, JSON_PRETTY_PRINT); + + $this->assertEquals(200, $response->getStatusCode(), $prettyJson); + $this->assertJson($json); + + $data = json_decode($json, true)['data']; + $prettyJson = json_encode(json_decode($json), JSON_PRETTY_PRINT); + + $this->assertCount(count($visibleDialogMessages), $data, $prettyJson); + + foreach ($visibleDialogMessages as $dialogMessageId) { + $ids = array_column($data, 'id'); + $this->assertContains((string) $dialogMessageId, $ids, json_encode($ids, JSON_PRETTY_PRINT)); + } + } + + public static function dialogMessagesAccessProvider(): array + { + return [ + 'Astarion can see messages in dialogs with Gale and Karlach' => [3, [102, 103, 104, 105]], + 'Gale can see messages in dialogs with Astarion and Karlach' => [4, [102, 103, 106, 107]], + 'Karlach can see messages in dialogs with Astarion and Gale' => [5, [104, 105, 106, 107]], + ]; + } +} diff --git a/extensions/messages/tests/integration/api/dialog_messages/CreateTest.php b/extensions/messages/tests/integration/api/dialog_messages/CreateTest.php new file mode 100644 index 000000000..902158d89 --- /dev/null +++ b/extensions/messages/tests/integration/api/dialog_messages/CreateTest.php @@ -0,0 +1,112 @@ +extension('flarum-messages'); + + $this->prepareDatabase([ + User::class => [ + ['id' => 3, 'username' => 'alice'], + ['id' => 4, 'username' => 'bob'], + ['id' => 5, 'username' => 'karlach'], + ], + Dialog::class => [ + ['id' => 102, 'type' => 'direct'], + ], + DialogMessage::class => [ + ['id' => 102, 'dialog_id' => 102, 'user_id' => 4, 'content' => 'Hello, Karlach!'], + ], + 'dialog_user' => [ + ['dialog_id' => 102, 'user_id' => 4, 'joined_at' => Carbon::now()], + ['dialog_id' => 102, 'user_id' => 5, 'joined_at' => Carbon::now()], + ], + ]); + } + + public function test_can_create_a_direct_private_conversation_with_someone(): void + { + $response = $this->send( + $this->request('POST', '/api/dialog-messages', [ + 'authenticatedAs' => 3, + 'json' => [ + 'data' => [ + 'type' => 'dialog-messages', + 'attributes' => [ + 'content' => 'Hello, Bob!', + 'users' => [ + ['id' => 4], + ], + ], + ], + ], + ])->withQueryParams(['include' => 'dialog.users,user']) + ); + + $json = $response->getBody()->getContents(); + $data = json_decode($json, true); + $pretty = json_encode($data, JSON_PRETTY_PRINT); + + $this->assertEquals(201, $response->getStatusCode(), $pretty); + $this->assertNotEquals(102, $data['data']['relationships']['dialog']['data']['id'], $pretty); + $this->assertEquals('direct', collect($data['included'])->firstWhere('type', 'dialogs')['attributes']['type'], $pretty); + $this->assertEquals('Hello, Bob!', $data['data']['attributes']['contentHtml'], $pretty); + $this->assertEqualsCanonicalizing([3, 4], collect(collect($data['included'])->firstWhere('type', 'dialogs')['relationships']['users']['data'])->pluck('id')->all(), $pretty); + } + + public function test_can_create_a_private_message_when_conversation_already_exists(): void + { + $response = $this->send( + $this->request('POST', '/api/dialog-messages', [ + 'authenticatedAs' => 5, + 'json' => [ + 'data' => [ + 'type' => 'dialog-messages', + 'attributes' => [ + 'content' => 'Hello, Bob!', + ], + 'relationships' => [ + 'dialog' => [ + 'data' => [ + 'type' => 'dialogs', + 'id' => '102', + ], + ], + ], + ], + ], + ])->withQueryParams(['include' => 'dialog.users,user']) + ); + + $json = $response->getBody()->getContents(); + $data = json_decode($json, true); + $pretty = json_encode($data, JSON_PRETTY_PRINT); + + $this->assertEquals(201, $response->getStatusCode(), $pretty); + $this->assertEquals(102, $data['data']['relationships']['dialog']['data']['id'], $pretty); + $this->assertEquals('direct', collect($data['included'])->firstWhere('type', 'dialogs')['attributes']['type'], $pretty); + $this->assertEquals('Hello, Bob!', $data['data']['attributes']['contentHtml'], $pretty); + $this->assertEqualsCanonicalizing([4, 5], collect(collect($data['included'])->firstWhere('type', 'dialogs')['relationships']['users']['data'])->pluck('id')->all(), $pretty); + } +} diff --git a/extensions/messages/tests/integration/api/dialogs/UpdateTest.php b/extensions/messages/tests/integration/api/dialogs/UpdateTest.php new file mode 100644 index 000000000..9f1b63b2f --- /dev/null +++ b/extensions/messages/tests/integration/api/dialogs/UpdateTest.php @@ -0,0 +1,112 @@ +extension('flarum-messages'); + + $this->prepareDatabase([ + User::class => [ + ['id' => 3, 'username' => 'alice'], + ['id' => 4, 'username' => 'bob'], + ['id' => 5, 'username' => 'karlach'], + ], + Dialog::class => [ + ['id' => 102, 'type' => 'direct', 'last_message_id' => 111], + ], + DialogMessage::class => [ + ['id' => 102, 'dialog_id' => 102, 'user_id' => 4, 'content' => '

    Hello, Alice!

    '], + ['id' => 103, 'dialog_id' => 102, 'user_id' => 3, 'content' => '

    Hello, Bob!

    '], + ['id' => 104, 'dialog_id' => 102, 'user_id' => 4, 'content' => '

    Hello, Alice!

    '], + ['id' => 105, 'dialog_id' => 102, 'user_id' => 3, 'content' => '

    Hello, Bob!

    '], + ['id' => 106, 'dialog_id' => 102, 'user_id' => 4, 'content' => '

    Hello, Alice!

    '], + ['id' => 107, 'dialog_id' => 102, 'user_id' => 3, 'content' => '

    Hello, Bob!

    '], + ['id' => 108, 'dialog_id' => 102, 'user_id' => 4, 'content' => '

    Hello, Alice!

    '], + ['id' => 109, 'dialog_id' => 102, 'user_id' => 3, 'content' => '

    Hello, Bob!

    '], + ['id' => 110, 'dialog_id' => 102, 'user_id' => 4, 'content' => '

    Hello, Alice!

    '], + ['id' => 111, 'dialog_id' => 102, 'user_id' => 3, 'content' => '

    Hello, Bob!

    '], + ], + 'dialog_user' => [ + ['dialog_id' => 102, 'user_id' => 3, 'last_read_message_id' => 0, 'last_read_at' => null, 'joined_at' => Carbon::now()], + ['dialog_id' => 102, 'user_id' => 4, 'last_read_message_id' => 0, 'last_read_at' => null, 'joined_at' => Carbon::now()], + ], + ]); + } + + public function test_can_mark_dialog_as_read(): void + { + $response = $this->send( + $this->request('PATCH', '/api/dialogs/102', [ + 'authenticatedAs' => 3, + 'json' => [ + 'data' => [ + 'type' => 'dialogs', + 'id' => '102', + 'attributes' => [ + 'lastReadMessageId' => 107, + ], + ], + ], + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $state = UserDialogState::query() + ->where('dialog_id', 102) + ->where('user_id', 3) + ->first(); + + $this->assertEquals(107, $state->last_read_message_id); + $this->assertNotNull($state->last_read_at); + } + + public function test_can_mark_all_as_read(): void + { + $response = $this->send( + $this->request('POST', '/api/dialogs/read', [ + 'authenticatedAs' => 3, + ]) + ); + + $this->assertEquals(204, $response->getStatusCode(), json_encode(json_decode($response->getBody()->getContents()), JSON_PRETTY_PRINT)); + + $state = UserDialogState::query() + ->where('dialog_id', 102) + ->where('user_id', 3) + ->first(); + + $nonState = UserDialogState::query() + ->where('dialog_id', 102) + ->where('user_id', '!=', 3) + ->first(); + + $this->assertNotNull($state->last_read_at); + $this->assertNull($nonState->last_read_at); + + $this->assertEquals(111, $state->last_read_message_id); + $this->assertEquals(0, $nonState->last_read_message_id); + } +} diff --git a/extensions/messages/tests/integration/setup.php b/extensions/messages/tests/integration/setup.php new file mode 100644 index 000000000..96caab5fe --- /dev/null +++ b/extensions/messages/tests/integration/setup.php @@ -0,0 +1,12 @@ +run(); diff --git a/extensions/messages/tests/phpunit.integration.xml b/extensions/messages/tests/phpunit.integration.xml new file mode 100644 index 000000000..e3e14eab9 --- /dev/null +++ b/extensions/messages/tests/phpunit.integration.xml @@ -0,0 +1,24 @@ + + + + + ../src/ + + + + + ./integration + ./integration/tmp + + + diff --git a/extensions/messages/tests/phpunit.unit.xml b/extensions/messages/tests/phpunit.unit.xml new file mode 100644 index 000000000..eb33f60fe --- /dev/null +++ b/extensions/messages/tests/phpunit.unit.xml @@ -0,0 +1,22 @@ + + + + + ../src/ + + + + + ./unit + + + diff --git a/extensions/messages/tests/unit/.gitkeep b/extensions/messages/tests/unit/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/extensions/messages/views/emails/html/messageReceived.blade.php b/extensions/messages/views/emails/html/messageReceived.blade.php new file mode 100644 index 000000000..f07ae20b8 --- /dev/null +++ b/extensions/messages/views/emails/html/messageReceived.blade.php @@ -0,0 +1,16 @@ +@php +/** @var \Flarum\Messages\Notification\MessageReceivedBlueprint $blueprint */ +@endphp + +@extends('flarum.forum::email.html.notification.base') + +@section('notificationContent') +{!! $formatter->convert($translator->trans('flarum-messages.email.message_received.html.body', [ +'{user_display_name}' => $blueprint->message->user->display_name, +'{url}' => $url->to('forum')->route('messages.dialog', ['id' => $blueprint->message->dialog_id, 'near' => $blueprint->message->id]) +])) !!} +@endsection + +@section('contentPreview') + {!! $blueprint->message->formatContent() !!} +@endsection diff --git a/extensions/messages/views/emails/plain/messageReceived.blade.php b/extensions/messages/views/emails/plain/messageReceived.blade.php new file mode 100644 index 000000000..01f7fbc48 --- /dev/null +++ b/extensions/messages/views/emails/plain/messageReceived.blade.php @@ -0,0 +1,13 @@ +@php + /** @var \Flarum\Messages\Notification\MessageReceivedBlueprint $blueprint */ +@endphp + +@extends('flarum.forum::email.plain.notification.base') + +@section('content') +{!! $translator->trans('flarum-messages.email.message_received.plain.body', [ +'{user_display_name}' => $blueprint->message->user->display_name, +'{url}' => $url->to('forum')->route('messages.dialog', ['id' => $blueprint->message->dialog_id, 'near' => $blueprint->message->id]), +'{content}' => $blueprint->message->content +]) !!} +@endsection diff --git a/extensions/package-manager/js/src/admin/components/ConfigureJson.tsx b/extensions/package-manager/js/src/admin/components/ConfigureJson.tsx index fc2408229..1e276fed4 100644 --- a/extensions/package-manager/js/src/admin/components/ConfigureJson.tsx +++ b/extensions/package-manager/js/src/admin/components/ConfigureJson.tsx @@ -1,9 +1,8 @@ import app from 'flarum/admin/app'; import type Mithril from 'mithril'; import Component, { type ComponentAttrs } from 'flarum/common/Component'; -import { type SettingsComponentOptions } from '@flarum/core/src/admin/components/AdminPage'; +import { type SettingsComponentOptions } from 'flarum/admin/components/AdminPage'; import FormGroup, { type CommonFieldOptions } from 'flarum/common/components/FormGroup'; -import AdminPage from 'flarum/admin/components/AdminPage'; import type ItemList from 'flarum/common/utils/ItemList'; import Stream from 'flarum/common/utils/Stream'; import Button from 'flarum/common/components/Button'; diff --git a/extensions/pusher/LICENSE b/extensions/pusher/LICENSE index 54ac29ab2..bb6e15d87 100644 --- a/extensions/pusher/LICENSE +++ b/extensions/pusher/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019-2021 Stichting Flarum (Flarum Foundation) +Copyright (c) 2019-2024 Stichting Flarum (Flarum Foundation) Copyright (c) 2014-2019 Toby Zerner (toby.zerner@gmail.com) Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/extensions/statistics/LICENSE b/extensions/statistics/LICENSE index 54ac29ab2..bb6e15d87 100644 --- a/extensions/statistics/LICENSE +++ b/extensions/statistics/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019-2021 Stichting Flarum (Flarum Foundation) +Copyright (c) 2019-2024 Stichting Flarum (Flarum Foundation) Copyright (c) 2014-2019 Toby Zerner (toby.zerner@gmail.com) Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/extensions/sticky/LICENSE b/extensions/sticky/LICENSE index 54ac29ab2..bb6e15d87 100644 --- a/extensions/sticky/LICENSE +++ b/extensions/sticky/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019-2021 Stichting Flarum (Flarum Foundation) +Copyright (c) 2019-2024 Stichting Flarum (Flarum Foundation) Copyright (c) 2014-2019 Toby Zerner (toby.zerner@gmail.com) Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/extensions/subscriptions/LICENSE b/extensions/subscriptions/LICENSE index 54ac29ab2..bb6e15d87 100644 --- a/extensions/subscriptions/LICENSE +++ b/extensions/subscriptions/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019-2021 Stichting Flarum (Flarum Foundation) +Copyright (c) 2019-2024 Stichting Flarum (Flarum Foundation) Copyright (c) 2014-2019 Toby Zerner (toby.zerner@gmail.com) Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/extensions/subscriptions/src/Notification/NewPostBlueprint.php b/extensions/subscriptions/src/Notification/NewPostBlueprint.php index e96c803dd..45cdc5f05 100644 --- a/extensions/subscriptions/src/Notification/NewPostBlueprint.php +++ b/extensions/subscriptions/src/Notification/NewPostBlueprint.php @@ -12,12 +12,13 @@ namespace Flarum\Subscriptions\Notification; use Flarum\Database\AbstractModel; use Flarum\Discussion\Discussion; use Flarum\Locale\TranslatorInterface; +use Flarum\Notification\AlertableInterface; use Flarum\Notification\Blueprint\BlueprintInterface; use Flarum\Notification\MailableInterface; use Flarum\Post\Post; use Flarum\User\User; -class NewPostBlueprint implements BlueprintInterface, MailableInterface +class NewPostBlueprint implements BlueprintInterface, AlertableInterface, MailableInterface { public function __construct( public Post $post diff --git a/extensions/suspend/LICENSE b/extensions/suspend/LICENSE index 54ac29ab2..bb6e15d87 100644 --- a/extensions/suspend/LICENSE +++ b/extensions/suspend/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019-2021 Stichting Flarum (Flarum Foundation) +Copyright (c) 2019-2024 Stichting Flarum (Flarum Foundation) Copyright (c) 2014-2019 Toby Zerner (toby.zerner@gmail.com) Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/extensions/suspend/src/Notification/UserSuspendedBlueprint.php b/extensions/suspend/src/Notification/UserSuspendedBlueprint.php index ea9c6a042..1a5d78294 100644 --- a/extensions/suspend/src/Notification/UserSuspendedBlueprint.php +++ b/extensions/suspend/src/Notification/UserSuspendedBlueprint.php @@ -13,11 +13,12 @@ use Carbon\Carbon; use Carbon\CarbonInterface; use Flarum\Database\AbstractModel; use Flarum\Locale\TranslatorInterface; +use Flarum\Notification\AlertableInterface; use Flarum\Notification\Blueprint\BlueprintInterface; use Flarum\Notification\MailableInterface; use Flarum\User\User; -class UserSuspendedBlueprint implements BlueprintInterface, MailableInterface +class UserSuspendedBlueprint implements BlueprintInterface, AlertableInterface, MailableInterface { public function __construct( public User $user diff --git a/extensions/suspend/src/Notification/UserUnsuspendedBlueprint.php b/extensions/suspend/src/Notification/UserUnsuspendedBlueprint.php index 31745dbb8..0dc0717ca 100644 --- a/extensions/suspend/src/Notification/UserUnsuspendedBlueprint.php +++ b/extensions/suspend/src/Notification/UserUnsuspendedBlueprint.php @@ -12,12 +12,13 @@ namespace Flarum\Suspend\Notification; use Carbon\CarbonInterface; use Flarum\Database\AbstractModel; use Flarum\Locale\TranslatorInterface; +use Flarum\Notification\AlertableInterface; use Flarum\Notification\Blueprint\BlueprintInterface; use Flarum\Notification\MailableInterface; use Flarum\User\User; use Illuminate\Support\Carbon; -class UserUnsuspendedBlueprint implements BlueprintInterface, MailableInterface +class UserUnsuspendedBlueprint implements BlueprintInterface, AlertableInterface, MailableInterface { public function __construct( public User $user diff --git a/extensions/tags/LICENSE b/extensions/tags/LICENSE index 54ac29ab2..bb6e15d87 100644 --- a/extensions/tags/LICENSE +++ b/extensions/tags/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019-2021 Stichting Flarum (Flarum Foundation) +Copyright (c) 2019-2024 Stichting Flarum (Flarum Foundation) Copyright (c) 2014-2019 Toby Zerner (toby.zerner@gmail.com) Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/extensions/tags/js/src/common/components/TagSelectionModal.tsx b/extensions/tags/js/src/common/components/TagSelectionModal.tsx index d8b47ab1d..7b9366f7b 100644 --- a/extensions/tags/js/src/common/components/TagSelectionModal.tsx +++ b/extensions/tags/js/src/common/components/TagSelectionModal.tsx @@ -128,7 +128,7 @@ export default class TagSelectionModal< } className() { - return classList('TagSelectionModal', this.attrs.className); + return classList('TagSelectionModal Modal--simple', this.attrs.className); } title() { diff --git a/extensions/tags/js/src/forum/addTagList.js b/extensions/tags/js/src/forum/addTagList.js index a26c9ae8f..d14f2e768 100644 --- a/extensions/tags/js/src/forum/addTagList.js +++ b/extensions/tags/js/src/forum/addTagList.js @@ -20,7 +20,7 @@ export default function addTagList() { -10 ); - if (app.current.matches(TagsPage)) return; + if (app.current.get('noTagsList')) return; items.add('separator', , -12); diff --git a/extensions/tags/js/src/forum/components/TagsPage.tsx b/extensions/tags/js/src/forum/components/TagsPage.tsx index 4620e08df..bd431bb7a 100755 --- a/extensions/tags/js/src/forum/components/TagsPage.tsx +++ b/extensions/tags/js/src/forum/components/TagsPage.tsx @@ -30,6 +30,8 @@ export default class TagsPage(); @@ -59,7 +61,7 @@ export default class TagsPage + {this.contentItems().toArray()} ); diff --git a/extensions/tags/less/common/TagSelectionModal.less b/extensions/tags/less/common/TagSelectionModal.less index 0237c9ad3..86cf6e581 100644 --- a/extensions/tags/less/common/TagSelectionModal.less +++ b/extensions/tags/less/common/TagSelectionModal.less @@ -1,28 +1,4 @@ .TagSelectionModal { - @media @tablet-up { - .Modal-header { - background: var(--control-bg); - padding: 20px 20px 0; - - & h3 { - text-align: left; - color: var(--control-color); - font-size: 16px; - } - } - } - .Modal-body { - padding: 20px; - - @media @phone { - padding: 15px; - } - } - .Modal-footer { - padding: 1px 0 0; - text-align: left; - } - &-controls { padding: 20px; } diff --git a/extensions/tags/less/forum.less b/extensions/tags/less/forum.less index 15798ee4b..f0822aa0e 100644 --- a/extensions/tags/less/forum.less +++ b/extensions/tags/less/forum.less @@ -67,31 +67,3 @@ } } } -@media @desktop-up { - .TagsPage { - --sidebar-width: 100%; - --gap: 30px; - - .sideNav { - .sideNav--horizontal(); - width: auto; - padding: 0; - - &:after { - display: none; - } - - > ul > li:first-child { - width: 190px; - } - } - - .Page-container { - flex-direction: column; - } - - .Page-content { - margin-top: 0; - } - } -} diff --git a/flarum-monorepo.json b/flarum-monorepo.json index f814cd126..46803529b 100644 --- a/flarum-monorepo.json +++ b/flarum-monorepo.json @@ -95,6 +95,11 @@ "name": "tags", "gitRemote": "git@github.com:flarum/tags.git", "mainBranch": "main" + }, + { + "name": "messages", + "gitRemote": "git@github.com:flarum/messages.git", + "mainBranch": "main" } ], "composer": [ diff --git a/framework/core/LICENSE.md b/framework/core/LICENSE.md index 54ac29ab2..bb6e15d87 100644 --- a/framework/core/LICENSE.md +++ b/framework/core/LICENSE.md @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019-2021 Stichting Flarum (Flarum Foundation) +Copyright (c) 2019-2024 Stichting Flarum (Flarum Foundation) Copyright (c) 2014-2019 Toby Zerner (toby.zerner@gmail.com) Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/framework/core/js/dist-typings/common/utils/ScrollListener.d.ts b/framework/core/js/dist-typings/common/utils/ScrollListener.d.ts index 0cc2a6352..c08c029ff 100644 --- a/framework/core/js/dist-typings/common/utils/ScrollListener.d.ts +++ b/framework/core/js/dist-typings/common/utils/ScrollListener.d.ts @@ -1,15 +1,18 @@ /** - * The `ScrollListener` class sets up a listener that handles window scroll + * The `ScrollListener` class sets up a listener that handles element scroll * events. */ export default class ScrollListener { /** * @param {(top: number) => void} callback The callback to run when the scroll position * changes. + * @param {Window|Element} element The element to listen for scroll events on. Defaults to + * `window`. */ - constructor(callback: (top: number) => void); + constructor(callback: (top: number) => void, element?: Window | Element); callback: (top: number) => void; ticking: boolean; + element: Element | Window; /** * On each animation frame, as long as the listener is active, run the * `update` method. @@ -22,12 +25,12 @@ export default class ScrollListener { */ update(): void; /** - * Start listening to and handling the window's scroll position. + * Start listening to and handling the element's scroll position. */ start(): void; active: (() => void) | null | undefined; /** - * Stop listening to and handling the window's scroll position. + * Stop listening to and handling the element's scroll position. */ stop(): void; } diff --git a/framework/core/js/dist-typings/forum/components/ComposerBody.d.ts b/framework/core/js/dist-typings/forum/components/ComposerBody.d.ts index 6496508c7..64dc36135 100644 --- a/framework/core/js/dist-typings/forum/components/ComposerBody.d.ts +++ b/framework/core/js/dist-typings/forum/components/ComposerBody.d.ts @@ -1,53 +1,42 @@ +import Component, { type ComponentAttrs } from '../../common/Component'; +import ItemList from '../../common/utils/ItemList'; +import ComposerState from '../states/ComposerState'; +import type Mithril from 'mithril'; +export interface IComposerBodyAttrs extends ComponentAttrs { + composer: ComposerState; + originalContent?: string; + submitLabel: string; + placeholder: string; + user: any; + confirmExit: string; + disabled: boolean; +} /** * The `ComposerBody` component handles the body, or the content, of the * composer. Subclasses should implement the `onsubmit` method and override * `headerTimes`. - * - * ### Attrs - * - * - `composer` - * - `originalContent` - * - `submitLabel` - * - `placeholder` - * - `user` - * - `confirmExit` - * - `disabled` - * - * @abstract */ -export default class ComposerBody extends Component { - constructor(); - oninit(vnode: any): void; - composer: any; - /** - * Whether or not the component is loading. - * - * @type {Boolean} - */ - loading: boolean | undefined; +export default abstract class ComposerBody extends Component { + protected loading: boolean; + protected composer: ComposerState; + protected jumpToPreview?: () => void; + static focusOnSelector: null | (() => string); + oninit(vnode: Mithril.Vnode): void; view(): JSX.Element; /** * Check if there is any unsaved data. - * - * @return {boolean} */ hasChanges(): boolean; /** * Build an item list for the composer's header. - * - * @return {ItemList} */ - headerItems(): ItemList; + headerItems(): ItemList; /** * Handle the submit event of the text editor. - * - * @abstract */ - onsubmit(): void; + abstract onsubmit(): void; /** * Stop loading. */ loaded(): void; } -import Component from "../../common/Component"; -import ItemList from "../../common/utils/ItemList"; diff --git a/framework/core/js/dist-typings/forum/components/HeaderList.d.ts b/framework/core/js/dist-typings/forum/components/HeaderList.d.ts index a17180608..dce37267d 100644 --- a/framework/core/js/dist-typings/forum/components/HeaderList.d.ts +++ b/framework/core/js/dist-typings/forum/components/HeaderList.d.ts @@ -9,6 +9,7 @@ export interface IHeaderListAttrs extends ComponentAttrs { loading?: boolean; emptyText: string; loadMore?: () => void; + footer?: () => Mithril.Children; } export default class HeaderList extends Component { $content: JQuery | null; diff --git a/framework/core/js/dist/forum/components/Composer.js.map b/framework/core/js/dist/forum/components/Composer.js.map index 51846733e..2d7d9165f 100644 --- a/framework/core/js/dist/forum/components/Composer.js.map +++ b/framework/core/js/dist/forum/components/Composer.js.map @@ -1 +1 @@ -{"version":3,"file":"forum/components/Composer.js","mappings":"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","sources":["webpack://@flarum/core/./src/forum/components/ComposerButton.js","webpack://@flarum/core/./src/forum/components/Composer.js"],"sourcesContent":["import Button from '../../common/components/Button';\n\n/**\n * The `ComposerButton` component displays a button suitable for the composer\n * controls.\n */\nexport default class ComposerButton extends Button {\n static initAttrs(attrs) {\n super.initAttrs(attrs);\n attrs.className = attrs.className || 'Button Button--icon Button--link';\n }\n}\nflarum.reg.add('core', 'forum/components/ComposerButton', ComposerButton);","import app from '../../forum/app';\nimport Component from '../../common/Component';\nimport ItemList from '../../common/utils/ItemList';\nimport ComposerButton from './ComposerButton';\nimport listItems from '../../common/helpers/listItems';\nimport classList from '../../common/utils/classList';\nimport ComposerState from '../states/ComposerState';\n\n/**\n * The `Composer` component displays the composer. It can be loaded with a\n * content component with `load` and then its position/state can be altered with\n * `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`.\n */\nexport default class Composer extends Component {\n oninit(vnode) {\n super.oninit(vnode);\n\n /**\n * The composer's \"state\".\n *\n * @type {ComposerState}\n */\n this.state = this.attrs.state;\n\n /**\n * Whether or not the composer currently has focus.\n *\n * @type {Boolean}\n */\n this.active = false;\n\n // Store the initial position so that we can trigger animations correctly.\n this.prevPosition = this.state.position;\n }\n view() {\n const body = this.state.body;\n const classes = {\n normal: this.state.position === ComposerState.Position.NORMAL,\n minimized: this.state.position === ComposerState.Position.MINIMIZED,\n fullScreen: this.state.position === ComposerState.Position.FULLSCREEN,\n active: this.active,\n visible: this.state.isVisible()\n };\n\n // Set up a handler so that clicks on the content will show the composer.\n const showIfMinimized = this.state.position === ComposerState.Position.MINIMIZED ? this.state.show.bind(this.state) : undefined;\n const ComposerBody = body.componentClass;\n return m(\"div\", {\n className: 'Composer ' + classList(classes)\n }, m(\"div\", {\n className: \"Composer-handle\",\n oncreate: this.configHandle.bind(this)\n }), m(\"ul\", {\n className: \"Composer-controls\"\n }, listItems(this.controlItems().toArray())), m(\"div\", {\n className: \"Composer-content\",\n onclick: showIfMinimized\n }, ComposerBody && m(ComposerBody, Object.assign({}, body.attrs, {\n composer: this.state,\n disabled: classes.minimized\n }))));\n }\n onupdate(vnode) {\n super.onupdate(vnode);\n if (this.state.position === this.prevPosition) {\n // Set the height of the Composer element and its contents on each redraw,\n // so that they do not lose it if their DOM elements are recreated.\n this.updateHeight();\n } else {\n this.animatePositionChange();\n this.prevPosition = this.state.position;\n }\n }\n oncreate(vnode) {\n super.oncreate(vnode);\n this.initializeHeight();\n this.$().hide().css('bottom', -this.state.computedHeight());\n\n // Whenever any of the inputs inside the composer are have focus, we want to\n // add a class to the composer to draw attention to it.\n this.$().on('focus blur', ':input,.TextEditor-editorContainer', e => {\n this.active = e.type === 'focusin';\n m.redraw();\n });\n\n // When the escape key is pressed on any inputs, close the composer.\n this.$().on('keydown', ':input,.TextEditor-editorContainer', 'esc', () => this.state.close());\n this.handlers = {};\n $(window).on('resize', this.handlers.onresize = this.updateHeight.bind(this)).resize();\n $(document).on('mousemove', this.handlers.onmousemove = this.onmousemove.bind(this)).on('mouseup', this.handlers.onmouseup = this.onmouseup.bind(this));\n }\n onremove(vnode) {\n super.onremove(vnode);\n $(window).off('resize', this.handlers.onresize);\n $(document).off('mousemove', this.handlers.onmousemove).off('mouseup', this.handlers.onmouseup);\n }\n\n /**\n * Add the necessary event handlers to the composer's handle so that it can\n * be used to resize the composer.\n */\n configHandle(vnode) {\n const composer = this;\n $(vnode.dom).css('cursor', 'row-resize').bind('dragstart mousedown', e => e.preventDefault()).mousedown(function (e) {\n composer.mouseStart = e.clientY;\n composer.heightStart = composer.$().height();\n composer.handle = $(this);\n $('body').css('cursor', 'row-resize');\n });\n }\n\n /**\n * Resize the composer according to mouse movement.\n *\n * @param {MouseEvent} e\n */\n onmousemove(e) {\n if (!this.handle) return;\n\n // Work out how much the mouse has been moved, and set the height\n // relative to the old one based on that. Then update the content's\n // height so that it fills the height of the composer, and update the\n // body's padding.\n const deltaPixels = this.mouseStart - e.clientY;\n this.changeHeight(this.heightStart + deltaPixels);\n\n // Update the body's padding-bottom so that no content on the page will ever\n // get permanently hidden behind the composer. If the user is already\n // scrolled to the bottom of the page, then we will keep them scrolled to\n // the bottom after the padding has been updated.\n const scrollTop = $(window).scrollTop();\n const anchorToBottom = scrollTop > 0 && scrollTop + $(window).height() >= $(document).height();\n this.updateBodyPadding(anchorToBottom);\n }\n\n /**\n * Finish resizing the composer when the mouse is released.\n */\n onmouseup() {\n if (!this.handle) return;\n this.handle = null;\n $('body').css('cursor', '');\n }\n\n /**\n * Draw focus to the first focusable content element (the text editor).\n */\n focus() {\n this.$('.Composer-content :input:enabled:visible, .TextEditor-editor').first().focus();\n }\n\n /**\n * Update the DOM to reflect the composer's current height. This involves\n * setting the height of the composer's root element, and adjusting the height\n * of any flexible elements inside the composer's body.\n */\n updateHeight() {\n const height = this.state.computedHeight();\n const $flexible = this.$('.Composer-flexible');\n this.$().height(height);\n if ($flexible.length) {\n const headerHeight = $flexible.offset().top - this.$().offset().top;\n const paddingBottom = parseInt($flexible.css('padding-bottom'), 10);\n const footerHeight = this.$('.Composer-footer').outerHeight(true);\n $flexible.height(this.$().outerHeight() - headerHeight - paddingBottom - footerHeight);\n }\n }\n\n /**\n * Update the amount of padding-bottom on the body so that the page's\n * content will still be visible above the composer when the page is\n * scrolled right to the bottom.\n */\n updateBodyPadding() {\n const visible = this.state.position !== ComposerState.Position.HIDDEN && this.state.position !== ComposerState.Position.MINIMIZED && app.screen() !== 'phone';\n const paddingBottom = visible ? this.state.computedHeight() - parseInt($('#app').css('padding-bottom'), 10) : 0;\n $('#content').css({\n paddingBottom\n });\n }\n\n /**\n * Trigger the right animation depending on the desired new position.\n */\n animatePositionChange() {\n // When exiting full-screen mode: focus content\n if (this.prevPosition === ComposerState.Position.FULLSCREEN && this.state.position === ComposerState.Position.NORMAL) {\n this.focus();\n return;\n }\n switch (this.state.position) {\n case ComposerState.Position.HIDDEN:\n return this.hide();\n case ComposerState.Position.MINIMIZED:\n return this.minimize();\n case ComposerState.Position.FULLSCREEN:\n return this.focus();\n case ComposerState.Position.NORMAL:\n return this.show();\n }\n }\n\n /**\n * Animate the Composer into the new position by changing the height.\n */\n animateHeightChange() {\n const $composer = this.$().stop(true);\n const oldHeight = $composer.outerHeight();\n const scrollTop = $(window).scrollTop();\n $composer.show();\n this.updateHeight();\n const newHeight = $composer.outerHeight();\n if (this.prevPosition === ComposerState.Position.HIDDEN) {\n $composer.css({\n bottom: -newHeight,\n height: newHeight\n });\n } else {\n $composer.css({\n height: oldHeight\n });\n }\n const animation = $composer.animate({\n bottom: 0,\n height: newHeight\n }, 'fast').promise();\n this.updateBodyPadding();\n $(window).scrollTop(scrollTop);\n return animation;\n }\n\n /**\n * Show the Composer backdrop.\n */\n showBackdrop() {\n this.$backdrop = $('
    ').addClass('composer-backdrop').appendTo('body');\n }\n\n /**\n * Hide the Composer backdrop.\n */\n hideBackdrop() {\n if (this.$backdrop) this.$backdrop.remove();\n }\n\n /**\n * Animate the composer sliding up from the bottom to take its normal height.\n *\n * @private\n */\n show() {\n this.animateHeightChange().then(() => this.focus());\n if (app.screen() === 'phone') {\n // On safari fixed position doesn't properly work on mobile,\n // So we use absolute and set the top value.\n // https://github.com/flarum/core/issues/2652\n\n // Due to another safari bug, `scrollTop` is unreliable when\n // at the very bottom of the page AND opening the composer.\n // So we fallback to a calculated version of scrollTop.\n // https://github.com/flarum/core/issues/2683\n const scrollElement = document.documentElement;\n const topOfViewport = Math.min(scrollElement.scrollTop, scrollElement.scrollHeight - scrollElement.clientHeight);\n this.$().css('top', $('.App').is('.mobile-safari') ? topOfViewport : 0);\n this.showBackdrop();\n }\n }\n\n /**\n * Animate closing the composer.\n *\n * @private\n */\n hide() {\n const $composer = this.$();\n\n // Animate the composer sliding down off the bottom edge of the viewport.\n // Only when the animation is completed, update other elements on the page.\n $composer.stop(true).animate({\n bottom: -$composer.height()\n }, 'fast', () => {\n $composer.hide();\n this.hideBackdrop();\n this.updateBodyPadding();\n });\n }\n\n /**\n * Shrink the composer until only its title is visible.\n *\n * @private\n */\n minimize() {\n this.animateHeightChange();\n this.$().css('top', 'auto');\n this.hideBackdrop();\n }\n\n /**\n * Build an item list for the composer's controls.\n *\n * @return {ItemList}\n */\n controlItems() {\n const items = new ItemList();\n if (this.state.position === ComposerState.Position.FULLSCREEN) {\n items.add('exitFullScreen', m(ComposerButton, {\n icon: \"fas fa-compress\",\n title: app.translator.trans('core.forum.composer.exit_full_screen_tooltip'),\n onclick: this.state.exitFullScreen.bind(this.state)\n }));\n } else {\n if (this.state.position !== ComposerState.Position.MINIMIZED) {\n items.add('minimize', m(ComposerButton, {\n icon: classList('fas minimize', {\n 'fa-minus': app.screen() !== 'phone',\n 'fa-times': app.screen() === 'phone'\n }),\n title: app.translator.trans('core.forum.composer.minimize_tooltip'),\n onclick: this.state.minimize.bind(this.state),\n itemClassName: \"App-backControl\"\n }));\n items.add('fullScreen', m(ComposerButton, {\n icon: \"fas fa-expand\",\n title: app.translator.trans('core.forum.composer.full_screen_tooltip'),\n onclick: this.state.fullScreen.bind(this.state)\n }));\n }\n items.add('close', m(ComposerButton, {\n icon: \"fas fa-times\",\n title: app.translator.trans('core.forum.composer.close_tooltip'),\n onclick: this.state.close.bind(this.state)\n }));\n }\n return items;\n }\n\n /**\n * Initialize default Composer height.\n */\n initializeHeight() {\n this.state.height = localStorage.getItem('composerHeight');\n if (!this.state.height) {\n this.state.height = this.defaultHeight();\n }\n }\n\n /**\n * Default height of the Composer in case none is saved.\n * @returns {number}\n */\n defaultHeight() {\n return this.$().height();\n }\n\n /**\n * Save a new Composer height and update the DOM.\n * @param {number} height\n */\n changeHeight(height) {\n this.state.height = height;\n this.updateHeight();\n localStorage.setItem('composerHeight', this.state.height);\n }\n}\nflarum.reg.add('core', 'forum/components/Composer', Composer);"],"names":["ComposerButton","Button","static","attrs","super","initAttrs","className","flarum","reg","add","Composer","Component","oninit","vnode","this","state","active","prevPosition","position","view","body","classes","normal","ComposerState","minimized","fullScreen","visible","isVisible","showIfMinimized","show","bind","undefined","ComposerBody","componentClass","m","classList","oncreate","configHandle","listItems","controlItems","toArray","onclick","Object","assign","composer","disabled","onupdate","updateHeight","animatePositionChange","initializeHeight","$","hide","css","computedHeight","on","e","type","redraw","close","handlers","window","onresize","resize","document","onmousemove","onmouseup","onremove","off","dom","preventDefault","mousedown","mouseStart","clientY","heightStart","height","handle","deltaPixels","changeHeight","scrollTop","anchorToBottom","updateBodyPadding","focus","first","$flexible","length","headerHeight","offset","top","paddingBottom","parseInt","footerHeight","outerHeight","app","minimize","animateHeightChange","$composer","stop","oldHeight","newHeight","bottom","animation","animate","promise","showBackdrop","$backdrop","addClass","appendTo","hideBackdrop","remove","then","scrollElement","documentElement","topOfViewport","Math","min","scrollHeight","clientHeight","is","items","ItemList","icon","title","exitFullScreen","itemClassName","localStorage","getItem","defaultHeight","setItem"],"sourceRoot":""} \ No newline at end of file +{"version":3,"file":"forum/components/Composer.js","mappings":"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","sources":["webpack://@flarum/core/./src/forum/components/ComposerButton.js","webpack://@flarum/core/./src/forum/components/Composer.js"],"sourcesContent":["import Button from '../../common/components/Button';\n\n/**\n * The `ComposerButton` component displays a button suitable for the composer\n * controls.\n */\nexport default class ComposerButton extends Button {\n static initAttrs(attrs) {\n super.initAttrs(attrs);\n attrs.className = attrs.className || 'Button Button--icon Button--link';\n }\n}\nflarum.reg.add('core', 'forum/components/ComposerButton', ComposerButton);","import app from '../../forum/app';\nimport Component from '../../common/Component';\nimport ItemList from '../../common/utils/ItemList';\nimport ComposerButton from './ComposerButton';\nimport listItems from '../../common/helpers/listItems';\nimport classList from '../../common/utils/classList';\nimport ComposerState from '../states/ComposerState';\n\n/**\n * The `Composer` component displays the composer. It can be loaded with a\n * content component with `load` and then its position/state can be altered with\n * `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`.\n */\nexport default class Composer extends Component {\n oninit(vnode) {\n super.oninit(vnode);\n\n /**\n * The composer's \"state\".\n *\n * @type {ComposerState}\n */\n this.state = this.attrs.state;\n\n /**\n * Whether or not the composer currently has focus.\n *\n * @type {Boolean}\n */\n this.active = false;\n\n // Store the initial position so that we can trigger animations correctly.\n this.prevPosition = this.state.position;\n }\n view() {\n const body = this.state.body;\n const classes = {\n normal: this.state.position === ComposerState.Position.NORMAL,\n minimized: this.state.position === ComposerState.Position.MINIMIZED,\n fullScreen: this.state.position === ComposerState.Position.FULLSCREEN,\n active: this.active,\n visible: this.state.isVisible()\n };\n\n // Set up a handler so that clicks on the content will show the composer.\n const showIfMinimized = this.state.position === ComposerState.Position.MINIMIZED ? this.state.show.bind(this.state) : undefined;\n const ComposerBody = body.componentClass;\n return m(\"div\", {\n className: 'Composer ' + classList(classes)\n }, m(\"div\", {\n className: \"Composer-handle\",\n oncreate: this.configHandle.bind(this)\n }), m(\"ul\", {\n className: \"Composer-controls\"\n }, listItems(this.controlItems().toArray())), m(\"div\", {\n className: \"Composer-content\",\n onclick: showIfMinimized\n }, ComposerBody && m(ComposerBody, Object.assign({}, body.attrs, {\n composer: this.state,\n disabled: classes.minimized\n }))));\n }\n onupdate(vnode) {\n super.onupdate(vnode);\n if (this.state.position === this.prevPosition) {\n // Set the height of the Composer element and its contents on each redraw,\n // so that they do not lose it if their DOM elements are recreated.\n this.updateHeight();\n } else {\n this.animatePositionChange();\n this.prevPosition = this.state.position;\n }\n }\n oncreate(vnode) {\n super.oncreate(vnode);\n this.initializeHeight();\n this.$().hide().css('bottom', -this.state.computedHeight());\n\n // Whenever any of the inputs inside the composer are have focus, we want to\n // add a class to the composer to draw attention to it.\n this.$().on('focus blur', ':input,.TextEditor-editorContainer', e => {\n this.active = e.type === 'focusin';\n m.redraw();\n });\n\n // When the escape key is pressed on any inputs, close the composer.\n this.$().on('keydown', ':input,.TextEditor-editorContainer', 'esc', () => this.state.close());\n this.handlers = {};\n $(window).on('resize', this.handlers.onresize = this.updateHeight.bind(this)).resize();\n $(document).on('mousemove', this.handlers.onmousemove = this.onmousemove.bind(this)).on('mouseup', this.handlers.onmouseup = this.onmouseup.bind(this));\n }\n onremove(vnode) {\n super.onremove(vnode);\n $(window).off('resize', this.handlers.onresize);\n $(document).off('mousemove', this.handlers.onmousemove).off('mouseup', this.handlers.onmouseup);\n }\n\n /**\n * Add the necessary event handlers to the composer's handle so that it can\n * be used to resize the composer.\n */\n configHandle(vnode) {\n const composer = this;\n $(vnode.dom).css('cursor', 'row-resize').bind('dragstart mousedown', e => e.preventDefault()).mousedown(function (e) {\n composer.mouseStart = e.clientY;\n composer.heightStart = composer.$().height();\n composer.handle = $(this);\n $('body').css('cursor', 'row-resize');\n });\n }\n\n /**\n * Resize the composer according to mouse movement.\n *\n * @param {MouseEvent} e\n */\n onmousemove(e) {\n if (!this.handle) return;\n\n // Work out how much the mouse has been moved, and set the height\n // relative to the old one based on that. Then update the content's\n // height so that it fills the height of the composer, and update the\n // body's padding.\n const deltaPixels = this.mouseStart - e.clientY;\n this.changeHeight(this.heightStart + deltaPixels);\n\n // Update the body's padding-bottom so that no content on the page will ever\n // get permanently hidden behind the composer. If the user is already\n // scrolled to the bottom of the page, then we will keep them scrolled to\n // the bottom after the padding has been updated.\n const scrollTop = $(window).scrollTop();\n const anchorToBottom = scrollTop > 0 && scrollTop + $(window).height() >= $(document).height();\n this.updateBodyPadding(anchorToBottom);\n }\n\n /**\n * Finish resizing the composer when the mouse is released.\n */\n onmouseup() {\n if (!this.handle) return;\n this.handle = null;\n $('body').css('cursor', '');\n }\n\n /**\n * Draw focus to the first focusable content element (the text editor).\n */\n focus() {\n var _this$attrs$state$bod, _this$attrs$state$bod2;\n this.$(((_this$attrs$state$bod = (_this$attrs$state$bod2 = this.attrs.state.body.componentClass).focusOnSelector) == null ? void 0 : _this$attrs$state$bod.call(_this$attrs$state$bod2)) || '.Composer-content :input:enabled:visible, .TextEditor-editor').first().focus();\n }\n\n /**\n * Update the DOM to reflect the composer's current height. This involves\n * setting the height of the composer's root element, and adjusting the height\n * of any flexible elements inside the composer's body.\n */\n updateHeight() {\n const height = this.state.computedHeight();\n const $flexible = this.$('.Composer-flexible');\n this.$().height(height);\n if ($flexible.length) {\n const headerHeight = $flexible.offset().top - this.$().offset().top;\n const paddingBottom = parseInt($flexible.css('padding-bottom'), 10);\n const footerHeight = this.$('.Composer-footer').outerHeight(true);\n $flexible.height(this.$().outerHeight() - headerHeight - paddingBottom - footerHeight);\n }\n }\n\n /**\n * Update the amount of padding-bottom on the body so that the page's\n * content will still be visible above the composer when the page is\n * scrolled right to the bottom.\n */\n updateBodyPadding() {\n const visible = this.state.position !== ComposerState.Position.HIDDEN && this.state.position !== ComposerState.Position.MINIMIZED && app.screen() !== 'phone';\n const paddingBottom = visible ? this.state.computedHeight() - parseInt($('#app').css('padding-bottom'), 10) : 0;\n $('#content').css({\n paddingBottom\n });\n }\n\n /**\n * Trigger the right animation depending on the desired new position.\n */\n animatePositionChange() {\n // When exiting full-screen mode: focus content\n if (this.prevPosition === ComposerState.Position.FULLSCREEN && this.state.position === ComposerState.Position.NORMAL) {\n this.focus();\n return;\n }\n switch (this.state.position) {\n case ComposerState.Position.HIDDEN:\n return this.hide();\n case ComposerState.Position.MINIMIZED:\n return this.minimize();\n case ComposerState.Position.FULLSCREEN:\n return this.focus();\n case ComposerState.Position.NORMAL:\n return this.show();\n }\n }\n\n /**\n * Animate the Composer into the new position by changing the height.\n */\n animateHeightChange() {\n const $composer = this.$().stop(true);\n const oldHeight = $composer.outerHeight();\n const scrollTop = $(window).scrollTop();\n $composer.show();\n this.updateHeight();\n const newHeight = $composer.outerHeight();\n if (this.prevPosition === ComposerState.Position.HIDDEN) {\n $composer.css({\n bottom: -newHeight,\n height: newHeight\n });\n } else {\n $composer.css({\n height: oldHeight\n });\n }\n const animation = $composer.animate({\n bottom: 0,\n height: newHeight\n }, 'fast').promise();\n this.updateBodyPadding();\n $(window).scrollTop(scrollTop);\n return animation;\n }\n\n /**\n * Show the Composer backdrop.\n */\n showBackdrop() {\n this.$backdrop = $('
    ').addClass('composer-backdrop').appendTo('body');\n }\n\n /**\n * Hide the Composer backdrop.\n */\n hideBackdrop() {\n if (this.$backdrop) this.$backdrop.remove();\n }\n\n /**\n * Animate the composer sliding up from the bottom to take its normal height.\n *\n * @private\n */\n show() {\n this.animateHeightChange().then(() => this.focus());\n if (app.screen() === 'phone') {\n // On safari fixed position doesn't properly work on mobile,\n // So we use absolute and set the top value.\n // https://github.com/flarum/core/issues/2652\n\n // Due to another safari bug, `scrollTop` is unreliable when\n // at the very bottom of the page AND opening the composer.\n // So we fallback to a calculated version of scrollTop.\n // https://github.com/flarum/core/issues/2683\n const scrollElement = document.documentElement;\n const topOfViewport = Math.min(scrollElement.scrollTop, scrollElement.scrollHeight - scrollElement.clientHeight);\n this.$().css('top', $('.App').is('.mobile-safari') ? topOfViewport : 0);\n this.showBackdrop();\n }\n }\n\n /**\n * Animate closing the composer.\n *\n * @private\n */\n hide() {\n const $composer = this.$();\n\n // Animate the composer sliding down off the bottom edge of the viewport.\n // Only when the animation is completed, update other elements on the page.\n $composer.stop(true).animate({\n bottom: -$composer.height()\n }, 'fast', () => {\n $composer.hide();\n this.hideBackdrop();\n this.updateBodyPadding();\n });\n }\n\n /**\n * Shrink the composer until only its title is visible.\n *\n * @private\n */\n minimize() {\n this.animateHeightChange();\n this.$().css('top', 'auto');\n this.hideBackdrop();\n }\n\n /**\n * Build an item list for the composer's controls.\n *\n * @return {ItemList}\n */\n controlItems() {\n const items = new ItemList();\n if (this.state.position === ComposerState.Position.FULLSCREEN) {\n items.add('exitFullScreen', m(ComposerButton, {\n icon: \"fas fa-compress\",\n title: app.translator.trans('core.forum.composer.exit_full_screen_tooltip'),\n onclick: this.state.exitFullScreen.bind(this.state)\n }));\n } else {\n if (this.state.position !== ComposerState.Position.MINIMIZED) {\n items.add('minimize', m(ComposerButton, {\n icon: classList('fas minimize', {\n 'fa-minus': app.screen() !== 'phone',\n 'fa-times': app.screen() === 'phone'\n }),\n title: app.translator.trans('core.forum.composer.minimize_tooltip'),\n onclick: this.state.minimize.bind(this.state),\n itemClassName: \"App-backControl\"\n }));\n items.add('fullScreen', m(ComposerButton, {\n icon: \"fas fa-expand\",\n title: app.translator.trans('core.forum.composer.full_screen_tooltip'),\n onclick: this.state.fullScreen.bind(this.state)\n }));\n }\n items.add('close', m(ComposerButton, {\n icon: \"fas fa-times\",\n title: app.translator.trans('core.forum.composer.close_tooltip'),\n onclick: this.state.close.bind(this.state)\n }));\n }\n return items;\n }\n\n /**\n * Initialize default Composer height.\n */\n initializeHeight() {\n this.state.height = localStorage.getItem('composerHeight');\n if (!this.state.height) {\n this.state.height = this.defaultHeight();\n }\n }\n\n /**\n * Default height of the Composer in case none is saved.\n * @returns {number}\n */\n defaultHeight() {\n return this.$().height();\n }\n\n /**\n * Save a new Composer height and update the DOM.\n * @param {number} height\n */\n changeHeight(height) {\n this.state.height = height;\n this.updateHeight();\n localStorage.setItem('composerHeight', this.state.height);\n }\n}\nflarum.reg.add('core', 'forum/components/Composer', Composer);"],"names":["ComposerButton","Button","static","attrs","super","initAttrs","className","flarum","reg","add","Composer","Component","oninit","vnode","this","state","active","prevPosition","position","view","body","classes","normal","ComposerState","minimized","fullScreen","visible","isVisible","showIfMinimized","show","bind","undefined","ComposerBody","componentClass","m","classList","oncreate","configHandle","listItems","controlItems","toArray","onclick","Object","assign","composer","disabled","onupdate","updateHeight","animatePositionChange","initializeHeight","$","hide","css","computedHeight","on","e","type","redraw","close","handlers","window","onresize","resize","document","onmousemove","onmouseup","onremove","off","dom","preventDefault","mousedown","mouseStart","clientY","heightStart","height","handle","deltaPixels","changeHeight","scrollTop","anchorToBottom","updateBodyPadding","focus","_this$attrs$state$bod","_this$attrs$state$bod2","focusOnSelector","call","first","$flexible","length","headerHeight","offset","top","paddingBottom","parseInt","footerHeight","outerHeight","app","minimize","animateHeightChange","$composer","stop","oldHeight","newHeight","bottom","animation","animate","promise","showBackdrop","$backdrop","addClass","appendTo","hideBackdrop","remove","then","scrollElement","documentElement","topOfViewport","Math","min","scrollHeight","clientHeight","is","items","ItemList","icon","title","exitFullScreen","itemClassName","localStorage","getItem","defaultHeight","setItem"],"sourceRoot":""} \ No newline at end of file diff --git a/framework/core/js/src/admin/components/CreateUserModal.tsx b/framework/core/js/src/admin/components/CreateUserModal.tsx index 98c2cac38..3d358f21f 100644 --- a/framework/core/js/src/admin/components/CreateUserModal.tsx +++ b/framework/core/js/src/admin/components/CreateUserModal.tsx @@ -218,6 +218,8 @@ export default class CreateUserModal { this.bulkAdd(false); diff --git a/framework/core/js/src/admin/components/PermissionGrid.tsx b/framework/core/js/src/admin/components/PermissionGrid.tsx index f117e470f..71999551a 100644 --- a/framework/core/js/src/admin/components/PermissionGrid.tsx +++ b/framework/core/js/src/admin/components/PermissionGrid.tsx @@ -177,7 +177,7 @@ export default class PermissionGrid { if ('setting' in item) { - return item.setting(); + return item.setting?.(); } else if ('permission' in item) { return ; } diff --git a/framework/core/js/src/common/common.ts b/framework/core/js/src/common/common.ts index 0f91304fb..35b5a9245 100644 --- a/framework/core/js/src/common/common.ts +++ b/framework/core/js/src/common/common.ts @@ -72,6 +72,7 @@ import './components/Button'; import './components/Modal'; import './components/FormModal'; import './components/GroupBadge'; +import './components/UserSelectionModal'; import './components/TextEditor'; import './components/TextEditorButton'; import './components/Tooltip'; diff --git a/framework/core/js/src/common/components/Pill.tsx b/framework/core/js/src/common/components/Pill.tsx new file mode 100644 index 000000000..51adc7f28 --- /dev/null +++ b/framework/core/js/src/common/components/Pill.tsx @@ -0,0 +1,20 @@ +import Component, { type ComponentAttrs } from '../Component'; +import type Mithril from 'mithril'; +import Button from './Button'; +import classList from '../utils/classList'; + +export interface IPillAttrs extends ComponentAttrs { + deletable?: boolean; + ondelete?: () => void; +} + +export default class Pill extends Component { + view(vnode: Mithril.Vnode) { + return ( + + {vnode.children} + {this.attrs.deletable && +
    +
    +
    + {this.selected().map((user) => ( + { + const selected = this.selected().filter((u) => u !== user); + this.selected(selected); + + if (!selected.length) { + this.load(); + } + }} + className="Pill-alt" + > + + {user.displayName()} + + ))} +
    + , + + this.loading || this.results()[this.search()] ? ( +
    + {this.loading ? ( + + ) : this.results() && !this.results()[this.search()]?.length ? ( + {app.translator.trans('core.lib.user_selection_modal.empty_results')} + ) : ( +
      {list.map((user) => this.userListItem(user))}
    + )} +
    + ) : null, + ]; + } + + userListItem(user: User) { + const selected = this.selected().includes(user); + + return ( + { + if (selected) { + this.selected(this.selected().filter((u) => u !== user)); + } else { + this.selected([...this.selected(), user]); + } + }} + > + + + ); + } + + meetsRequirements(): boolean { + return this.selected().length > 0 && this.selected().length <= (this.attrs.maxItems || Infinity); + } + + onsubmit(e: SubmitEvent) { + e.preventDefault(); + + if (this.attrs.onsubmit) this.attrs.onsubmit(this.selected()); + + this.hide(); + } + + protected load = throttle(500, () => { + if (this.results()[this.search()]) return; + + if (this.attrs.maxItems && this.selected().length === this.attrs.maxItems) return; + + this.loading = true; + + return app.store + .find('users', { filter: { q: this.search() } }) + .then((results) => { + this.results({ ...this.results(), [this.search()]: results }); + }) + .finally(() => { + this.loading = false; + m.redraw(); + }); + }); +} diff --git a/framework/core/js/src/common/states/PageState.ts b/framework/core/js/src/common/states/PageState.ts index 278eb19d5..366480174 100644 --- a/framework/core/js/src/common/states/PageState.ts +++ b/framework/core/js/src/common/states/PageState.ts @@ -8,6 +8,9 @@ export default class PageState { constructor(type: Function | null, data: any = {}) { this.type = type; + /** + * @type any + */ this.data = data; } diff --git a/framework/core/js/src/common/states/PaginatedListState.ts b/framework/core/js/src/common/states/PaginatedListState.ts index 974a01eb6..015e6fd4b 100644 --- a/framework/core/js/src/common/states/PaginatedListState.ts +++ b/framework/core/js/src/common/states/PaginatedListState.ts @@ -1,6 +1,19 @@ import app from '../../common/app'; import Model from '../Model'; import { ApiQueryParamsPlural, ApiResponsePlural } from '../Store'; +import type Mithril from 'mithril'; +import setRouteWithForcedRefresh from '../utils/setRouteWithForcedRefresh'; + +export type SortMapItem = + | string + | { + sort: string; + label: Mithril.Children; + }; + +export type SortMap = { + [key: string]: SortMapItem; +}; export interface Page { number: number; @@ -274,4 +287,55 @@ export default abstract class PaginatedListState void} callback The callback to run when the scroll position * changes. + * @param {Window|Element} element The element to listen for scroll events on. Defaults to + * `window`. */ - constructor(callback) { + constructor(callback, element = window) { this.callback = callback; this.ticking = false; + this.element = element; } /** @@ -37,23 +40,23 @@ export default class ScrollListener { * Run the callback, whether there was a scroll event or not. */ update() { - this.callback(window.pageYOffset); + this.callback(this.element.pageYOffset); } /** - * Start listening to and handling the window's scroll position. + * Start listening to and handling the element's scroll position. */ start() { if (!this.active) { - window.addEventListener('scroll', (this.active = this.loop.bind(this)), { passive: true }); + this.element.addEventListener('scroll', (this.active = this.loop.bind(this)), { passive: true }); } } /** - * Stop listening to and handling the window's scroll position. + * Stop listening to and handling the element's scroll position. */ stop() { - window.removeEventListener('scroll', this.active); + this.element.removeEventListener('scroll', this.active); this.active = null; } diff --git a/framework/core/js/src/forum/components/AbstractPost.tsx b/framework/core/js/src/forum/components/AbstractPost.tsx new file mode 100644 index 000000000..0b261cea0 --- /dev/null +++ b/framework/core/js/src/forum/components/AbstractPost.tsx @@ -0,0 +1,157 @@ +import app from '../../forum/app'; +import Component, { ComponentAttrs } from '../../common/Component'; +import SubtreeRetainer from '../../common/utils/SubtreeRetainer'; +import Dropdown from '../../common/components/Dropdown'; +import listItems from '../../common/helpers/listItems'; +import ItemList from '../../common/utils/ItemList'; +import LoadingIndicator from '../../common/components/LoadingIndicator'; +import type Mithril from 'mithril'; +import type User from '../../common/models/User'; + +export interface IAbstractPostAttrs extends ComponentAttrs {} + +/** + * This component can be used on any type of model with an author and content. + * Subclasses are specialized for specific types of models. + */ +export default abstract class AbstractPost extends Component { + /** + * May be set by subclasses. + */ + loading = false; + + /** + * Ensures that the post will not be redrawn + * unless new data comes in. + */ + subtree!: SubtreeRetainer; + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + + this.loading = false; + + this.subtree = new SubtreeRetainer( + () => this.loading, + () => this.freshness(), + () => { + const user = this.user(); + return user && user.freshness; + } + ); + } + + view(vnode: Mithril.Vnode) { + const attrs = this.elementAttrs(); + + attrs.className = this.classes(attrs.className as string | undefined).join(' '); + + const controls = this.controls(); + const footerItems = this.footerItems().toArray(); + + return ( +
    + {this.header()} +
    +
    {this.sideItems().toArray()}
    +
    + {this.loading ? : this.content()} + +
    {footerItems.length ?
      {listItems(footerItems)}
    : null}
    +
    +
    +
    + ); + } + + onbeforeupdate(vnode: Mithril.VnodeDOM) { + super.onbeforeupdate(vnode); + + return this.subtree.needsRebuild(); + } + + onupdate(vnode: Mithril.VnodeDOM) { + super.onupdate(vnode); + + const $actions = this.$('.Post-actions'); + const $controls = this.$('.Post-controls'); + + $actions.toggleClass('openWithin', $controls.hasClass('open')); + } + + elementAttrs(): Record { + return {}; + } + + header(): Mithril.Children { + return null; + } + + content(): Mithril.Children[] { + return []; + } + + classes(existing?: string): string[] { + let classes = (existing || '').split(' ').concat(['Post']); + + const user = this.user(); + + if (this.loading) { + classes.push('Post--loading'); + } + + if (user && user === app.session.user) { + classes.push('Post--by-actor'); + } + + if (this.createdByStarter()) { + classes.push('Post--by-start-user'); + } + + return classes; + } + + actionItems(): ItemList { + return new ItemList(); + } + + footerItems(): ItemList { + return new ItemList(); + } + + sideItems(): ItemList { + const items = new ItemList(); + + items.add('avatar', this.avatar(), 100); + + return items; + } + + abstract user(): User | null | false; + abstract controls(): Mithril.Children[]; + abstract freshness(): Date; + abstract createdByStarter(): boolean; + + avatar(): Mithril.Children { + return null; + } +} diff --git a/framework/core/js/src/forum/components/Comment.tsx b/framework/core/js/src/forum/components/Comment.tsx new file mode 100644 index 000000000..8309ee194 --- /dev/null +++ b/framework/core/js/src/forum/components/Comment.tsx @@ -0,0 +1,51 @@ +import Component, { type ComponentAttrs } from '../../common/Component'; +import type Mithril from 'mithril'; +import listItems from '../../common/helpers/listItems'; +import UserCard from './UserCard'; +import ComposerPostPreview from './ComposerPostPreview'; +import app from '../app'; +import type ItemList from '../../common/utils/ItemList'; +import type User from '../../common/models/User'; +import escapeRegExp from '../../common/utils/escapeRegExp'; +import highlight from '../../common/helpers/highlight'; + +export interface ICommentAttrs extends ComponentAttrs { + headerItems: ItemList; + user: User | false | undefined; + cardVisible: boolean; + isEditing: boolean; + isHidden: boolean; + contentHtml: string; + search?: string; +} + +export default class Comment extends Component { + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + } + + view() { + let contentHtml: any = this.attrs.isEditing ? '' : this.attrs.contentHtml; + + if (!this.attrs.isEditing && this.attrs.search) { + const phrase = escapeRegExp(this.attrs.search); + const highlightRegExp = new RegExp(phrase + '|' + phrase.trim().replace(/\s+/g, '|'), 'gi'); + contentHtml = highlight(contentHtml, highlightRegExp, undefined, true); + } else { + contentHtml = m.trust(contentHtml); + } + + return [ +
    +
      {listItems(this.attrs.headerItems.toArray())}
    + + {!this.attrs.isHidden && this.attrs.cardVisible && ( + + )} +
    , +
    + {this.attrs.isEditing ? : contentHtml} +
    , + ]; + } +} diff --git a/framework/core/js/src/forum/components/CommentPost.js b/framework/core/js/src/forum/components/CommentPost.js index 364ecbda9..498f531eb 100644 --- a/framework/core/js/src/forum/components/CommentPost.js +++ b/framework/core/js/src/forum/components/CommentPost.js @@ -5,14 +5,12 @@ import PostUser from './PostUser'; import PostMeta from './PostMeta'; import PostEdited from './PostEdited'; import ItemList from '../../common/utils/ItemList'; -import listItems from '../../common/helpers/listItems'; import Button from '../../common/components/Button'; -import ComposerPostPreview from './ComposerPostPreview'; import Link from '../../common/components/Link'; -import UserCard from './UserCard'; import Avatar from '../../common/components/Avatar'; import escapeRegExp from '../../common/utils/escapeRegExp'; import highlight from '../../common/helpers/highlight'; +import Comment from './Comment'; /** * The `CommentPost` component displays a standard `comment`-typed post. This @@ -62,26 +60,19 @@ export default class CommentPost extends Post { } content() { - let contentHtml = this.isEditing() ? '' : this.attrs.post.contentHtml(); - - if (!this.isEditing() && this.attrs.params?.q) { - const phrase = escapeRegExp(this.attrs.params.q); - const highlightRegExp = new RegExp(phrase + '|' + phrase.trim().replace(/\s+/g, '|'), 'gi'); - contentHtml = highlight(contentHtml, highlightRegExp, undefined, true); - } else { - contentHtml = m.trust(contentHtml); - } - - return super.content().concat([ -
    -
      {listItems(this.headerItems().toArray())}
    - - {!this.attrs.post.isHidden() && this.cardVisible && ( - - )} -
    , -
    {this.isEditing() ? : contentHtml}
    , - ]); + return super + .content() + .concat([ + , + ]); } refreshContent() { diff --git a/framework/core/js/src/forum/components/Composer.js b/framework/core/js/src/forum/components/Composer.js index 90cd08d94..94c6a1686 100644 --- a/framework/core/js/src/forum/components/Composer.js +++ b/framework/core/js/src/forum/components/Composer.js @@ -164,7 +164,9 @@ export default class Composer extends Component { * Draw focus to the first focusable content element (the text editor). */ focus() { - this.$('.Composer-content :input:enabled:visible, .TextEditor-editor').first().focus(); + this.$(this.attrs.state.body.componentClass.focusOnSelector?.() || '.Composer-content :input:enabled:visible, .TextEditor-editor') + .first() + .focus(); } /** diff --git a/framework/core/js/src/forum/components/ComposerBody.js b/framework/core/js/src/forum/components/ComposerBody.tsx similarity index 67% rename from framework/core/js/src/forum/components/ComposerBody.js rename to framework/core/js/src/forum/components/ComposerBody.tsx index 337217f1a..5789d7640 100644 --- a/framework/core/js/src/forum/components/ComposerBody.js +++ b/framework/core/js/src/forum/components/ComposerBody.tsx @@ -1,4 +1,4 @@ -import Component from '../../common/Component'; +import Component, { type ComponentAttrs } from '../../common/Component'; import LoadingIndicator from '../../common/components/LoadingIndicator'; import ConfirmDocumentUnload from '../../common/components/ConfirmDocumentUnload'; import TextEditor from '../../common/components/TextEditor'; @@ -6,37 +6,36 @@ import listItems from '../../common/helpers/listItems'; import ItemList from '../../common/utils/ItemList'; import classList from '../../common/utils/classList'; import Avatar from '../../common/components/Avatar'; +import ComposerState from '../states/ComposerState'; +import type Mithril from 'mithril'; + +export interface IComposerBodyAttrs extends ComponentAttrs { + composer: ComposerState; + originalContent?: string; + submitLabel: string; + placeholder: string; + user: any; + confirmExit: string; + disabled: boolean; +} /** * The `ComposerBody` component handles the body, or the content, of the * composer. Subclasses should implement the `onsubmit` method and override * `headerTimes`. - * - * ### Attrs - * - * - `composer` - * - `originalContent` - * - `submitLabel` - * - `placeholder` - * - `user` - * - `confirmExit` - * - `disabled` - * - * @abstract */ -export default class ComposerBody extends Component { - oninit(vnode) { +export default abstract class ComposerBody extends Component { + protected loading = false; + protected composer!: ComposerState; + protected jumpToPreview?: () => void; + + static focusOnSelector: null | (() => string) = null; + + oninit(vnode: Mithril.Vnode) { super.oninit(vnode); this.composer = this.attrs.composer; - /** - * Whether or not the component is loading. - * - * @type {Boolean} - */ - this.loading = false; - // Let the composer state know to ask for confirmation under certain // circumstances, if the body supports / requires it and has a corresponding // confirmation question to ask. @@ -44,7 +43,7 @@ export default class ComposerBody extends Component { this.composer.preventClosingWhen(() => this.hasChanges(), this.attrs.confirmExit); } - this.composer.fields.content(this.attrs.originalContent || ''); + this.composer.fields!.content(this.attrs.originalContent || ''); } view() { @@ -61,9 +60,9 @@ export default class ComposerBody extends Component { disabled={this.loading || this.attrs.disabled} composer={this.composer} preview={this.jumpToPreview?.bind(this)} - onchange={this.composer.fields.content} + onchange={this.composer.fields!.content} onsubmit={this.onsubmit.bind(this)} - value={this.composer.fields.content()} + value={this.composer.fields!.content()} /> @@ -75,30 +74,24 @@ export default class ComposerBody extends Component { /** * Check if there is any unsaved data. - * - * @return {boolean} */ - hasChanges() { - const content = this.composer.fields.content(); + hasChanges(): boolean { + const content = this.composer.fields!.content(); - return content && content !== this.attrs.originalContent; + return Boolean(content) && content !== this.attrs.originalContent; } /** * Build an item list for the composer's header. - * - * @return {ItemList} */ headerItems() { - return new ItemList(); + return new ItemList(); } /** * Handle the submit event of the text editor. - * - * @abstract */ - onsubmit() {} + abstract onsubmit(): void; /** * Stop loading. diff --git a/framework/core/js/src/forum/components/HeaderDropdown.tsx b/framework/core/js/src/forum/components/HeaderDropdown.tsx index 2a4f77336..a43ec1abc 100644 --- a/framework/core/js/src/forum/components/HeaderDropdown.tsx +++ b/framework/core/js/src/forum/components/HeaderDropdown.tsx @@ -43,7 +43,7 @@ export default abstract class HeaderDropdown : null, - unread !== 0 && {unread}, + unread !== 0 && {unread}, {this.attrs.label}, ]; } diff --git a/framework/core/js/src/forum/components/HeaderList.tsx b/framework/core/js/src/forum/components/HeaderList.tsx index 3f41f10eb..6c894db7a 100644 --- a/framework/core/js/src/forum/components/HeaderList.tsx +++ b/framework/core/js/src/forum/components/HeaderList.tsx @@ -12,6 +12,7 @@ export interface IHeaderListAttrs extends ComponentAttrs { loading?: boolean; emptyText: string; loadMore?: () => void; + footer?: () => Mithril.Children; } export default class HeaderList extends Component { @@ -20,7 +21,7 @@ export default class HeaderList void) | null = null; view(vnode: Mithril.Vnode) { - const { title, controls, hasItems, loading = false, emptyText, className, ...attrs } = vnode.attrs; + const { title, controls, hasItems, loading = false, emptyText, className, footer, ...attrs } = vnode.attrs; return (
    @@ -37,6 +38,7 @@ export default class HeaderList{emptyText}
    )} + {!!footer &&
    {footer()}
    } ); } diff --git a/framework/core/js/src/forum/components/IndexPage.tsx b/framework/core/js/src/forum/components/IndexPage.tsx index bd446fa62..992207779 100644 --- a/framework/core/js/src/forum/components/IndexPage.tsx +++ b/framework/core/js/src/forum/components/IndexPage.tsx @@ -157,7 +157,8 @@ export default class IndexPage { - acc[sortId] = app.translator.trans(`core.forum.index_sort.${sortId}_button`); + const sort = sortMap[sortId]; + acc[sortId] = typeof sort !== 'string' ? sort.label : app.translator.trans(`core.forum.index_sort.${sortId}_button`); return acc; }, {}); diff --git a/framework/core/js/src/forum/components/LoadingPost.js b/framework/core/js/src/forum/components/LoadingPost.tsx similarity index 58% rename from framework/core/js/src/forum/components/LoadingPost.js rename to framework/core/js/src/forum/components/LoadingPost.tsx index 9e127e415..5a2188d9d 100644 --- a/framework/core/js/src/forum/components/LoadingPost.js +++ b/framework/core/js/src/forum/components/LoadingPost.tsx @@ -1,15 +1,17 @@ -import Component from '../../common/Component'; - +import Component, { type ComponentAttrs } from '../../common/Component'; import Avatar from '../../common/components/Avatar'; +import classList from '../../common/utils/classList'; + +export interface ILoadingPostAttrs extends ComponentAttrs {} /** * The `LoadingPost` component shows a placeholder that looks like a post, * indicating that the post is loading. */ -export default class LoadingPost extends Component { +export default class LoadingPost extends Component { view() { return ( -
    +
    diff --git a/framework/core/js/src/forum/components/Post.tsx b/framework/core/js/src/forum/components/Post.tsx index 02c53b22b..69082728d 100644 --- a/framework/core/js/src/forum/components/Post.tsx +++ b/framework/core/js/src/forum/components/Post.tsx @@ -1,15 +1,12 @@ import app from '../../forum/app'; -import Component, { ComponentAttrs } from '../../common/Component'; -import SubtreeRetainer from '../../common/utils/SubtreeRetainer'; -import Dropdown from '../../common/components/Dropdown'; import PostControls from '../utils/PostControls'; -import listItems from '../../common/helpers/listItems'; import ItemList from '../../common/utils/ItemList'; import type PostModel from '../../common/models/Post'; -import LoadingIndicator from '../../common/components/LoadingIndicator'; -import type Mithril from 'mithril'; +import Mithril from 'mithril'; +import AbstractPost, { type IAbstractPostAttrs } from './AbstractPost'; +import type User from '../../common/models/User'; -export interface IPostAttrs extends ComponentAttrs { +export interface IPostAttrs extends IAbstractPostAttrs { post: PostModel; } @@ -18,125 +15,65 @@ export interface IPostAttrs extends ComponentAttrs { * includes a controls dropdown; subclasses must implement `content` and `attrs` * methods. */ -export default abstract class Post extends Component { - /** - * May be set by subclasses. - */ - loading = false; - - /** - * Ensures that the post will not be redrawn - * unless new data comes in. - */ - subtree!: SubtreeRetainer; - +export default abstract class Post extends AbstractPost { oninit(vnode: Mithril.Vnode) { super.oninit(vnode); - - this.loading = false; - - this.subtree = new SubtreeRetainer( - () => this.loading, - () => this.attrs.post.freshness, - () => { - const user = this.attrs.post.user(); - return user && user.freshness; - } - ); } - view(vnode: Mithril.Vnode) { - const attrs = this.elementAttrs(); + user(): User | null | false { + return this.attrs.post.user(); + } - attrs.className = this.classes(attrs.className as string | undefined).join(' '); + controls(): Mithril.Children[] { + return PostControls.controls(this.attrs.post, this).toArray(); + } - const controls = PostControls.controls(this.attrs.post, this).toArray(); - const footerItems = this.footerItems().toArray(); + freshness(): Date { + return this.attrs.post.freshness; + } - return ( -
    - {this.header()} -
    -
    {this.sideItems().toArray()}
    -
    - {this.loading ? : this.content()} - -
    {footerItems.length ?
      {listItems(footerItems)}
    : null}
    -
    -
    -
    - ); + createdByStarter(): boolean { + const user = this.attrs.post.user(); + const discussion = this.attrs.post.discussion(); + + return user && user === discussion.user(); } onbeforeupdate(vnode: Mithril.VnodeDOM) { - super.onbeforeupdate(vnode); - - return this.subtree.needsRebuild(); + return super.onbeforeupdate(vnode); } onupdate(vnode: Mithril.VnodeDOM) { super.onupdate(vnode); - - const $actions = this.$('.Post-actions'); - const $controls = this.$('.Post-controls'); - - $actions.toggleClass('openWithin', $controls.hasClass('open')); } /** * Get attributes for the post element. */ elementAttrs(): Record { - return {}; + return super.elementAttrs(); } header(): Mithril.Children { - return null; + return super.header(); } /** * Get the post's content. */ - content(): Mithril.Children { - return []; + content(): Mithril.Children[] { + return super.content(); } /** * Get the post's classes. */ classes(existing?: string): string[] { - let classes = (existing || '').split(' ').concat(['Post']); + let classes = super.classes(existing); const user = this.attrs.post.user(); const discussion = this.attrs.post.discussion(); - if (this.loading) { - classes.push('Post--loading'); - } - - if (user && user === app.session.user) { - classes.push('Post--by-actor'); - } - if (user && user === discussion.user()) { classes.push('Post--by-start-user'); } @@ -148,25 +85,21 @@ export default abstract class Post * Build an item list for the post's actions. */ actionItems(): ItemList { - return new ItemList(); + return super.actionItems(); } /** * Build an item list for the post's footer. */ footerItems(): ItemList { - return new ItemList(); + return super.footerItems(); } sideItems(): ItemList { - const items = new ItemList(); - - items.add('avatar', this.avatar(), 100); - - return items; + return super.sideItems(); } avatar(): Mithril.Children { - return null; + return super.avatar(); } } diff --git a/framework/core/js/src/forum/components/PostMeta.js b/framework/core/js/src/forum/components/PostMeta.js deleted file mode 100644 index 892f54fcd..000000000 --- a/framework/core/js/src/forum/components/PostMeta.js +++ /dev/null @@ -1,61 +0,0 @@ -import app from '../../forum/app'; -import Component from '../../common/Component'; -import humanTime from '../../common/helpers/humanTime'; -import fullTime from '../../common/helpers/fullTime'; -import Button from '../../common/components/Button'; - -/** - * The `PostMeta` component displays the time of a post, and when clicked, shows - * a dropdown containing more information about the post (number, full time, - * permalink). - * - * ### Attrs - * - * - `post` - */ -export default class PostMeta extends Component { - view() { - const post = this.attrs.post; - const time = post.createdAt(); - const permalink = this.getPermalink(post); - const touch = 'ontouchstart' in document.documentElement; - - // When the dropdown menu is shown, select the contents of the permalink - // input so that the user can quickly copy the URL. - const selectPermalink = function (e) { - setTimeout(() => $(this).parent().find('.PostMeta-permalink').select()); - - e.redraw = false; - }; - - return ( -
    - - -
    - {app.translator.trans('core.forum.post.number_tooltip', { number: post.number() })}{' '} - {fullTime(time)} {post.data.attributes.ipAddress} - {touch ? ( - - {permalink} - - ) : ( - e.stopPropagation()} /> - )} -
    -
    - ); - } - - /** - * Get the permalink for the given post. - * - * @param {import('../../common/models/Post').default} post - * @returns {string} - */ - getPermalink(post) { - return app.forum.attribute('baseOrigin') + app.route.post(post); - } -} diff --git a/framework/core/js/src/forum/components/PostMeta.tsx b/framework/core/js/src/forum/components/PostMeta.tsx new file mode 100644 index 000000000..3e6d9096d --- /dev/null +++ b/framework/core/js/src/forum/components/PostMeta.tsx @@ -0,0 +1,86 @@ +import app from '../../forum/app'; +import Component, { type ComponentAttrs } from '../../common/Component'; +import humanTime from '../../common/helpers/humanTime'; +import fullTime from '../../common/helpers/fullTime'; +import Post from '../../common/models/Post'; +import type Model from '../../common/Model'; +import type User from '../../common/models/User'; +import classList from '../../common/utils/classList'; + +type ModelType = Post | (Model & { user: () => User | null | false; createdAt: () => Date }); + +export interface IPostMetaAttrs extends ComponentAttrs { + /** Can be a post or similar model like private message */ + post: ModelType; + permalink?: () => string; +} + +/** + * The `PostMeta` component displays the time of a post, and when clicked, shows + * a dropdown containing more information about the post (number, full time, + * permalink). + */ +export default class PostMeta extends Component { + view() { + const post = this.attrs.post; + const time = post.createdAt(); + const permalink = this.getPermalink(post); + const touch = 'ontouchstart' in document.documentElement; + + // When the dropdown menu is shown, select the contents of the permalink + // input so that the user can quickly copy the URL. + const selectPermalink = function (this: Element, e: MouseEvent) { + setTimeout(() => $(this).parent().find('.PostMeta-permalink').select()); + + e.redraw = false; + }; + + return ( +
    + + + {!!permalink && ( +
    + {this.postIdentifier(post)} {fullTime(time)}{' '} + {post.data.attributes!.ipAddress} + {touch ? ( + + {permalink} + + ) : ( + e.stopPropagation()} /> + )} +
    + )} +
    + ); + } + + /** + * Get the permalink for the given post. + */ + getPermalink(post: ModelType): null | string { + if (post instanceof Post) { + return app.forum.attribute('baseOrigin') + app.route.post(post); + } + + return this.attrs.permalink?.() || null; + } + + postIdentifier(post: ModelType): string | null { + if (post instanceof Post) { + return app.translator.trans('core.forum.post.number_tooltip', { number: post.number() }, true); + } + + return null; + } +} diff --git a/framework/core/js/src/forum/components/PostUser.js b/framework/core/js/src/forum/components/PostUser.tsx similarity index 68% rename from framework/core/js/src/forum/components/PostUser.js rename to framework/core/js/src/forum/components/PostUser.tsx index 8c0f9dd00..2a091c8da 100644 --- a/framework/core/js/src/forum/components/PostUser.js +++ b/framework/core/js/src/forum/components/PostUser.tsx @@ -1,20 +1,23 @@ import app from '../../forum/app'; -import Component from '../../common/Component'; +import Component, { type ComponentAttrs } from '../../common/Component'; import Link from '../../common/components/Link'; -import UserCard from './UserCard'; import username from '../../common/helpers/username'; import userOnline from '../../common/helpers/userOnline'; import listItems from '../../common/helpers/listItems'; import Avatar from '../../common/components/Avatar'; +import type Model from '../../common/Model'; +import type Post from '../../common/models/Post'; +import type User from '../../common/models/User'; + +export interface IPostUserAttrs extends ComponentAttrs { + /** Can be a post or similar model like private message */ + post: Post | (Model & { user: () => User | null | false }); +} /** * The `PostUser` component shows the avatar and username of a post's author. - * - * ### Attrs - * - * - `post` */ -export default class PostUser extends Component { +export default class PostUser extends Component { view() { const post = this.attrs.post; const user = post.user(); diff --git a/framework/core/js/src/forum/components/ReplyPlaceholder.js b/framework/core/js/src/forum/components/ReplyPlaceholder.tsx similarity index 62% rename from framework/core/js/src/forum/components/ReplyPlaceholder.js rename to framework/core/js/src/forum/components/ReplyPlaceholder.tsx index 1f13f4b47..797b781fa 100644 --- a/framework/core/js/src/forum/components/ReplyPlaceholder.js +++ b/framework/core/js/src/forum/components/ReplyPlaceholder.tsx @@ -1,22 +1,30 @@ import app from '../../forum/app'; -import Component from '../../common/Component'; +import Component, { type ComponentAttrs } from '../../common/Component'; import username from '../../common/helpers/username'; import DiscussionControls from '../utils/DiscussionControls'; import ComposerPostPreview from './ComposerPostPreview'; import listItems from '../../common/helpers/listItems'; import Avatar from '../../common/components/Avatar'; +import type Discussion from '../../common/models/Discussion'; +import type Model from '../../common/Model'; + +export interface IReplyPlaceholderAttrs extends ComponentAttrs { + discussion: Discussion | Model; + onclick?: () => void; + composingReply?: () => boolean; +} /** * The `ReplyPlaceholder` component displays a placeholder for a reply, which, * when clicked, opens the reply composer. - * - * ### Attrs - * - * - `discussion` */ -export default class ReplyPlaceholder extends Component { +export default class ReplyPlaceholder extends Component { view() { - if (app.composer.composingReplyTo(this.attrs.discussion)) { + const composingReply = this.attrs.composingReply + ? this.attrs.composingReply() + : app.composer.composingReplyTo(this.attrs.discussion as Discussion); + + if (composingReply) { return (
    @@ -27,7 +35,7 @@ export default class ReplyPlaceholder extends Component {

    {username(app.session.user)}

    -
      {listItems(app.session.user.badges().toArray())}
    +
      {listItems(app.session.user!.badges().toArray())}
    @@ -39,9 +47,11 @@ export default class ReplyPlaceholder extends Component { ); } - const reply = () => { - DiscussionControls.replyAction.call(this.attrs.discussion, true).catch(() => {}); - }; + const reply = + this.attrs.onclick || + (() => { + DiscussionControls.replyAction.call(this.attrs.discussion, true, false).catch(() => {}); + }); return ( : {this.content(vnode)}} + + ); + } + + content(vnode: Mithril.Vnode) { + const user = this.attrs.user; + const query = this.attrs.query; + const name = username(user, (name: string) => highlight(name, query)); + + return ( + <> + +
    + {name} +
    {listItems(user.badges().toArray())}
    +
    + {vnode.children} + + ); + } +} diff --git a/framework/core/js/src/forum/components/UsersSearchSource.tsx b/framework/core/js/src/forum/components/UsersSearchSource.tsx index e4ba692be..398e450b8 100644 --- a/framework/core/js/src/forum/components/UsersSearchSource.tsx +++ b/framework/core/js/src/forum/components/UsersSearchSource.tsx @@ -9,6 +9,7 @@ import Avatar from '../../common/components/Avatar'; import type { SearchSource } from './Search'; import extractText from '../../common/utils/extractText'; import listItems from '../../common/helpers/listItems'; +import UserSearchResult from './UserSearchResult'; /** * The `UsersSearchSource` finds and displays user search results in the search @@ -53,21 +54,7 @@ export default class UsersSearchSource implements SearchSource { if (!results.length) return []; - return results.map((user) => { - const name = username(user, (name: string) => highlight(name, query)); - - return ( -
  • - - -
    - {name} -
    {listItems(user.badges().toArray())}
    -
    - -
  • - ); - }); + return results.map((user) => ); } customGrouping(): boolean { diff --git a/framework/core/js/src/forum/forum.ts b/framework/core/js/src/forum/forum.ts index 09fc50286..1fe1a4381 100644 --- a/framework/core/js/src/forum/forum.ts +++ b/framework/core/js/src/forum/forum.ts @@ -27,6 +27,7 @@ import './components/HeaderSecondary'; import './components/DiscussionList'; import './components/AvatarEditor'; import './components/Post'; +import './components/LoadingPost'; import './components/TerminalPost'; import './components/NotificationsDropdown'; import './components/UserPage'; diff --git a/framework/core/js/src/forum/states/ComposerState.js b/framework/core/js/src/forum/states/ComposerState.tsx similarity index 74% rename from framework/core/js/src/forum/states/ComposerState.js rename to framework/core/js/src/forum/states/ComposerState.tsx index 0751aac04..ace876cbb 100644 --- a/framework/core/js/src/forum/states/ComposerState.js +++ b/framework/core/js/src/forum/states/ComposerState.tsx @@ -2,55 +2,60 @@ import app from '../../forum/app'; import subclassOf from '../../common/utils/subclassOf'; import Stream from '../../common/utils/Stream'; import Component from '../../common/Component'; +import type EditorDriverInterface from '../../common/utils/EditorDriverInterface'; +import type ComposerBody from '../components/ComposerBody'; +import type Discussion from '../../common/models/Discussion'; class ComposerState { + static Position = { + HIDDEN: 'hidden', + NORMAL: 'normal', + MINIMIZED: 'minimized', + FULLSCREEN: 'fullScreen', + }; + + /** + * The composer's current position. + */ + position = ComposerState.Position.HIDDEN; + + /** + * The composer's intended height, which can be modified by the user + * (by dragging the composer handle). + */ + height: number | null = null; + + /** + * The dynamic component being shown inside the composer. + */ + body: any = { attrs: {} }; + + /** + * A reference to the text editor that allows text manipulation. + */ + editor: EditorDriverInterface | null = null; + + /** + * If the composer was loaded and mounted. + */ + mounted: boolean = false; + + protected onExit: { callback: () => boolean; message: string } | null = null; + + /** + * Fields of the composer. + */ + public fields!: Record> & { content: Stream }; + constructor() { - /** - * The composer's current position. - * - * @type {ComposerState.Position} - */ - this.position = ComposerState.Position.HIDDEN; - - /** - * The composer's intended height, which can be modified by the user - * (by dragging the composer handle). - * - * @type {number} - */ - this.height = null; - - /** - * The dynamic component being shown inside the composer. - * - * @type {Object} - */ - this.body = { attrs: {} }; - - /** - * A reference to the text editor that allows text manipulation. - * - * @type {import('../../common/utils/EditorDriverInterface')|null} - */ - this.editor = null; - - /** - * If the composer was loaded and mounted. - * - * @type {boolean} - */ - this.mounted = false; - this.clear(); } /** * Load a content component into the composer. * - * @param {() => Promise | typeof import('../components/ComposerBody').default} componentClass - * @param {object} attrs */ - async load(componentClass, attrs) { + async load(componentClass: () => Promise | ComposerBody, attrs: object) { if (!(componentClass.prototype instanceof Component)) { componentClass = (await componentClass()).default; } @@ -98,7 +103,7 @@ class ComposerState { async show() { if (!this.mounted) { const Composer = (await import('../components/Composer')).default; - m.mount(document.getElementById('composer'), { view: () => }); + m.mount(document.getElementById('composer')!, { view: () => }); this.mounted = true; } @@ -160,11 +165,10 @@ class ComposerState { /** * Determine whether the body matches the given component class and data. * - * @param {object} type The component class to check against. Subclasses are accepted as well. - * @param {object} data - * @return {boolean} + * @param type The component class to check against. Subclasses are accepted as well. + * @param data */ - bodyMatches(type, data = {}) { + bodyMatches(type: object, data: any = {}): boolean { // Fail early when the body is of a different type if (!subclassOf(this.body.componentClass, type)) return false; @@ -204,7 +208,7 @@ class ComposerState { * @param {import('../../common/models/Discussion').default} discussion * @return {boolean} */ - composingReplyTo(discussion) { + composingReplyTo(discussion: Discussion) { const ReplyComposer = flarum.reg.checkModule('core', 'forum/components/ReplyComposer'); if (!ReplyComposer) return false; @@ -216,10 +220,11 @@ class ComposerState { * Confirm with the user that they want to close the composer and lose their * content. * - * @return {boolean} Whether or not the exit was cancelled. + * @return Whether or not the exit was cancelled. */ - preventExit() { + preventExit(): boolean | void { if (!this.isVisible()) return; + if (!this.onExit) return; if (this.onExit.callback()) { @@ -233,11 +238,8 @@ class ComposerState { * The provided callback will be used to determine whether asking for * confirmation is necessary. If the callback returns true at the time of * closing, the provided text will be shown in a standard confirmation dialog. - * - * @param {() => boolean} callback - * @param {string} message */ - preventClosingWhen(callback, message) { + preventClosingWhen(callback: () => boolean, message: string) { this.onExit = { callback, message }; } @@ -250,40 +252,31 @@ class ComposerState { } /** - * Maxmimum height of the Composer. - * @returns {number} + * Maximum height of the Composer. */ - maximumHeight() { - return $(window).height() - $('#header').outerHeight(); + maximumHeight(): number { + return $(window).height()! - $('#header').outerHeight()!; } /** * Computed the composer's current height, based on the intended height, and * the composer's current state. This will be applied to the composer * content's DOM element. - * @returns {number | string} */ - computedHeight() { + computedHeight(): number | string { // If the composer is minimized, then we don't want to set a height; we'll // let the CSS decide how high it is. If it's fullscreen, then we need to // make it as high as the window. if (this.position === ComposerState.Position.MINIMIZED) { return ''; } else if (this.position === ComposerState.Position.FULLSCREEN) { - return $(window).height(); + return $(window).height()!; } // Otherwise, if it's normal or hidden, then we use the intended height. // We don't let the composer get too small or too big, though. - return Math.max(this.minimumHeight(), Math.min(this.height, this.maximumHeight())); + return Math.max(this.minimumHeight(), Math.min(this.height || 0, this.maximumHeight())); } } -ComposerState.Position = { - HIDDEN: 'hidden', - NORMAL: 'normal', - MINIMIZED: 'minimized', - FULLSCREEN: 'fullScreen', -}; - export default ComposerState; diff --git a/framework/core/js/src/forum/states/DiscussionListState.ts b/framework/core/js/src/forum/states/DiscussionListState.ts index be1a88df8..f53f2b751 100644 --- a/framework/core/js/src/forum/states/DiscussionListState.ts +++ b/framework/core/js/src/forum/states/DiscussionListState.ts @@ -1,5 +1,5 @@ import app from '../../forum/app'; -import PaginatedListState, { Page, PaginatedListParams, PaginatedListRequestParams } from '../../common/states/PaginatedListState'; +import PaginatedListState, { Page, PaginatedListParams, PaginatedListRequestParams, type SortMap } from '../../common/states/PaginatedListState'; import Discussion from '../../common/models/Discussion'; import { ApiResponsePlural } from '../../common/Store'; import EventEmitter from '../../common/utils/EventEmitter'; @@ -28,7 +28,7 @@ export default class DiscussionListState

    * { + padding: 6px 8px; + background-color: var(--pill-bg); + color: var(--pill-color); + border-radius: 0; + line-height: 20px; + } + + img { + max-height: 16px; + } + + .Avatar { + --size: 16px; + } + + &-list { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + } +} diff --git a/framework/core/less/common/Search.less b/framework/core/less/common/Search.less index 522f5efa5..5daa435f2 100644 --- a/framework/core/less/common/Search.less +++ b/framework/core/less/common/Search.less @@ -35,6 +35,9 @@ .username { font-size: 15px; } + &-name { + flex-grow: 1; + } } .SearchModal { diff --git a/framework/core/less/common/UserSelectionModal.less b/framework/core/less/common/UserSelectionModal.less new file mode 100644 index 000000000..fa25a3946 --- /dev/null +++ b/framework/core/less/common/UserSelectionModal.less @@ -0,0 +1,31 @@ +.UserSelectionModal-form { + display: flex; + align-items: center; + gap: 4px; + width: 100%; + + &-input { + flex-grow: 1; + } +} + +.UserSelectionModal { + .Dropdown--expanded(); + + .UserSearchResult { + margin: 0; + + > * { + padding: 12px 20px; + } + } + + &-selected { + margin-top: 8px; + } + + &-list { + overflow: auto; + max-height: 50vh; + } +} diff --git a/framework/core/less/common/common.less b/framework/core/less/common/common.less index 78cd44e93..5063fe100 100644 --- a/framework/core/less/common/common.less +++ b/framework/core/less/common/common.less @@ -25,6 +25,7 @@ @import "LoadingIndicator"; @import "Modal"; @import "Navigation"; +@import "Pill"; @import "Placeholder"; @import "Search"; @import "Select"; @@ -32,4 +33,5 @@ @import "TextEditor"; @import "ThemeMode"; @import "Tooltip"; +@import "UserSelectionModal"; @import "ValidationError"; diff --git a/framework/core/less/common/root.less b/framework/core/less/common/root.less index f76ee6c40..38ff85826 100644 --- a/framework/core/less/common/root.less +++ b/framework/core/less/common/root.less @@ -45,7 +45,6 @@ --control-danger-bg: @@control-danger-bg; --control-danger-color: @@control-danger-color; --control-body-bg-mix: mix(@@control-bg, @@body-bg, 50%); - --control-muted-color: lighten(@@control-color, 40%); // --------------------------------- // COMPONENT COLORS @@ -83,6 +82,8 @@ [data-theme^=light] { .scheme(light); + --control-muted-color: lighten(@control-color-light, 40%); + --search-gambit: var(--control-bg-shaded); // --------------------------------- @@ -117,6 +118,8 @@ [data-theme^=dark] { .scheme(dark); + --control-muted-color: darken(@control-color-dark, 25%); + --search-gambit: var(--control-bg-light); // --------------------------------- @@ -213,6 +216,15 @@ --hero-bg: var(--control-bg); --hero-color: var(--control-color); + --pill-bg: var(--control-bg); + --pill-color: var(--control-color); + --pill-bg-alt: var(--body-bg); + --pill-color-alt: var(--text-color); + + --bubble-bg: var(--control-color); + --bubble-color: var(--body-bg); + --bubble-shadow-color: var(--body-bg); + --highlight-color: #FFE300; .light-contents-vars(); diff --git a/framework/core/less/forum/HeaderDropdown.less b/framework/core/less/forum/HeaderDropdown.less index 5859f5617..6de32e810 100644 --- a/framework/core/less/forum/HeaderDropdown.less +++ b/framework/core/less/forum/HeaderDropdown.less @@ -26,21 +26,10 @@ color: var(--header-color); } -.HeaderDropdown-unread { - position: absolute; - top: 2px; - left: 18px; - background: var(--header-control-color); - color: var(--header-bg); - font-size: 11px; - font-weight: bold; - padding: 2px 4px 3px; - line-height: 1em; - border-radius: 10px; - box-shadow: 0 0 0 1px var(--header-bg); - min-width: 16px; - height: 16px; - text-align: center; +.HeaderDropdownBubble { + --bubble-bg: var(--header-control-color); + --bubble-color: var(--header-bg); + --bubble-shadow-color: var(--header-bg); @media @phone { left: 20px; @@ -50,3 +39,27 @@ background: var(--header-color); } } + +.Bubble { + position: absolute; + top: 2px; + left: 18px; + background: var(--bubble-bg); + color: var(--bubble-color); + font-size: 11px; + font-weight: bold; + padding: 2px 4px 3px; + line-height: 1em; + border-radius: 10px; + box-shadow: 0 0 0 1px var(--bubble-shadow-color); + min-width: 16px; + height: 16px; + text-align: center; + display: flex; + align-items: center; + justify-content: center; + + &--primary { + --bubble-bg: var(--primary-color); + } +} diff --git a/framework/core/less/forum/HeaderList.less b/framework/core/less/forum/HeaderList.less index 54dc58b19..a2585fd26 100644 --- a/framework/core/less/forum/HeaderList.less +++ b/framework/core/less/forum/HeaderList.less @@ -5,7 +5,7 @@ margin-right: 12px; } - &-header { + &-header, &-footer { @media @tablet-up { padding: 12px 15px; border-bottom: 1px solid var(--control-bg); @@ -50,6 +50,13 @@ } } + &-footer { + @media @tablet-up { + border-bottom: none; + border-top: 1px solid var(--control-bg); + } + } + // Message displayed when notifications are empty &-empty { color: var(--muted-color); @@ -107,7 +114,46 @@ } } +.CompactActions { + &:hover &-actions, + &:focus &-actions, + &:focus-within &-actions { + > .Button { + opacity: 1; + } + } + + &-actions { + grid-area: actions; + + > .Button { + line-height: inherit; + padding: 0; + opacity: 0; + + .add-keyboard-focus-ring(); + .add-keyboard-focus-ring-offset(4px); + + // Needs more specificity to fix hover/focus styles not applying in dropdown + [data-colored-header=true] .HeaderListItem & { + color: var(--control-color); + + &:hover, + &:focus { + color: var(--link-color); + } + } + + .icon { + font-size: 13px; + } + } + } +} + .HeaderListItem { + .CompactActions(); + padding: 8px 16px; color: var(--muted-color) !important; // required to override .light-contents applied to header overflow: hidden; @@ -135,10 +181,6 @@ &:focus-within { text-decoration: none; background: var(--control-bg); - - .HeaderListItem-actions > .Button { - opacity: 1; - } } .Avatar { @@ -184,33 +226,6 @@ text-transform: uppercase; } - &-actions { - grid-area: actions; - - > .Button { - line-height: inherit; - padding: 0; - opacity: 0; - - .add-keyboard-focus-ring(); - .add-keyboard-focus-ring-offset(4px); - - // Needs more specificity to fix hover/focus styles not applying in dropdown - [data-colored-header=true] .HeaderListItem & { - color: var(--control-color); - - &:hover, - &:focus { - color: var(--link-color); - } - } - - .icon { - font-size: 13px; - } - } - } - &-excerpt { grid-area: excerpt; color: var(--muted-more-color); diff --git a/framework/core/less/forum/IndexPage.less b/framework/core/less/forum/IndexPage.less index 0affc6814..1fc7f6e17 100644 --- a/framework/core/less/forum/IndexPage.less +++ b/framework/core/less/forum/IndexPage.less @@ -2,7 +2,7 @@ // Sidebar @media @desktop-up { - .IndexPage-nav .item-newDiscussion { + .IndexPage-nav .App-primaryControl { margin-bottom: 20px; .Button { diff --git a/framework/core/less/forum/PageStructure.less b/framework/core/less/forum/PageStructure.less index ec32af4a9..0ddff8c04 100644 --- a/framework/core/less/forum/PageStructure.less +++ b/framework/core/less/forum/PageStructure.less @@ -35,4 +35,33 @@ margin-top: 15px; } } + + &--vertical { + @media @desktop-up { + --sidebar-width: 100%; + --gap: 30px; + + .sideNav { + .sideNav--horizontal(); + width: auto; + padding: 0; + + &:after { + display: none; + } + + > ul > li:first-child { + width: 190px; + } + } + + .Page-container { + flex-direction: column; + } + + .Page-content { + margin-top: 0; + } + } + } } diff --git a/framework/core/less/forum/Post.less b/framework/core/less/forum/Post.less index f317b3830..c43415004 100644 --- a/framework/core/less/forum/Post.less +++ b/framework/core/less/forum/Post.less @@ -219,6 +219,10 @@ .PostMeta { display: inline; } +.PostMeta > .Button { + line-height: 1; + vertical-align: baseline; +} .PostMeta .Dropdown-menu { width: 420px; padding: 10px; diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml index 41b951ad4..edb62d0d7 100644 --- a/framework/core/locale/core.yml +++ b/framework/core/locale/core.yml @@ -831,6 +831,14 @@ core: three_text: "{first}, {second}, and {third}" two_text: "{first} and {second}" + # These translations are used in the generic user selection modal. + user_selection_modal: + empty_results: No users found matching your search. + max_items_to_select: "{count, plural, one {Select up to # user} other {Select up to # users}}" + search_placeholder: => core.ref.search_users + submit_button: => core.ref.okay + title: Select relevant users + # These translations are used to modify usernames. username: deleted_text: "[deleted]" diff --git a/framework/core/migrations/2024_05_05_000001_convert_preferences_to_json_in_users.php b/framework/core/migrations/2024_05_05_000001_convert_preferences_to_json_in_users.php index 1129e4f03..6079b0e9d 100644 --- a/framework/core/migrations/2024_05_05_000001_convert_preferences_to_json_in_users.php +++ b/framework/core/migrations/2024_05_05_000001_convert_preferences_to_json_in_users.php @@ -12,25 +12,55 @@ use Illuminate\Database\Schema\Builder; return [ 'up' => function (Builder $schema) { + $preferences = $schema->getConnection()->getSchemaGrammar()->wrap('preferences'); + if ($schema->getConnection()->getDriverName() === 'pgsql') { $users = $schema->getConnection()->getSchemaGrammar()->wrapTable('users'); - $preferences = $schema->getConnection()->getSchemaGrammar()->wrap('preferences'); - $schema->getConnection()->statement("ALTER TABLE $users ALTER COLUMN $preferences TYPE JSON USING preferences::TEXT::JSON"); + $schema->getConnection()->statement("ALTER TABLE $users ALTER COLUMN $preferences TYPE JSON USING $preferences::TEXT::JSON"); } else { $schema->table('users', function (Blueprint $table) { - $table->json('preferences')->nullable()->change(); + $table->json('preferences_json')->nullable(); + }); + + if ($schema->getConnection()->getDriverName() === 'mysql') { + $schema->getConnection()->table('users')->update([ + 'preferences_json' => $schema->getConnection()->raw("CAST(CONVERT($preferences USING utf8mb4) AS JSON)"), + ]); + } + + $schema->table('users', function (Blueprint $table) { + $table->dropColumn('preferences'); + }); + + $schema->table('users', function (Blueprint $table) { + $table->renameColumn('preferences_json', 'preferences'); }); } }, 'down' => function (Builder $schema) { + $preferences = $schema->getConnection()->getSchemaGrammar()->wrap('preferences'); + if ($schema->getConnection()->getDriverName() === 'pgsql') { $users = $schema->getConnection()->getSchemaGrammar()->wrapTable('users'); - $preferences = $schema->getConnection()->getSchemaGrammar()->wrap('preferences'); $schema->getConnection()->statement("ALTER TABLE $users ALTER COLUMN $preferences TYPE BYTEA USING preferences::TEXT::BYTEA"); } else { $schema->table('users', function (Blueprint $table) { - $table->binary('preferences')->nullable()->change(); + $table->binary('preferences_binary')->nullable(); + }); + + if ($schema->getConnection()->getDriverName() === 'mysql') { + $schema->getConnection()->table('users')->update([ + 'preferences_binary' => $schema->getConnection()->raw($preferences), + ]); + } + + $schema->table('users', function (Blueprint $table) { + $table->dropColumn('preferences'); + }); + + $schema->table('users', function (Blueprint $table) { + $table->renameColumn('preferences_binary', 'preferences'); }); } } diff --git a/framework/core/migrations/2024_05_07_000001_convert_data_to_json_in_notifications.php b/framework/core/migrations/2024_05_07_000001_convert_data_to_json_in_notifications.php index c433d0c08..82ca9e76e 100644 --- a/framework/core/migrations/2024_05_07_000001_convert_data_to_json_in_notifications.php +++ b/framework/core/migrations/2024_05_07_000001_convert_data_to_json_in_notifications.php @@ -18,7 +18,21 @@ return [ $schema->getConnection()->statement("ALTER TABLE $notifications ALTER COLUMN $data TYPE JSON USING data::TEXT::JSON"); } else { $schema->table('notifications', function (Blueprint $table) { - $table->json('data')->nullable()->change(); + $table->json('data_json')->nullable(); + }); + + if ($schema->getConnection()->getDriverName() === 'mysql') { + $schema->getConnection()->table('notifications')->update([ + 'data_json' => $schema->getConnection()->raw('CAST(CONVERT(data USING utf8mb4) AS JSON)'), + ]); + } + + $schema->table('notifications', function (Blueprint $table) { + $table->dropColumn('data'); + }); + + $schema->table('notifications', function (Blueprint $table) { + $table->renameColumn('data_json', 'data'); }); } }, @@ -30,7 +44,21 @@ return [ $schema->getConnection()->statement("ALTER TABLE $notifications ALTER COLUMN $data TYPE BYTEA USING data::TEXT::BYTEA"); } else { $schema->table('notifications', function (Blueprint $table) { - $table->binary('data')->nullable()->change(); + $table->binary('data_binary')->nullable(); + }); + + if ($schema->getConnection()->getDriverName() === 'mysql') { + $schema->getConnection()->table('notifications')->update([ + 'data_binary' => $schema->getConnection()->raw('data'), + ]); + } + + $schema->table('notifications', function (Blueprint $table) { + $table->dropColumn('data'); + }); + + $schema->table('notifications', function (Blueprint $table) { + $table->renameColumn('data_binary', 'data'); }); } } diff --git a/framework/core/src/Api/Endpoint/Concerns/SavesAndValidatesData.php b/framework/core/src/Api/Endpoint/Concerns/SavesAndValidatesData.php index dd8767b81..d07d2fbba 100644 --- a/framework/core/src/Api/Endpoint/Concerns/SavesAndValidatesData.php +++ b/framework/core/src/Api/Endpoint/Concerns/SavesAndValidatesData.php @@ -73,8 +73,16 @@ trait SavesAndValidatesData $factory = new Factory($translator); } - $attributeValidator = $factory->make($data['attributes'], $rules['attributes'], $messages, $attributes); - $relationshipValidator = $factory->make($data['relationships'], $rules['relationships'], $messages, $attributes); + $attributesAsData = $data['attributes'] ?? []; + // Allows referring to other types of data in validation rules, ex: required_without:relationships.fieldName + $attributesAsData['relationships'] = $data['relationships'] ?? []; + + $relationshipsAsData = $data['relationships'] ?? []; + // Allows referring to other types of data in validation rules, ex: required_without:attributes.fieldName + $relationshipsAsData['attributes'] = $data['attributes'] ?? []; + + $attributeValidator = $factory->make($attributesAsData, $rules['attributes'], $messages, $attributes); + $relationshipValidator = $factory->make($relationshipsAsData, $rules['relationships'], $messages, $attributes); $this->validate('attributes', $attributeValidator); $this->validate('relationships', $relationshipValidator); diff --git a/framework/core/src/Api/Resource/AbstractDatabaseResource.php b/framework/core/src/Api/Resource/AbstractDatabaseResource.php index 24cb2f212..4fb659f20 100644 --- a/framework/core/src/Api/Resource/AbstractDatabaseResource.php +++ b/framework/core/src/Api/Resource/AbstractDatabaseResource.php @@ -18,7 +18,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Support\Str; -use InvalidArgumentException; use RuntimeException; use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Pagination\OffsetPagination; @@ -96,10 +95,6 @@ abstract class AbstractDatabaseResource extends AbstractResource implements /** @var Relationship|null $relationship */ $relationship = collect($context->fields($this))->first(fn ($f) => $f->name === $relationName); - if (! $relationship) { - throw new InvalidArgumentException("To use relation aggregates, the relationship field must be part of the resource. Missing field: $relationName for attribute $field->name."); - } - EloquentBuffer::add($model, $relationName, $aggregate); return function () use ($model, $relationName, $relationship, $field, $context, $aggregate) { diff --git a/framework/core/src/Api/Resource/AbstractResource.php b/framework/core/src/Api/Resource/AbstractResource.php index aaf611e06..f027d3741 100644 --- a/framework/core/src/Api/Resource/AbstractResource.php +++ b/framework/core/src/Api/Resource/AbstractResource.php @@ -24,6 +24,7 @@ abstract class AbstractResource extends BaseResource use Bootable; use Extendable; use HasSortMap; + /** @use HasHooks */ use HasHooks; public function id(Context $context): ?string diff --git a/framework/core/src/Api/Resource/EloquentBuffer.php b/framework/core/src/Api/Resource/EloquentBuffer.php index 09557accc..9e824fdd8 100644 --- a/framework/core/src/Api/Resource/EloquentBuffer.php +++ b/framework/core/src/Api/Resource/EloquentBuffer.php @@ -16,6 +16,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Support\Str; use Tobyz\JsonApiServer\Laravel\Field\ToMany; use Tobyz\JsonApiServer\Laravel\Field\ToOne; use Tobyz\JsonApiServer\Schema\Field\Relationship; @@ -40,12 +41,12 @@ abstract class EloquentBuffer } /** - * @param array{relation: string, column: string, function: string, constrain: callable|null}|null $aggregate + * @param array{name: string, relation: string, column: string, function: string, constrain: callable|null}|null $aggregate */ public static function load( Model $model, string $relationName, - Relationship $relationship, + ?Relationship $relationship, Context $context, ?array $aggregate = null, ): void { @@ -65,10 +66,10 @@ abstract class EloquentBuffer // may be multiple if this is a polymorphic relationship. We // start by getting the resource types this relationship // could possibly contain. - /** @var AbstractDatabaseResource[] $resources */ + /** @var (AbstractDatabaseResource|AbstractResource)[] $resources */ $resources = $context->api->resources; - if ($type = $relationship->collections) { + if ($relationship && $type = $relationship->collections) { $resources = array_intersect_key($resources, array_flip($type)); } @@ -77,31 +78,31 @@ abstract class EloquentBuffer // method in order to apply type-specific scoping. $constrain = []; - foreach ($resources as $resource) { - $modelClass = get_class($resource->newModel($context)); + if (! $aggregate && $relationship) { + foreach ($resources as $resource) { + $modelClass = $resource instanceof AbstractDatabaseResource ? get_class($resource->newModel($context)) : null; - if ($resource instanceof AbstractDatabaseResource && ! isset($constrain[$modelClass])) { - $constrain[$modelClass] = function (Builder $query) use ($resource, $context, $relationship, $aggregate) { - if (! $aggregate) { + if ($resource instanceof AbstractDatabaseResource && ! isset($constrain[$modelClass])) { + $constrain[$modelClass] = function (Builder $query) use ($resource, $context, $relationship, $relation) { /** @var Endpoint $endpoint */ $endpoint = $context->endpoint; $query ->with($endpoint->getEagerLoadsFor($relationship->name, $context)) ->with($endpoint->getWhereEagerLoadsFor($relationship->name, $context)); - } - $resource->scope($query, $context); + $resource->scope($query, $context); - if ($aggregate && ! empty($aggregate['constrain'])) { - ($aggregate['constrain'])($query, $context); - } - - if (($relationship instanceof ToMany || $relationship instanceof ToOne) && $relationship->scope) { - ($relationship->scope)($query, $context); - } - }; + if (($relationship instanceof ToMany || $relationship instanceof ToOne) && $relationship->scope) { + ($relationship->scope)($relation, $context); + } + }; + } } + } elseif (! empty($aggregate['constrain'])) { + $modelClass = get_class($relation->getModel()); + + $constrain[$modelClass] = fn (Builder $query) => ($aggregate['constrain'])($query, $context); } if ($relation instanceof MorphTo) { @@ -115,7 +116,7 @@ abstract class EloquentBuffer $collection = $model->newCollection($models); - if (! $aggregate) { + if (! $aggregate && $relationship) { $collection->load([$relationName => $loader]); // Set the inverse relation on the loaded relations. @@ -136,7 +137,8 @@ abstract class EloquentBuffer } }); } else { - $collection->loadAggregate([$relationName => $loader], $aggregate['column'], $aggregate['function']); + $alias = Str::snake($aggregate['name']); + $collection->loadAggregate(["$relationName as $alias" => $loader], $aggregate['column'], $aggregate['function']); } self::setBuffer($model, $relationName, $aggregate, []); diff --git a/framework/core/src/Api/Schema/Concerns/GetsRelationAggregates.php b/framework/core/src/Api/Schema/Concerns/GetsRelationAggregates.php index 4efcbfef4..8f23d0152 100644 --- a/framework/core/src/Api/Schema/Concerns/GetsRelationAggregates.php +++ b/framework/core/src/Api/Schema/Concerns/GetsRelationAggregates.php @@ -15,7 +15,7 @@ use Tobyz\JsonApiServer\Schema\Type\Number; trait GetsRelationAggregates { /** - * @var array{relation: string, column: string, function: string, constrain: Closure}|null + * @var array{name: string, relation: string, column: string, function: string, constrain: Closure}|null */ public ?array $relationAggregate = null; @@ -25,7 +25,9 @@ trait GetsRelationAggregates throw new \InvalidArgumentException('Relation aggregates can only be used with number attributes'); } - $this->relationAggregate = compact('relation', 'column', 'function', 'constrain'); + $name = $this->name; + + $this->relationAggregate = compact('name', 'relation', 'column', 'function', 'constrain'); return $this; } diff --git a/framework/core/src/Api/Schema/Concerns/HasValidationRules.php b/framework/core/src/Api/Schema/Concerns/HasValidationRules.php index 25fc0a443..b8002dc13 100644 --- a/framework/core/src/Api/Schema/Concerns/HasValidationRules.php +++ b/framework/core/src/Api/Schema/Concerns/HasValidationRules.php @@ -153,6 +153,21 @@ trait HasValidationRules }, $condition); } + public function in(array $values, bool|callable $condition = true): static + { + return $this->rule(Rule::in($values), $condition); + } + + public function notIn(array $values, bool|callable $condition = true): static + { + return $this->rule(Rule::notIn($values), $condition); + } + + public function items(int $count, bool|callable $condition = true): static + { + return $this->rule("size:$count", $condition); + } + protected function evaluate(Context $context, mixed $callback): mixed { if (is_string($callback) || ! is_callable($callback)) { diff --git a/framework/core/src/Api/Schema/Contracts/RelationAggregator.php b/framework/core/src/Api/Schema/Contracts/RelationAggregator.php index 8077e5539..964f33e89 100644 --- a/framework/core/src/Api/Schema/Contracts/RelationAggregator.php +++ b/framework/core/src/Api/Schema/Contracts/RelationAggregator.php @@ -16,7 +16,7 @@ interface RelationAggregator public function relationAggregate(string $relation, string $column, string $function): static; /** - * @return array{relation: string, column: string, function: string, constrain: Closure|null}|null + * @return array{name: string, relation: string, column: string, function: string, constrain: Closure|null}|null */ public function getRelationAggregate(): ?array; } diff --git a/framework/core/src/Formatter/Formattable.php b/framework/core/src/Formatter/Formattable.php new file mode 100644 index 000000000..0270be58c --- /dev/null +++ b/framework/core/src/Formatter/Formattable.php @@ -0,0 +1,15 @@ +unparse($value, $this); + } + + public function getParsedContentAttribute(): string + { + return $this->attributes['content']; + } + + public function setContentAttribute(string $value, ?User $actor = null): void + { + $this->attributes['content'] = $value ? static::$formatter->parse($value, $this, $actor ?? $this->user) : null; + } + + public function setParsedContentAttribute(string $value): void + { + $this->attributes['content'] = $value; + } + + /** + * Get the content rendered as HTML. + */ + public function formatContent(?ServerRequestInterface $request = null): string + { + return static::$formatter->render($this->attributes['content'], $this, $request); + } + + public static function getFormatter(): Formatter + { + return static::$formatter; + } + + public static function setFormatter(Formatter $formatter): void + { + static::$formatter = $formatter; + } +} diff --git a/framework/core/src/Notification/AlertableInterface.php b/framework/core/src/Notification/AlertableInterface.php new file mode 100644 index 000000000..45e384e77 --- /dev/null +++ b/framework/core/src/Notification/AlertableInterface.php @@ -0,0 +1,18 @@ +queue->push(new SendNotificationsJob($blueprint, $users)); } } public function registerType(string $blueprintClass, array $driversEnabledByDefault): void { - User::registerPreference( - User::getNotificationPreferenceKey($blueprintClass::getType(), 'alert'), - boolval(...), - in_array('alert', $driversEnabledByDefault) - ); + if ((new ReflectionClass($blueprintClass))->implementsInterface(AlertableInterface::class)) { + User::registerPreference( + User::getNotificationPreferenceKey($blueprintClass::getType(), 'alert'), + boolval(...), + in_array('alert', $driversEnabledByDefault) + ); + } } } diff --git a/framework/core/src/Notification/Job/SendNotificationsJob.php b/framework/core/src/Notification/Job/SendNotificationsJob.php index 7226bedbd..72a9eb3ed 100644 --- a/framework/core/src/Notification/Job/SendNotificationsJob.php +++ b/framework/core/src/Notification/Job/SendNotificationsJob.php @@ -9,6 +9,7 @@ namespace Flarum\Notification\Job; +use Flarum\Notification\AlertableInterface; use Flarum\Notification\Blueprint\BlueprintInterface; use Flarum\Notification\Notification; use Flarum\Queue\AbstractJob; @@ -17,7 +18,7 @@ use Flarum\User\User; class SendNotificationsJob extends AbstractJob { public function __construct( - private readonly BlueprintInterface $blueprint, + private readonly BlueprintInterface&AlertableInterface $blueprint, /** @var User[] */ private readonly array $recipients = [] ) { diff --git a/framework/core/src/Notification/Notification.php b/framework/core/src/Notification/Notification.php index cdae9397b..3819efe71 100644 --- a/framework/core/src/Notification/Notification.php +++ b/framework/core/src/Notification/Notification.php @@ -177,7 +177,7 @@ class Notification extends AbstractModel * * @param User[] $recipients */ - public static function notify(array $recipients, BlueprintInterface $blueprint): void + public static function notify(array $recipients, BlueprintInterface&AlertableInterface $blueprint): void { $attributes = static::getBlueprintAttributes($blueprint); $now = Carbon::now()->toDateTimeString(); diff --git a/framework/core/src/Post/CommentPost.php b/framework/core/src/Post/CommentPost.php index 91c618a5f..84785663b 100644 --- a/framework/core/src/Post/CommentPost.php +++ b/framework/core/src/Post/CommentPost.php @@ -10,23 +10,22 @@ namespace Flarum\Post; use Carbon\Carbon; -use Flarum\Formatter\Formatter; +use Flarum\Formatter\Formattable; +use Flarum\Formatter\HasFormattedContent; use Flarum\Post\Event\Hidden; use Flarum\Post\Event\Posted; use Flarum\Post\Event\Restored; use Flarum\Post\Event\Revised; use Flarum\User\User; -use Psr\Http\Message\ServerRequestInterface; /** * A standard comment in a discussion. - * - * @property string $parsed_content */ -class CommentPost extends Post +class CommentPost extends Post implements Formattable { + use HasFormattedContent; + public static string $type = 'comment'; - protected static Formatter $formatter; protected $observables = ['hidden']; @@ -90,42 +89,4 @@ class CommentPost extends Post return $this; } - - public function getContentAttribute(string $value): string - { - return static::$formatter->unparse($value, $this); - } - - public function getParsedContentAttribute(): string - { - return $this->attributes['content']; - } - - public function setContentAttribute(string $value, ?User $actor = null): void - { - $this->attributes['content'] = $value ? static::$formatter->parse($value, $this, $actor ?? $this->user) : null; - } - - public function setParsedContentAttribute(string $value): void - { - $this->attributes['content'] = $value; - } - - /** - * Get the content rendered as HTML. - */ - public function formatContent(?ServerRequestInterface $request = null): string - { - return static::$formatter->render($this->attributes['content'], $this, $request); - } - - public static function getFormatter(): Formatter - { - return static::$formatter; - } - - public static function setFormatter(Formatter $formatter): void - { - static::$formatter = $formatter; - } } diff --git a/framework/core/tests/integration/extenders/NotificationTest.php b/framework/core/tests/integration/extenders/NotificationTest.php index df88e7b09..084ec96ff 100644 --- a/framework/core/tests/integration/extenders/NotificationTest.php +++ b/framework/core/tests/integration/extenders/NotificationTest.php @@ -11,6 +11,7 @@ namespace Flarum\Tests\integration\extenders; use Flarum\Database\AbstractModel; use Flarum\Extend; +use Flarum\Notification\AlertableInterface; use Flarum\Notification\Blueprint\BlueprintInterface; use Flarum\Notification\Driver\NotificationDriverInterface; use Flarum\Notification\Notification; @@ -118,7 +119,7 @@ class NotificationTest extends TestCase } } -class CustomNotificationType implements BlueprintInterface +class CustomNotificationType implements BlueprintInterface, AlertableInterface { public function getFromUser(): ?User { diff --git a/js-packages/webpack-config/LICENSE b/js-packages/webpack-config/LICENSE index 097f400cc..ca98243d5 100755 --- a/js-packages/webpack-config/LICENSE +++ b/js-packages/webpack-config/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019-2021 Stichting Flarum (Flarum Foundation) +Copyright (c) 2019-2024 Stichting Flarum (Flarum Foundation) Copyright (c) 2014-2019 Toby Zerner (toby.zerner@gmail.com) Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/phpstan.neon b/phpstan.neon index 583384be2..5861d3072 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -33,6 +33,8 @@ parameters: - extensions/suspend/extend.php - extensions/tags/src - extensions/tags/extend.php + - extensions/messages/src + - extensions/messages/extend.php excludePaths: - *.blade.php databaseMigrationsPath: ['framework/core/migrations']