feat: messages extension (#4028)

* feat: private messages
This commit is contained in:
Sami Mazouz 2024-09-28 11:12:52 +01:00 committed by GitHub
parent bc4356a7f5
commit b74ecbfacf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
186 changed files with 5331 additions and 605 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<CustomAttrs extends IFlagListAttrs = IFlagListAttr
<HeaderList
className="FlagList"
title={app.translator.trans('flarum-flags.forum.flagged_posts.title')}
controls={this.controlItems()}
hasItems={state.hasItems()}
loading={state.isLoading()}
emptyText={app.translator.trans('flarum-flags.forum.flagged_posts.empty_text')}
@ -37,6 +39,12 @@ export default class FlagList<CustomAttrs extends IFlagListAttrs = IFlagListAttr
);
}
controlItems() {
const items = new ItemList();
return items;
}
content(state: FlagListState) {
if (!state.isLoading() && state.hasItems()) {
return state.getPages().map((page: Page<Flag>) => {

View File

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

View File

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

View File

@ -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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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')

View File

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

View File

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

View File

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

View File

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

20
extensions/messages/.gitattributes vendored Normal file
View File

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

13
extensions/messages/.gitignore vendored Normal file
View File

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

View File

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

View File

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

View File

@ -0,0 +1,87 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages;
use Flarum\Api\Context;
use Flarum\Api\Resource;
use Flarum\Api\Schema;
use Flarum\Extend;
use Flarum\Messages\Http\Middleware\PopulateDialogWithActor;
use Flarum\Search\Database\DatabaseSearchDriver;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
return [
(new Extend\Frontend('forum'))
->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),
];

9
extensions/messages/js/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
node_modules

View File

@ -0,0 +1,2 @@
export * from './src/common';
export * from './src/admin';

View File

@ -0,0 +1,2 @@
export * from './src/common';
export * from './src/forum';

View File

@ -0,0 +1 @@
module.exports = require('@flarum/jest-config')({});

View File

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

View File

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

View File

@ -0,0 +1,8 @@
import Extend from 'flarum/common/extenders';
import commonExtend from '../common/extend';
export default [
...commonExtend,
// Add your admin extenders here
];

View File

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

View File

@ -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), //
];

View File

@ -0,0 +1 @@
export default null;

View File

@ -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<string>('title').call(this);
}
type() {
return Model.attribute<string>('type').call(this);
}
lastMessageAt() {
return Model.attribute<Date, string>('lastMessageAt', Model.transformDate).call(this);
}
createdAt() {
return Model.attribute<Date, string>('createdAt', Model.transformDate).call(this);
}
users() {
return Model.hasMany<User>('users').call(this);
}
firstMessage() {
return Model.hasOne<DialogMessage>('firstMessage').call(this);
}
lastMessage() {
return Model.hasOne<DialogMessage>('lastMessage').call(this);
}
unreadCount() {
return Model.attribute<number>('unreadCount').call(this);
}
lastReadMessageId() {
return Model.attribute<number>('lastReadMessageId').call(this);
}
lastReadAt() {
return Model.attribute<Date, string>('lastReadAt', Model.transformDate).call(this);
}
recipient() {
let users = this.users();
return !users ? null : users.find((user) => user && user.id() !== app.session.user!.id());
}
}

View File

@ -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<string | null | undefined>('content').call(this);
}
contentHtml() {
return Model.attribute<string | null | undefined>('contentHtml').call(this);
}
renderFailed() {
return Model.attribute<boolean | undefined>('renderFailed').call(this);
}
contentPlain() {
return computed<string | null | undefined>('contentHtml', (content) => {
if (typeof content === 'string') {
return getPlainContent(content);
}
return content as null | undefined;
}).call(this);
}
createdAt() {
return Model.attribute<Date, string>('createdAt', Model.transformDate).call(this);
}
dialog() {
return Model.hasOne<Dialog>('dialog').call(this);
}
user() {
return Model.hasOne<User>('user').call(this);
}
}

View File

@ -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<CustomAttrs extends IDetailsModalAttrs = IDetailsModalAttrs> extends Modal<CustomAttrs> {
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 (
<div className="Modal-body DetailsModal-infoGroups">
<div className="DetailsModal-recipients DetailsModal-info">
<div className="DetailsModal-info-title">{app.translator.trans('flarum-messages.forum.dialog_section.details_modal.recipients')}</div>
<div className="DetailsModal-recipients-list">
{recipients?.map((recipient: User) => {
return (
<div className="DetailsModal-recipient">
<Avatar user={recipient} />
<Link href={app.route('user', { username: recipient.slug() })}>
<span className="DetailsModal-recipient-username">{username(recipient)}</span>
</Link>
<div className="badges">{listItems(recipient.badges().toArray())}</div>
</div>
);
})}
</div>
</div>
{this.infoItems().toArray()}
</div>
);
}
infoItems() {
const items = new ItemList<Mithril.Children>();
items.add(
'created',
<div className="DetailsModal-createdAt DetailsModal-info">
<div className="DetailsModal-info-title">{app.translator.trans('flarum-messages.forum.dialog_section.details_modal.created_at')}</div>
<div className="DetailsModal-info-content">{fullTime(this.attrs.dialog.createdAt())}</div>
</div>
);
return items;
}
}

View File

@ -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<CustomAttrs extends IDialogListDropdownAttrs = IDialogListDropdownAttrs> extends Component<
CustomAttrs,
DialogListState
> {
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode);
}
view() {
const state = this.attrs.state;
return (
<HeaderList
className="DialogDropdownList"
title={app.translator.trans('flarum-messages.forum.dialog_list.title')}
controls={this.controlItems()}
hasItems={state.hasItems()}
loading={state.isLoading()}
emptyText={app.translator.trans('flarum-messages.forum.messages_page.empty_text')}
loadMore={() => state.hasNext() && !state.isLoadingNext() && state.loadNext()}
footer={() => (
<h4>
<LinkButton href={app.route('messages')} className="Button Button--link" icon="fas fa-inbox">
{app.translator.trans('flarum-messages.forum.dialog_list.view_all')}
</LinkButton>
</h4>
)}
>
<div className="HeaderListGroup-content">{this.content()}</div>
</HeaderList>
);
}
controlItems() {
const items = new ItemList();
const state = this.attrs.state;
if (app.session.user!.attribute<number>('messageCount') > 0) {
items.add(
'mark_all_as_read',
<Tooltip text={app.translator.trans('flarum-messages.forum.messages_page.mark_all_as_read_tooltip')}>
<Button
className="Button Button--link"
data-container=".DialogDropdownList"
icon="fas fa-check"
title={app.translator.trans('flarum-messages.forum.messages_page.mark_all_as_read_tooltip')}
onclick={state.markAllAsRead.bind(state)}
/>
</Tooltip>,
70
);
}
return items;
}
content() {
return <DialogList state={this.attrs.state} hideMore={true} itemActions={true} />;
}
}

View File

@ -0,0 +1,47 @@
import app from 'flarum/forum/app';
import Component, { type ComponentAttrs } from 'flarum/common/Component';
import type Mithril from 'mithril';
import DialogListState from '../states/DialogListState';
import Dialog from '../../common/models/Dialog';
import Button from 'flarum/common/components/Button';
import DialogListItem from './DialogListItem';
export interface IDialogListAttrs extends ComponentAttrs {
state: DialogListState;
activeDialog?: Dialog | null;
hideMore?: boolean;
itemActions?: boolean;
}
export default class DialogList<CustomAttrs extends IDialogListAttrs = IDialogListAttrs> extends Component<CustomAttrs> {
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode);
}
oncreate(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
super.oncreate(vnode);
}
onupdate(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
super.onupdate(vnode);
}
view() {
return (
<div className="DialogList">
<ul className="DialogList-list">
{this.attrs.state.getAllItems().map((dialog) => (
<DialogListItem dialog={dialog} active={this.attrs.activeDialog?.id() === dialog.id()} actions={this.attrs.itemActions} />
))}
</ul>
{this.attrs.state.hasNext() && !this.attrs.hideMore && (
<div className="DialogList-loadMore">
<Button className="Button" onclick={this.attrs.state.loadNext.bind(this.attrs.state)}>
{app.translator.trans('flarum-messages.forum.dialog_list.load_more_button')}
</Button>
</div>
)}
</div>
);
}
}

View File

@ -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<CustomAttrs extends IDialogListItemAttrs = IDialogListItemAttrs> extends Component<CustomAttrs> {
view(vnode: Mithril.Vnode<CustomAttrs, this>) {
const dialog = this.attrs.dialog;
const recipient = dialog.recipient();
const lastMessage = dialog.lastMessage();
return (
<li
className={classList('DialogListItem', {
'DialogListItem--unread': dialog.unreadCount(),
active: this.attrs.active,
})}
>
<Link
href={app.route.dialog(dialog)}
className={classList('DialogListItem-button', {
active: this.attrs.active,
})}
>
<div className="DialogListItem-avatar">
<Avatar user={recipient} />
{!!dialog.unreadCount() && <div className="Bubble Bubble--primary">{dialog.unreadCount()}</div>}
</div>
<div className="DialogListItem-content">
<div className="DialogListItem-title">
{username(recipient)}
{humanTime(dialog.lastMessageAt()!)}
{this.attrs.actions && <div className="DialogListItem-actions">{this.actionItems().toArray()}</div>}
</div>
<div className="DialogListItem-lastMessage">{lastMessage ? lastMessage.contentPlain()?.slice(0, 80) : ''}</div>
</div>
</Link>
</li>
);
}
actionItems(): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
items.add(
'markAsRead',
<Button
className="Notification-action Button Button--link"
icon="fas fa-check"
aria-label={app.translator.trans('flarum-messages.forum.dialog_list.mark_as_read_tooltip')}
onclick={(e: Event) => {
e.preventDefault();
e.stopPropagation();
this.attrs.dialog
.save({ lastReadMessageId: (this.attrs.dialog.data.relationships?.lastMessage.data as ModelIdentifier).id })
.finally(() => {
if (this.attrs.dialog.unreadCount() === 0) {
app.session.user!.pushAttributes({
messageCount: (app.session.user!.attribute<number>('messageCount') ?? 1) - 1,
});
}
m.redraw();
});
}}
/>,
100
);
return items;
}
}

View File

@ -0,0 +1,90 @@
import Component, { type ComponentAttrs } from 'flarum/common/Component';
import Dialog from '../../common/models/Dialog';
import type Mithril from 'mithril';
import MessageStream from './MessageStream';
import username from 'flarum/common/helpers/username';
import MessageStreamState from '../states/MessageStreamState';
import Avatar from 'flarum/common/components/Avatar';
import Link from 'flarum/common/components/Link';
import app from 'flarum/forum/app';
import ItemList from 'flarum/common/utils/ItemList';
import Button from 'flarum/common/components/Button';
import Dropdown from 'flarum/common/components/Dropdown';
import DetailsModal from './DetailsModal';
import listItems from 'flarum/common/helpers/listItems';
export interface IDialogStreamAttrs extends ComponentAttrs {
dialog: Dialog;
}
export default class DialogSection<CustomAttrs extends IDialogStreamAttrs = IDialogStreamAttrs> extends Component<CustomAttrs> {
protected loading = false;
protected messages!: MessageStreamState;
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode);
this.messages = new MessageStreamState({
filter: {
dialog: this.attrs.dialog.id(),
},
sort: '-createdAt',
});
this.messages.refresh();
}
view() {
const recipient = this.attrs.dialog.recipient();
return (
<div className="DialogSection">
<div className="DialogSection-header">
<Avatar user={recipient} />
<div className="DialogSection-header-info">
{(recipient && (
<Link href={app.route.user(recipient!)}>
<h2>{username(recipient)}</h2>
</Link>
)) || <h2>{username(recipient)}</h2>}
<div className="badges">{listItems(recipient?.badges().toArray() || [])}</div>
</div>
<div className="DialogSection-header-actions">{this.actionItems().toArray()}</div>
</div>
<MessageStream dialog={this.attrs.dialog} state={this.messages} />
</div>
);
}
actionItems() {
const items = new ItemList<Mithril.Children>();
items.add(
'details',
<Dropdown
icon="fas fa-ellipsis-h"
className="DialogSection-controls"
buttonClassName="Button Button--icon"
accessibleToggleLabel={app.translator.trans('flarum-messages.forum.dialog_section.controls_toggle_label')}
label={app.translator.trans('flarum-messages.forum.dialog_section.controls_toggle_label')}
>
{this.controlItems().toArray()}
</Dropdown>
);
return items;
}
controlItems() {
const items = new ItemList<Mithril.Children>();
items.add(
'details',
<Button icon="fas fa-info-circle" onclick={() => app.modal.show(DetailsModal, { dialog: this.attrs.dialog })}>
{app.translator.trans('flarum-messages.forum.dialog_section.controls.details_button')}
</Button>
);
return items;
}
}

View File

@ -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<CustomAttrs extends IDialogsDropdownAttrs = IDialogsDropdownAttrs> extends HeaderDropdown<CustomAttrs> {
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 <LoadingIndicator />;
}
return <this.DialogDropdownList state={this.attrs.state} />;
}
goToRoute() {
m.route.set(app.route('dialogs'));
}
getUnreadCount() {
return app.session.user!.attribute<number>('messageCount');
}
getNewCount() {
return app.session.user!.attribute<number>('messageCount');
}
}

View File

@ -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<CustomAttrs extends IMessageAttrs = IMessageAttrs> extends AbstractPost<CustomAttrs> {
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
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<CustomAttrs, this>) {
return super.onbeforeupdate(vnode);
}
onupdate(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
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([
<Comment
headerItems={this.headerItems()}
cardVisible={false}
isEditing={false}
isHidden={false}
contentHtml={this.attrs.message.contentHtml()}
user={this.attrs.message.user()}
/>,
]);
}
classes(existing?: string): string[] {
return super.classes(existing);
}
actionItems(): ItemList<Mithril.Children> {
return super.actionItems();
}
footerItems(): ItemList<Mithril.Children> {
return super.footerItems();
}
sideItems(): ItemList<Mithril.Children> {
return super.sideItems();
}
avatar(): Mithril.Children {
return this.attrs.message.user() ? <Avatar user={this.attrs.message.user()} /> : '';
}
headerItems() {
const items = new ItemList<Mithril.Children>();
const message = this.attrs.message;
items.add('user', <PostUser post={message} />, 100);
items.add('meta', <PostMeta post={message} />);
return items;
}
}

View File

@ -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<CustomAttrs extends IMessageComposerAttrs = IMessageComposerAttrs> extends ComposerBody<CustomAttrs> {
protected recipients!: Stream<User[]>;
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<CustomAttrs, this>) {
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',
<div className="MessageComposer-recipients">
{!this.attrs.replyingTo && (
<Button
type="button"
className="Button Button--outline Button--compact"
onclick={() =>
app.modal.show(UserSelectionModal, {
title: app.translator.trans('flarum-messages.forum.recipient_selection_modal.title', {}, true),
selected: this.recipients(),
maxItems: 1,
excluded: [app.session.user!.id()!],
onsubmit: (users: User[]) => {
this.recipients(users);
},
})
}
>
{app.translator.trans('flarum-messages.forum.composer.recipients')}
</Button>
)}
{!!this.recipients().length && (
<div className="MessageComposer-recipients-label">{app.translator.trans('flarum-messages.forum.composer.to')}</div>
)}
<ul className="MessageComposer-recipients-list">
{this.recipients().map((user) => (
<li>
<Tooltip text={user.username()}>
<Avatar user={user} />
</Tooltip>
</li>
))}
</ul>
</div>,
100
);
return items;
}
/**
* Get the data to submit to the server when the discussion is saved.
*/
data(): Record<string, unknown> {
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<DialogMessage>('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));
}
}

View File

@ -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<CustomAttrs extends IDialogStreamAttrs = IDialogStreamAttrs> extends Component<CustomAttrs> {
protected replyPlaceholderComponent = Stream<any>(null);
protected loadingPostComponent = Stream<any>(null);
protected scrollListener!: ScrollListener;
protected initialToBottomScroll = false;
protected lastTime: Date | null = null;
protected checkedRead = false;
protected markingAsRead = false;
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
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<CustomAttrs, this>) {
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<CustomAttrs, this>) {
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<CustomAttrs, this>) {
super.onremove(vnode);
this.scrollListener.stop();
}
view() {
return <div className="MessageStream">{this.attrs.state.isLoading() ? <LoadingIndicator /> : this.content()}</div>;
}
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(
<div className="MessageStream-item" key="loadPrevious">
<Button
onclick={() => this.whileMaintainingScroll(() => this.attrs.state.loadNext())}
type="button"
className="Button Button--block MessageStream-loadPrev"
>
{app.translator.trans('flarum-messages.forum.messages_page.stream.load_previous_button')}
</Button>
</div>
);
if (LoadingPost) {
items.push(
<div className="MessageStream-item" key="loading-prev">
<LoadingPost />
</div>
);
}
}
messages.forEach((message, index) => items.push(this.messageItem(message, index)));
if (ReplyPlaceholder) {
items.push(
<div className="MessageStream-item" key="reply" /*data-index={this.attrs.state.count()}*/>
<ReplyPlaceholder
discussion={this.attrs.dialog}
onclick={() => {
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)}
/>
</div>
);
}
return items;
}
messageItem(message: DialogMessage, index: number) {
return (
<div className="MessageStream-item" key={index} data-id={message.id()}>
{this.timeGap(message)}
<Message message={message} />
</div>
);
}
timeGap(message: DialogMessage): Mithril.Children {
if (message.id() === (this.attrs.dialog.data.relationships?.firstMessage.data as ModelIdentifier).id) {
this.lastTime = message.createdAt()!;
return (
<div class="PostStream-timeGap">
<span>{app.translator.trans('flarum-messages.forum.messages_page.stream.start_of_the_conversation')}</span>
</div>
);
}
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 (
<div className="PostStream-timeGap">
{/* @ts-ignore */}
<span>
{app.translator.trans('flarum-messages.forum.messages_page.stream.time_lapsed_text', { period: dayjs().add(dt, 'ms').fromNow(true) })}
</span>
</div>
);
}
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<void>) {
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<number>('messageCount') ?? 1) - 1,
});
}
m.redraw();
});
}
}
}

View File

@ -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<CustomAttrs extends IMessagesPageAttrs = IMessagesPageAttrs> extends Page<CustomAttrs> {
protected selectedDialog = Stream<Dialog | null>(null);
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
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<Dialog>('dialogs', dialogId) || ((await app.store.find<Dialog>('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<CustomAttrs, this>) {
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 (
<PageStructure className="MessagesPage Page--vertical" loading={false} hero={this.hero.bind(this)} sidebar={() => <MessagesSidebar />}>
{app.dialogs.isLoading() ? (
<LoadingIndicator />
) : !app.dialogs.hasItems() ? (
<InfoTile icon="far fa-envelope-open">{app.translator.trans('flarum-messages.forum.messages_page.empty_text')}</InfoTile>
) : (
<div className="MessagesPage-content">
<div className="MessagesPage-sidebar" key="sidebar">
<div className="IndexPage-toolbar" key="toolbar">
<ul className="IndexPage-toolbar-view">{listItems(this.viewItems().toArray())}</ul>
<ul className="IndexPage-toolbar-action">{listItems(this.actionItems().toArray())}</ul>
</div>
<DialogList key="list" state={app.dialogs} activeDialog={this.selectedDialog()} />
</div>
{this.selectedDialog() ? (
<DialogSection key="dialog" dialog={this.selectedDialog()} />
) : (
<LoadingIndicator key="loading" display="block" />
)}
</div>
)}
</PageStructure>
);
}
hero(): Mithril.Children {
return (
<header className="Hero MessagesPageHero">
<div className="container">
<div className="containerNarrow">
<h1 className="Hero-title">
<Icon name="fas fa-envelope" /> {app.translator.trans('flarum-messages.forum.messages_page.hero.title')}
</h1>
<div className="Hero-subtitle">{app.translator.trans('flarum-messages.forum.messages_page.hero.subtitle')}</div>
</div>
</div>
</header>
);
}
/**
* 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<Mithril.Children>();
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',
<Dropdown
buttonClassName="Button"
label={sortOptions[app.dialogs.getParams()?.sort || 0] || Object.values(sortOptions)[0]}
accessibleToggleLabel={app.translator.trans('core.forum.index_sort.toggle_dropdown_accessible_label')}
>
{Object.keys(sortOptions).map((value) => {
const label = sortOptions[value];
const active = (app.dialogs.getParams().sort || Object.keys(sortMap)[0]) === value;
return (
<Button icon={active ? 'fas fa-check' : true} onclick={() => app.dialogs.changeSort(value)} active={active}>
{label}
</Button>
);
})}
</Dropdown>
);
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<Mithril.Children>();
items.add(
'refresh',
<Button
title={app.translator.trans('flarum-messages.forum.messages_page.refresh_tooltip')}
aria-label={app.translator.trans('flarum-messages.forum.messages_page.refresh_tooltip')}
icon="fas fa-sync"
className="Button Button--icon"
onclick={() => {
app.dialogs.refresh();
}}
/>
);
if (app.session.user) {
items.add(
'markAllAsRead',
<Button
title={app.translator.trans('flarum-messages.forum.messages_page.mark_all_as_read_tooltip')}
aria-label={app.translator.trans('flarum-messages.forum.messages_page.mark_all_as_read_tooltip')}
icon="fas fa-check"
className="Button Button--icon"
onclick={() => app.dialogs.markAllAsRead()}
/>
);
}
return items;
}
}

View File

@ -0,0 +1,57 @@
import app from 'flarum/forum/app';
import IndexSidebar, { type IndexSidebarAttrs } from 'flarum/forum/components/IndexSidebar';
import Mithril from 'mithril';
import ItemList from 'flarum/common/utils/ItemList';
import Button from 'flarum/common/components/Button';
export interface IMessagesSidebarAttrs extends IndexSidebarAttrs {}
export default class MessagesSidebar<CustomAttrs extends IMessagesSidebarAttrs = IMessagesSidebarAttrs> extends IndexSidebar<CustomAttrs> {
static initAttrs(attrs: IMessagesSidebarAttrs) {
attrs.className = 'MessagesPage-nav';
}
items(): ItemList<Mithril.Children> {
const items = super.items();
const canSendAnyMessage = app.session.user!.attribute<boolean>('canSendAnyMessage');
items.remove('newDiscussion');
items.add(
'newMessage',
<Button
icon="fas fa-edit"
className="Button Button--primary IndexPage-newDiscussion MessagesPage-newMessage"
itemClassName="App-primaryControl"
onclick={() => {
return this.newMessageAction();
}}
disabled={!canSendAnyMessage}
>
{app.translator.trans('flarum-messages.forum.messages_page.new_message_button')}
</Button>,
10
);
return items;
}
/**
* Open the composer for a new message.
*/
newMessageAction(): Promise<unknown> {
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;
});
}
}

View File

@ -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() })),
];

View File

@ -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',
<LinkButton
href={app.route('messages')}
icon="far fa-envelope"
active={app.current.data.routeName && ['messages', 'dialog'].includes(app.current.data.routeName)}
>
{app.translator.trans('flarum-messages.forum.index.messages_link')}
</LinkButton>,
95
);
}
});
extend(HeaderSecondary.prototype, 'items', function (items) {
if (app.session.user?.attribute<boolean>('canSendAnyMessage')) {
items.add('messages', <DialogsDropdown state={app.dropdownDialogs} />, 15);
}
});
// @ts-ignore
extend(UserControls, 'userControls', (items, user) => {
if (app.session.user?.attribute<boolean>('canSendAnyMessage')) {
items.add(
'sendMessage',
<Button
icon="fas fa-envelope"
onclick={() => {
import('flarum/forum/components/ComposerBody').then(() => {
app.composer
.load(() => import('./components/MessageComposer'), {
user: app.session.user,
recipients: [user],
})
.then(() => app.composer.show());
});
}}
>
{app.translator.trans('flarum-messages.forum.user_controls.send_message_button')}
</Button>
);
}
});
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'),
});
});
});

View File

@ -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<P extends DialogListParams = DialogListParams> extends PaginatedListState<Dialog, P> {
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<void> {
if (app.session.user?.attribute<number>('messageCount') !== this.lastCount) {
this.pages = [];
this.location = { page: 1 };
this.lastCount = app.session.user?.attribute<number>('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();
});
}
}

View File

@ -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<P extends MessageStreamParams = MessageStreamParams> extends PaginatedListState<DialogMessage, P> {
constructor(params: P, page: number = 1) {
super(params, page, null);
}
get type(): string {
return 'dialog-messages';
}
public getAllItems(): DialogMessage[] {
return super.getAllItems();
}
}

View File

@ -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/*"]
}
}
}

View File

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

View File

@ -0,0 +1 @@
module.exports = require('flarum-webpack-config')();

View File

View File

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

View File

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

View File

@ -0,0 +1,25 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Flarum\Database\Migration;
use Illuminate\Database\Schema\Blueprint;
return Migration::createTable(
'dialogs',
function (Blueprint $table) {
$table->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();
}
);

View File

@ -0,0 +1,23 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Flarum\Database\Migration;
use Illuminate\Database\Schema\Blueprint;
return Migration::createTable(
'dialog_messages',
function (Blueprint $table) {
$table->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();
}
);

View File

@ -0,0 +1,24 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Flarum\Database\Migration;
use Illuminate\Database\Schema\Blueprint;
return Migration::createTable(
'dialog_user',
function (Blueprint $table) {
$table->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();
}
);

View File

@ -0,0 +1,26 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => 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']);
});
}
];

View File

@ -0,0 +1,15 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Flarum\Database\Migration;
use Flarum\Group\Group;
return Migration::addPermissions([
'dialog.sendMessage' => Group::MEMBER_ID,
]);

View File

@ -0,0 +1,22 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\Access;
use Flarum\Messages\DialogMessage;
use Flarum\User\Access\AbstractPolicy;
use Flarum\User\User;
class DialogMessagePolicy extends AbstractPolicy
{
public function update(User $actor, DialogMessage $dialogMessage): bool
{
return false;
}
}

View File

@ -0,0 +1,27 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\Access;
use Flarum\Messages\Dialog;
use Flarum\User\Access\AbstractPolicy;
use Flarum\User\User;
class DialogPolicy extends AbstractPolicy
{
public function view(User $actor, Dialog $dialog): bool
{
return Dialog::whereVisibleTo($actor)->where('id', $dialog->id)->exists();
}
public function sendMessage(User $actor, Dialog $dialog): bool
{
return $this->view($actor, $dialog) && $actor->hasPermission('dialog.sendMessage');
}
}

View File

@ -0,0 +1,21 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\Access;
use Flarum\User\Access\AbstractPolicy;
use Flarum\User\User;
class GlobalPolicy extends AbstractPolicy
{
public function sendAnyMessage(User $actor): bool
{
return $actor->hasPermission('dialog.sendMessage');
}
}

View File

@ -0,0 +1,23 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\Access;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
class ScopeDialogMessageVisibility
{
public function __invoke(User $actor, Builder $query): void
{
$query->whereHas('dialog', function (Builder $query) use ($actor) {
$query->whereVisibleTo($actor);
});
}
}

View File

@ -0,0 +1,23 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\Access;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
class ScopeDialogVisibility
{
public function __invoke(User $actor, Builder $query): void
{
$query->whereHas('users', function (Builder $query) use ($actor) {
$query->where('user_id', $actor->id);
});
}
}

View File

@ -0,0 +1,226 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\Api\Resource;
use Exception;
use Flarum\Api\Context;
use Flarum\Api\Endpoint;
use Flarum\Api\Resource;
use Flarum\Api\Schema;
use Flarum\Api\Sort\SortColumn;
use Flarum\Bus\Dispatcher;
use Flarum\Foundation\ErrorHandling\LogReporter;
use Flarum\Foundation\ValidationException;
use Flarum\Locale\Translator;
use Flarum\Messages\Command\ReadDialog;
use Flarum\Messages\Dialog;
use Flarum\Messages\DialogMessage;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Tobyz\JsonApiServer\Context as OriginalContext;
/**
* @extends Resource\AbstractDatabaseResource<DialogMessage>
*/
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);
}
}

View File

@ -0,0 +1,168 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\Api\Resource;
use Carbon\Carbon;
use Flarum\Api\Context;
use Flarum\Api\Endpoint;
use Flarum\Api\Resource;
use Flarum\Api\Schema;
use Flarum\Api\Sort\SortColumn;
use Flarum\Bus\Dispatcher;
use Flarum\Locale\Translator;
use Flarum\Messages\Command\ReadDialog;
use Flarum\Messages\Dialog;
use Flarum\Messages\UserDialogState;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Arr;
use Laminas\Diactoros\Response\EmptyResponse;
use Tobyz\JsonApiServer\Context as OriginalContext;
/**
* @extends Resource\AbstractDatabaseResource<Dialog>
*/
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'),
];
}
}

View File

@ -0,0 +1,22 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\Command;
use Flarum\User\User;
class ReadDialog
{
public function __construct(
public int $dialogId,
public User $actor,
public int $lastReadMessageId
) {
}
}

View File

@ -0,0 +1,50 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\Command;
use Flarum\Foundation\DispatchEventsTrait;
use Flarum\Messages\Dialog;
use Flarum\Messages\Dialog\Event\UserDataSaving;
use Flarum\Messages\UserDialogState;
use Illuminate\Contracts\Events\Dispatcher;
class ReadDialogHandler
{
use DispatchEventsTrait;
public function __construct(
protected Dispatcher $events,
) {
}
public function handle(ReadDialog $command): UserDialogState
{
$actor = $command->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;
}
}

View File

@ -0,0 +1,127 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages;
use Flarum\Database\AbstractModel;
use Flarum\Database\ScopeVisibilityTrait;
use Flarum\Foundation\EventGeneratorTrait;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use InvalidArgumentException;
/**
* @property int $id
* @property int|null $first_message_id
* @property int|null $last_message_id
* @property \Carbon\Carbon|null $last_message_at
* @property int|null $last_message_user_id
* @property string $type
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection<int, DialogMessage> $messages
* @property-read \Illuminate\Database\Eloquent\Collection<int, User> $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;
}
}

View File

@ -0,0 +1,20 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\Dialog\Event;
use Flarum\Messages\UserDialogState;
class UserDataSaving
{
public function __construct(
public UserDialogState $state
) {
}
}

View File

@ -0,0 +1,20 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\Dialog\Event;
use Flarum\Messages\UserDialogState;
class UserRead
{
public function __construct(
public UserDialogState $state
) {
}
}

View File

@ -0,0 +1,35 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\Dialog\Filter;
use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\SearchState;
use Illuminate\Database\Eloquent\Builder;
/**
* @implements FilterInterface<DatabaseSearchState>
*/
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');
});
}
}

View File

@ -0,0 +1,51 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages;
use Flarum\Database\AbstractModel;
use Flarum\Database\ScopeVisibilityTrait;
use Flarum\Formatter\Formattable;
use Flarum\Formatter\HasFormattedContent;
use Flarum\Foundation\EventGeneratorTrait;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property int $dialog_id
* @property int|null $user_id
* @property string $content
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property-read Dialog $dialog
* @property-read User|null $user
*/
class DialogMessage extends AbstractModel implements Formattable
{
use EventGeneratorTrait;
use ScopeVisibilityTrait;
use HasFormattedContent;
protected $table = 'dialog_messages';
public $timestamps = true;
protected $guarded = [];
public function dialog(): BelongsTo
{
return $this->belongsTo(Dialog::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -0,0 +1,20 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\DialogMessage\Event;
use Flarum\Messages\DialogMessage;
class Created
{
public function __construct(
public DialogMessage $message
) {
}
}

View File

@ -0,0 +1,21 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\DialogMessage\Event;
use Flarum\Messages\DialogMessage;
class Creating
{
public function __construct(
protected DialogMessage $message,
protected array $data
) {
}
}

View File

@ -0,0 +1,20 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\DialogMessage\Event;
use Flarum\Messages\DialogMessage;
class Updated
{
public function __construct(
protected DialogMessage $message
) {
}
}

View File

@ -0,0 +1,21 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\DialogMessage\Event;
use Flarum\Messages\DialogMessage;
class Updating
{
public function __construct(
protected DialogMessage $message,
protected array $data
) {
}
}

View File

@ -0,0 +1,30 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\DialogMessage\Filter;
use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\SearchState;
/**
* @implements FilterInterface<DatabaseSearchState>
*/
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 ? '!=' : '=');
}
}

View File

@ -0,0 +1,26 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages;
use Flarum\Formatter\Formatter;
use Flarum\Foundation\AbstractServiceProvider;
class DialogServiceProvider extends AbstractServiceProvider
{
public function register(): void
{
//
}
public function boot(Formatter $formatter): void
{
DialogMessage::setFormatter($formatter);
}
}

View File

@ -0,0 +1,29 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\Http\Middleware;
use Flarum\Http\RequestUtil;
use Flarum\Messages\Dialog;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
class PopulateDialogWithActor implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$actor = RequestUtil::getActor($request);
Dialog::setStateUser($actor);
return $handler->handle($request);
}
}

View File

@ -0,0 +1,40 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\Job;
use Flarum\Messages\DialogMessage;
use Flarum\Messages\Notification\MessageReceivedBlueprint;
use Flarum\Notification\NotificationSyncer;
use Flarum\Queue\AbstractJob;
use Flarum\User\User;
use Illuminate\Database\Query\Builder;
class SendMessageNotificationsJob extends AbstractJob
{
public function __construct(
protected DialogMessage $message
) {
}
public function handle(NotificationSyncer $notifications): void
{
$users = User::query()
->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);
}
}

View File

@ -0,0 +1,27 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\Listener;
use Flarum\Messages\DialogMessage;
use Flarum\Messages\Job;
use Illuminate\Contracts\Queue\Queue;
class SendNotificationWhenMessageSent
{
public function __construct(
protected Queue $queue
) {
}
public function handle(DialogMessage\Event\Created $event): void
{
$this->queue->push(new Job\SendMessageNotificationsJob($event->message));
}
}

View File

@ -0,0 +1,65 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\Notification;
use Flarum\Database\AbstractModel;
use Flarum\Locale\TranslatorInterface;
use Flarum\Messages\DialogMessage;
use Flarum\Notification\Blueprint\BlueprintInterface;
use Flarum\Notification\MailableInterface;
use Flarum\User\User;
class MessageReceivedBlueprint implements BlueprintInterface, MailableInterface
{
public function __construct(
public DialogMessage $message
) {
}
public function getFromUser(): ?User
{
return $this->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;
}
}

View File

@ -0,0 +1,23 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\Search;
use Flarum\Messages\DialogMessage;
use Flarum\Search\Database\AbstractSearcher;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
class DialogMessageSearcher extends AbstractSearcher
{
public function getQuery(User $actor): Builder
{
return DialogMessage::whereVisibleTo($actor);
}
}

View File

@ -0,0 +1,23 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\Search;
use Flarum\Messages\Dialog;
use Flarum\Search\Database\AbstractSearcher;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
class DialogSearcher extends AbstractSearcher
{
public function getQuery(User $actor): Builder
{
return Dialog::whereVisibleTo($actor);
}
}

View File

@ -0,0 +1,63 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages;
use Carbon\Carbon;
use Flarum\Database\AbstractModel;
use Flarum\Foundation\EventGeneratorTrait;
use Flarum\Messages\Dialog\Event\UserRead;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property int $dialog_id
* @property int $user_id
* @property \Carbon\Carbon $joined_at
* @property int|null $last_read_message_id
* @property \Carbon\Carbon $last_read_at
* @property-read Dialog $dialog
* @property-read User $user
*/
class UserDialogState extends AbstractModel
{
use EventGeneratorTrait;
protected $table = 'dialog_user';
protected $casts = [
'user_id' => '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;
}
}

View File

View File

@ -0,0 +1,128 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\Tests\integration\api;
use Carbon\Carbon;
use Flarum\Messages\Dialog;
use Flarum\Messages\DialogMessage;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
use Flarum\User\User;
use PHPUnit\Framework\Attributes\DataProvider;
class ListTest extends TestCase
{
use RetrievesAuthorizedUsers;
protected function setUp(): void
{
parent::setUp();
$this->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]],
];
}
}

View File

@ -0,0 +1,112 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\Tests\integration\api\dialog_messages;
use Carbon\Carbon;
use Flarum\Messages\Dialog;
use Flarum\Messages\DialogMessage;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
use Flarum\User\User;
class CreateTest extends TestCase
{
use RetrievesAuthorizedUsers;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}

View File

@ -0,0 +1,112 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Messages\Tests\integration\api\dialogs;
use Carbon\Carbon;
use Flarum\Messages\Dialog;
use Flarum\Messages\DialogMessage;
use Flarum\Messages\UserDialogState;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
use Flarum\User\User;
class UpdateTest extends TestCase
{
use RetrievesAuthorizedUsers;
protected function setUp(): void
{
parent::setUp();
$this->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' => '<p>Hello, Alice!</p>'],
['id' => 103, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</p>'],
['id' => 104, 'dialog_id' => 102, 'user_id' => 4, 'content' => '<p>Hello, Alice!</p>'],
['id' => 105, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</p>'],
['id' => 106, 'dialog_id' => 102, 'user_id' => 4, 'content' => '<p>Hello, Alice!</p>'],
['id' => 107, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</p>'],
['id' => 108, 'dialog_id' => 102, 'user_id' => 4, 'content' => '<p>Hello, Alice!</p>'],
['id' => 109, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</p>'],
['id' => 110, 'dialog_id' => 102, 'user_id' => 4, 'content' => '<p>Hello, Alice!</p>'],
['id' => 111, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</p>'],
],
'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);
}
}

View File

@ -0,0 +1,12 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
$setup = require __DIR__.'/../../../../php-packages/testing/bootstrap/monorepo.php';
$setup->run();

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
backupGlobals="false"
cacheDirectory=".phpunit.cache"
backupStaticProperties="false"
colors="true"
processIsolation="true"
stopOnFailure="false"
bootstrap="../../../php-packages/testing/bootstrap/monorepo.php"
>
<source>
<include>
<directory suffix=".php">../src/</directory>
</include>
</source>
<testsuites>
<testsuite name="Flarum Integration Tests">
<directory suffix="Test.php">./integration</directory>
<exclude>./integration/tmp</exclude>
</testsuite>
</testsuites>
</phpunit>

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