Merge branch '2.x' into dk/info-v2

This commit is contained in:
Daniël Klabbers 2025-02-24 21:57:55 +01:00
commit 2ac40e3e03
135 changed files with 1343 additions and 400 deletions

View File

@ -171,7 +171,7 @@
"mockery/mockery": "^1.5",
"phpunit/phpunit": "^11.0",
"phpstan/phpstan": "^1.10.0",
"larastan/larastan": "2.9.12",
"larastan/larastan": "2.9.14",
"symfony/var-dumper": "^7.0",
"flarum/testing-tests": "*@dev"
},

View File

@ -37,10 +37,8 @@ class ScopeFlagVisibility
if ($actor->hasPermission('discussion.viewFlags')) {
$query->orWhereDoesntHave('post.discussion.tags');
}
}
if (! $actor->hasPermission('discussion.viewFlags')) {
$query->orWhere('flags.user_id', $actor->id);
} elseif (! $actor->hasPermission('discussion.viewFlags')) {
$query->whereRaw('1 = 0');
}
});
}

View File

@ -96,7 +96,7 @@ class ListTest extends TestCase
}
#[Test]
public function regular_user_sees_own_flags_of_visible_posts()
public function regular_user_does_not_see_own_flags_of_visible_posts()
{
$response = $this->send(
$this->request('GET', '/api/flags', [
@ -109,7 +109,7 @@ class ListTest extends TestCase
$data = json_decode($response->getBody()->getContents(), true)['data'];
$ids = Arr::pluck($data, 'id');
$this->assertEqualsCanonicalizing(['2', '4'], $ids);
$this->assertEqualsCanonicalizing([], $ids);
}
#[Test]

View File

@ -122,7 +122,7 @@ class ListWithTagsTest extends TestCase
}
#[Test]
public function regular_user_sees_own_flags()
public function regular_user_does_not_see_own_flags()
{
$response = $this->send(
$this->request('GET', '/api/flags', [
@ -135,7 +135,7 @@ class ListWithTagsTest extends TestCase
$data = json_decode($response->getBody()->getContents(), true)['data'];
$ids = Arr::pluck($data, 'id');
$this->assertEqualsCanonicalizing(['2', '4'], $ids);
$this->assertEqualsCanonicalizing([], $ids);
}
#[Test]

View File

@ -144,7 +144,7 @@ class IncludeFlagsVisibilityTest extends TestCase
'user_with_general_permission_sees_where_unrestricted_tag' => [2, [6, 7, 8]],
'user_with_tag1_permission_sees_tag1_flags' => [3, [1, 2, 3, 4, 5]],
'normal_user_sees_none' => [4, []],
'normal_user_sees_own' => [5, [2, 7, 4, 8]],
'normal_user_does_not_see_own' => [5, []],
];
}
}

View File

@ -24,7 +24,7 @@ return [
->css(__DIR__.'/less/forum.less')
->jsDirectory(__DIR__.'/js/dist/forum')
->route('/messages', 'messages')
->route('/messages/dialog/{id:\d+}', 'messages.dialog'),
->route('/messages/dialog/{id:\d+}[/{near:\d+}]', 'messages.dialog'),
(new Extend\Frontend('admin'))
->js(__DIR__.'/js/dist/admin.js')
@ -51,7 +51,9 @@ return [
(new Extend\ApiResource(Resource\UserResource::class))
->fields(fn () => [
Schema\Boolean::make('canSendAnyMessage')
->get(fn (object $model, Context $context) => $context->getActor()->can('sendAnyMessage')),
->get(fn (User $user, Context $context) => $user->can('sendAnyMessage')),
Schema\Boolean::make('canDeleteOwnMessages')
->visible(fn (User $user, Context $context) => $context->getActor()->is($user)),
Schema\Integer::make('messageCount')
->get(function (object $model, Context $context) {
return Dialog::whereVisibleTo($context->getActor())

View File

@ -3,7 +3,7 @@ import DialogListState from '../forum/states/DialogListState';
declare module 'flarum/forum/routes' {
export interface ForumRoutes {
dialog: (tag: Dialog) => string;
dialog: (dialog: Dialog, near?: number) => string;
}
}
@ -19,3 +19,9 @@ declare module 'flarum/forum/states/ComposerState' {
composingMessageTo(dialog: Dialog): boolean;
}
}
declare module 'flarum/common/models/User' {
export default interface User {
canSendAnyMessage(): boolean;
}
}

View File

@ -1,2 +1,2 @@
declare const _default: (import("flarum/common/extenders/Store").default | import("flarum/common/extenders/Admin").default)[];
declare const _default: (import("flarum/common/extenders/Store").default | import("flarum/common/extenders/Model").default | import("flarum/common/extenders/Admin").default)[];
export default _default;

View File

@ -1,2 +1,2 @@
declare const _default: import("flarum/common/extenders/Store").default[];
declare const _default: (import("flarum/common/extenders/Store").default | import("flarum/common/extenders/Model").default)[];
export default _default;

View File

@ -2,6 +2,7 @@ import Model from 'flarum/common/Model';
import type Dialog from './Dialog';
import type User from 'flarum/common/models/User';
export default class DialogMessage extends Model {
number(): number;
content(): string | null | undefined;
contentHtml(): string | null | undefined;
renderFailed(): boolean | undefined;
@ -9,4 +10,5 @@ export default class DialogMessage extends Model {
createdAt(): Date;
dialog(): false | Dialog;
user(): false | User;
canDelete(): boolean;
}

View File

@ -10,6 +10,7 @@ export default class DialogSection<CustomAttrs extends IDialogStreamAttrs = IDia
protected loading: boolean;
protected messages: MessageStreamState;
oninit(vnode: Mithril.Vnode<CustomAttrs, this>): void;
requestParams(forgetNear?: boolean): any;
view(): JSX.Element;
actionItems(): ItemList<Mithril.Children>;
controlItems(): ItemList<Mithril.Children>;

View File

@ -3,8 +3,10 @@ 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 type MessageStreamState from '../states/MessageStreamState';
export interface IMessageAttrs extends IAbstractPostAttrs {
message: DialogMessage;
state: MessageStreamState;
}
/**
* The `Post` component displays a single post. The basic post template just

View File

@ -7,6 +7,7 @@ export interface IMessagesPageAttrs extends IPageAttrs {
}
export default class MessagesPage<CustomAttrs extends IMessagesPageAttrs = IMessagesPageAttrs> extends Page<CustomAttrs> {
protected selectedDialog: Stream<Dialog | null>;
protected currentDialogId: string | null;
oninit(vnode: Mithril.Vnode<CustomAttrs, this>): void;
dialogRequestParams(): {
include: string;
@ -15,6 +16,7 @@ export default class MessagesPage<CustomAttrs extends IMessagesPageAttrs = IMess
onupdate(vnode: Mithril.VnodeDOM<CustomAttrs, this>): void;
view(): JSX.Element;
hero(): Mithril.Children;
contentItems(): ItemList<Mithril.Children>;
/**
* 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
@ -23,7 +25,7 @@ export default class MessagesPage<CustomAttrs extends IMessagesPageAttrs = IMess
viewItems(): ItemList<Mithril.Children>;
/**
* 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.
* on the results. By default, this is just a "mark all as read" button.
*/
actionItems(): ItemList<Mithril.Children>;
}

View File

@ -1,2 +1,2 @@
declare const _default: (import("flarum/common/extenders/Store").default | import("flarum/common/extenders/Routes").default)[];
declare const _default: (import("flarum/common/extenders/Store").default | import("flarum/common/extenders/Model").default | import("flarum/common/extenders/Routes").default)[];
export default _default;

View File

@ -0,0 +1,17 @@
import ItemList from 'flarum/common/utils/ItemList';
import type Mithril from 'mithril';
import type DialogMessage from '../../common/models/DialogMessage';
import type Message from '../components/Message';
declare const MessageControls: {
controls(message: DialogMessage, context: Message<any>): ItemList<Mithril.Children>;
sections(): {
user: (message: DialogMessage, context: Message) => ItemList<Mithril.Children>;
moderation: (message: DialogMessage, context: Message) => ItemList<Mithril.Children>;
destructive: (message: DialogMessage, context: Message) => ItemList<Mithril.Children>;
};
userControls(message: DialogMessage, context: Message): ItemList<Mithril.Children>;
moderationControls(message: DialogMessage, context: Message): ItemList<Mithril.Children>;
destructiveControls(message: DialogMessage, context: Message): ItemList<Mithril.Children>;
deleteAction(message: DialogMessage, context: Message): Promise<void> | undefined;
};
export default MessageControls;

View File

@ -1,2 +1,2 @@
(()=>{var e={n:t=>{var r=t&&t.__esModule?()=>t.default:()=>t;return e.d(r,{a:r}),r},d:(t,r)=>{for(var a in r)e.o(r,a)&&!e.o(t,a)&&Object.defineProperty(t,a,{enumerable:!0,get:r[a]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t),r:e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})}},t={};(()=>{"use strict";e.r(t),e.d(t,{extend:()=>h});const r=flarum.reg.get("core","admin/app");var a=e.n(r);const s=flarum.reg.get("core","common/extenders");var n=e.n(s);const l=flarum.reg.get("core","common/Model");var o=e.n(l);const i=flarum.reg.get("core","common/utils/computed");var u=e.n(i);const d=flarum.reg.get("core","common/utils/string");class c extends(o()){content(){return o().attribute("content").call(this)}contentHtml(){return o().attribute("contentHtml").call(this)}renderFailed(){return o().attribute("renderFailed").call(this)}contentPlain(){return u()("contentHtml",(e=>"string"==typeof e?(0,d.getPlainContent)(e):e)).call(this)}createdAt(){return o().attribute("createdAt",o().transformDate).call(this)}dialog(){return o().hasOne("dialog").call(this)}user(){return o().hasOne("user").call(this)}}flarum.reg.add("flarum-messages","common/models/DialogMessage",c);const m=flarum.reg.get("core","common/app");var g=e.n(m);class f extends(o()){title(){return o().attribute("title").call(this)}type(){return o().attribute("type").call(this)}lastMessageAt(){return o().attribute("lastMessageAt",o().transformDate).call(this)}createdAt(){return o().attribute("createdAt",o().transformDate).call(this)}users(){return o().hasMany("users").call(this)}firstMessage(){return o().hasOne("firstMessage").call(this)}lastMessage(){return o().hasOne("lastMessage").call(this)}unreadCount(){return o().attribute("unreadCount").call(this)}lastReadMessageId(){return o().attribute("lastReadMessageId").call(this)}lastReadAt(){return o().attribute("lastReadAt",o().transformDate).call(this)}recipient(){let e=this.users();return e?e.find((e=>e&&e.id()!==g().session.user.id())):null}}flarum.reg.add("flarum-messages","common/models/Dialog",f);const h=[(new(n().Store)).add("dialogs",f).add("dialog-messages",c),(new(n().Admin)).permission((()=>({icon:"fas fa-envelope-open-text",label:a().translator.trans("flarum-messages.admin.permissions.send_messages"),permission:"dialog.sendMessage",allowGuest:!1})),"start",98)];a().initializers.add("flarum-messages",(()=>{}))})(),module.exports=t})();
(()=>{var e={n:t=>{var a=t&&t.__esModule?()=>t.default:()=>t;return e.d(a,{a}),a},d:(t,a)=>{for(var s in a)e.o(a,s)&&!e.o(t,s)&&Object.defineProperty(t,s,{enumerable:!0,get:a[s]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t),r:e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})}},t={};(()=>{"use strict";e.r(t),e.d(t,{extend:()=>w});const a=flarum.reg.get("core","admin/app");var s=e.n(a);const r=flarum.reg.get("core","common/extenders");var n=e.n(r);const l=flarum.reg.get("core","common/Model");var o=e.n(l);const i=flarum.reg.get("core","common/utils/computed");var u=e.n(i);const c=flarum.reg.get("core","common/utils/string");class d extends(o()){number(){return o().attribute("number").call(this)}content(){return o().attribute("content").call(this)}contentHtml(){return o().attribute("contentHtml").call(this)}renderFailed(){return o().attribute("renderFailed").call(this)}contentPlain(){return u()("contentHtml",(e=>"string"==typeof e?(0,c.getPlainContent)(e):e)).call(this)}createdAt(){return o().attribute("createdAt",o().transformDate).call(this)}dialog(){return o().hasOne("dialog").call(this)}user(){return o().hasOne("user").call(this)}canDelete(){return o().attribute("canDelete").call(this)}}flarum.reg.add("flarum-messages","common/models/DialogMessage",d);const g=flarum.reg.get("core","common/app");var f=e.n(g);class b extends(o()){title(){return o().attribute("title").call(this)}type(){return o().attribute("type").call(this)}lastMessageAt(){return o().attribute("lastMessageAt",o().transformDate).call(this)}createdAt(){return o().attribute("createdAt",o().transformDate).call(this)}users(){return o().hasMany("users").call(this)}firstMessage(){return o().hasOne("firstMessage").call(this)}lastMessage(){return o().hasOne("lastMessage").call(this)}unreadCount(){return o().attribute("unreadCount").call(this)}lastReadMessageId(){return o().attribute("lastReadMessageId").call(this)}lastReadAt(){return o().attribute("lastReadAt",o().transformDate).call(this)}recipient(){let e=this.users();return e?e.find((e=>e&&e.id()!==f().session.user.id())):null}}flarum.reg.add("flarum-messages","common/models/Dialog",b);const p=flarum.reg.get("core","common/models/User");var _=e.n(p);const h=[(new(n().Store)).add("dialogs",b).add("dialog-messages",d),new(n().Model)(_()).attribute("canSendAnyMessage").attribute("canDeleteOwnMessage")],y=flarum.reg.get("core","admin/components/SettingDropdown");var v=e.n(y);const w=[...h,(new(n().Admin)).permission((()=>({icon:"fas fa-envelope-open-text",label:s().translator.trans("flarum-messages.admin.permissions.send_messages_label"),permission:"dialog.sendMessage",allowGuest:!1})),"start",95).permission((()=>({icon:"far fa-trash-alt",label:s().translator.trans("flarum-messages.admin.permissions.delete_own_messages_label"),id:"flarum-messages.allow_delete_own_messages",setting:()=>(parseInt(s().data.settings["flarum-messages.allow_delete_own_messages"],10),m(v(),{default:"0",key:"flarum-messages.allow_delete_own_messages",options:[{value:"-1",label:s().translator.trans("core.admin.permissions_controls.allow_indefinitely_button")},{value:"10",label:s().translator.trans("core.admin.permissions_controls.allow_ten_minutes_button")},{value:"reply",label:s().translator.trans("core.admin.permissions_controls.allow_until_reply_button")},{value:"0",label:s().translator.trans("core.admin.permissions_controls.allow_never_button")}]}))})),"reply",80)];s().initializers.add("flarum-messages",(()=>{}))})(),module.exports=t})();
//# sourceMappingURL=admin.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,21 +0,0 @@
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

@ -1,18 +0,0 @@
import Extend from 'flarum/common/extenders';
import commonExtend from '../common/extend';
import app from 'flarum/admin/app';
export default [
...commonExtend,
new Extend.Admin().permission(
() => ({
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,45 @@
import Extend from 'flarum/common/extenders';
import commonExtend from '../common/extend';
import app from 'flarum/admin/app';
import SettingDropdown from 'flarum/admin/components/SettingDropdown';
export default [
...commonExtend,
new Extend.Admin()
.permission(
() => ({
icon: 'fas fa-envelope-open-text',
label: app.translator.trans('flarum-messages.admin.permissions.send_messages_label'),
permission: 'dialog.sendMessage',
allowGuest: false,
}),
'start',
95
)
.permission(
() => ({
icon: 'far fa-trash-alt',
label: app.translator.trans('flarum-messages.admin.permissions.delete_own_messages_label'),
id: 'flarum-messages.allow_delete_own_messages',
setting: () => {
const minutes = parseInt(app.data.settings['flarum-messages.allow_delete_own_messages'], 10);
return (
<SettingDropdown
default={'0'}
key="flarum-messages.allow_delete_own_messages"
options={[
{ value: '-1', label: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button') },
{ value: '10', label: app.translator.trans('core.admin.permissions_controls.allow_ten_minutes_button') },
{ value: 'reply', label: app.translator.trans('core.admin.permissions_controls.allow_until_reply_button') },
{ value: '0', label: app.translator.trans('core.admin.permissions_controls.allow_never_button') },
]}
/>
);
},
}),
'reply',
80
),
];

View File

@ -1,9 +1,14 @@
import DialogMessage from './models/DialogMessage';
import Dialog from './models/Dialog';
import Extend from 'flarum/common/extenders';
import User from 'flarum/common/models/User';
export default [
new Extend.Store()
.add('dialogs', Dialog) //
.add('dialog-messages', DialogMessage), //
new Extend.Model(User) //
.attribute<boolean>('canSendAnyMessage')
.attribute<boolean>('canDeleteOwnMessage'),
];

View File

@ -5,6 +5,9 @@ import type Dialog from './Dialog';
import type User from 'flarum/common/models/User';
export default class DialogMessage extends Model {
number() {
return Model.attribute<number>('number').call(this);
}
content() {
return Model.attribute<string | null | undefined>('content').call(this);
}
@ -33,4 +36,8 @@ export default class DialogMessage extends Model {
user() {
return Model.hasOne<User>('user').call(this);
}
canDelete() {
return Model.attribute<boolean>('canDelete').call(this);
}
}

View File

@ -24,14 +24,27 @@ export default class DialogSection<CustomAttrs extends IDialogStreamAttrs = IDia
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode);
this.messages = new MessageStreamState({
this.messages = new MessageStreamState(this.requestParams());
this.messages.refresh();
}
requestParams(forgetNear = false): any {
const params: any = {
filter: {
dialog: this.attrs.dialog.id(),
},
sort: '-createdAt',
});
sort: '-number',
};
this.messages.refresh();
const near = m.route.param('near');
if (near && !forgetNear) {
params.page = params.page || {};
params.page.near = parseInt(near);
}
return params;
}
view() {
@ -42,11 +55,14 @@ export default class DialogSection<CustomAttrs extends IDialogStreamAttrs = IDia
<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>}
<h2 className="DialogSection-header-info-title">
{(recipient && <Link href={app.route.user(recipient!)}>{username(recipient)}</Link>) || username(recipient)}
{recipient && recipient.canSendAnyMessage() ? null : (
<span className="DialogSection-header-info-helperText">
{app.translator.trans('flarum-messages.forum.dialog_section.cannot_reply_text')}
</span>
)}
</h2>
<div className="badges">{listItems(recipient?.badges().toArray() || [])}</div>
</div>
<div className="DialogSection-header-actions">{this.actionItems().toArray()}</div>
@ -59,6 +75,13 @@ export default class DialogSection<CustomAttrs extends IDialogStreamAttrs = IDia
actionItems() {
const items = new ItemList<Mithril.Children>();
items.add(
'back',
<Button className="Button Button--icon DialogSection-back" icon="fas fa-arrow-left" onclick={this.attrs.onback}>
{app.translator.trans('flarum-messages.forum.dialog_section.back_label')}
</Button>
);
items.add(
'details',
<Dropdown

View File

@ -9,9 +9,12 @@ 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';
import MessageControls from '../utils/MessageControls';
import type MessageStreamState from '../states/MessageStreamState';
export interface IMessageAttrs extends IAbstractPostAttrs {
message: DialogMessage;
state: MessageStreamState;
}
/**
@ -29,7 +32,7 @@ export default abstract class Message<CustomAttrs extends IMessageAttrs = IMessa
}
controls(): Mithril.Children[] {
return [];
return MessageControls.controls(this.attrs.message, this).toArray();
}
freshness(): Date {
@ -97,7 +100,7 @@ export default abstract class Message<CustomAttrs extends IMessageAttrs = IMessa
}
avatar(): Mithril.Children {
return this.attrs.message.user() ? <Avatar user={this.attrs.message.user()} /> : '';
return this.attrs.message.user() ? <Avatar user={this.attrs.message.user()} className="Post-avatar" /> : '';
}
headerItems() {
@ -105,7 +108,21 @@ export default abstract class Message<CustomAttrs extends IMessageAttrs = IMessa
const message = this.attrs.message;
items.add('user', <PostUser post={message} />, 100);
items.add('meta', <PostMeta post={message} />);
items.add(
'meta',
<PostMeta
post={message}
permalink={() => {
const dialog = message.dialog();
if (!dialog) {
return null;
}
return app.forum.attribute('baseOrigin') + app.route.dialog(dialog, message.number());
}}
/>
);
return items;
}

View File

@ -77,18 +77,20 @@ export default class MessageStream<CustomAttrs extends IDialogStreamAttrs = IDia
content() {
const items: Mithril.Children[] = [];
const messages = this.attrs.state.getAllItems().sort((a, b) => a.createdAt().getTime() - b.createdAt().getTime());
const messages = Array.from(new Map(this.attrs.state.getAllItems().map((msg) => [msg.id(), msg])).values()).sort(
(a, b) => a.number() - b.number()
);
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">
<div className="MessageStream-item" key="loadNext">
<Button
onclick={() => this.whileMaintainingScroll(() => this.attrs.state.loadNext())}
type="button"
className="Button Button--block MessageStream-loadPrev"
className="Button Button--block MessageStream-loadNext"
>
{app.translator.trans('flarum-messages.forum.messages_page.stream.load_previous_button')}
</Button>
@ -97,7 +99,7 @@ export default class MessageStream<CustomAttrs extends IDialogStreamAttrs = IDia
if (LoadingPost) {
items.push(
<div className="MessageStream-item" key="loading-prev">
<div className="MessageStream-item" key="loading-next">
<LoadingPost />
</div>
);
@ -106,9 +108,31 @@ export default class MessageStream<CustomAttrs extends IDialogStreamAttrs = IDia
messages.forEach((message, index) => items.push(this.messageItem(message, index)));
if (ReplyPlaceholder) {
if (messages[messages.length - 1].id() !== (this.attrs.dialog.data.relationships?.lastMessage.data as ModelIdentifier).id) {
if (LoadingPost) {
items.push(
<div className="MessageStream-item" key="loading-prev">
<LoadingPost />
</div>
);
}
items.push(
<div className="MessageStream-item" key="reply" /*data-index={this.attrs.state.count()}*/>
<div className="MessageStream-item" key="loadPrev">
<Button
onclick={() => this.whileMaintainingScroll(() => this.attrs.state.loadPrev())}
type="button"
className="Button Button--block MessageStream-loadPrev"
>
{app.translator.trans('flarum-messages.forum.messages_page.stream.load_next_button')}
</Button>
</div>
);
}
if (app.session.user!.canSendAnyMessage() && ReplyPlaceholder) {
items.push(
<div className="MessageStream-item" key="reply">
<ReplyPlaceholder
discussion={this.attrs.dialog}
onclick={() => {
@ -135,9 +159,9 @@ export default class MessageStream<CustomAttrs extends IDialogStreamAttrs = IDia
messageItem(message: DialogMessage, index: number) {
return (
<div className="MessageStream-item" key={index} data-id={message.id()}>
<div className="MessageStream-item" key={index} data-id={message.id()} data-number={message.number()}>
{this.timeGap(message)}
<Message message={message} />
<Message message={message} state={this.attrs.state} />
</div>
);
}
@ -177,7 +201,7 @@ export default class MessageStream<CustomAttrs extends IDialogStreamAttrs = IDia
return this.attrs.state.loadNext();
}
if (this.element.scrollTop + this.element.clientHeight === this.element.scrollHeight && this.attrs.state.hasPrev()) {
if (this.element.scrollTop + this.element.clientHeight >= this.element.scrollHeight && this.attrs.state.hasPrev()) {
return this.attrs.state.loadPrev();
}
@ -186,16 +210,34 @@ export default class MessageStream<CustomAttrs extends IDialogStreamAttrs = IDia
}
scrollToBottom() {
this.element.scrollTop = this.element.scrollHeight;
const near = m.route.param('near');
if (near) {
const $message = this.element.querySelector(`.MessageStream-item[data-number="${near}"]`);
if ($message) {
this.element.scrollTop = $message.getBoundingClientRect().top - this.element.getBoundingClientRect().top;
$message.classList.add('flash');
// forget near
window.history.replaceState(null, '', app.route.dialog(this.attrs.dialog));
} else {
this.element.scrollTop = this.element.scrollHeight;
}
} else {
this.element.scrollTop = this.element.scrollHeight;
}
}
whileMaintainingScroll(callback: () => null | Promise<void>) {
const scrollTop = this.element.scrollTop;
const scrollHeight = this.element.scrollHeight;
const closerToBottomThanTop = scrollTop > (scrollHeight - this.element.clientHeight) / 2;
const result = callback();
if (result instanceof Promise) {
if (result instanceof Promise && !closerToBottomThanTop) {
result.then(() => {
requestAnimationFrame(() => {
this.element.scrollTop = this.element.scrollHeight - scrollHeight + scrollTop;

View File

@ -14,11 +14,13 @@ 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';
import classList from 'flarum/common/utils/classList';
export interface IMessagesPageAttrs extends IPageAttrs {}
export default class MessagesPage<CustomAttrs extends IMessagesPageAttrs = IMessagesPageAttrs> extends Page<CustomAttrs> {
protected selectedDialog = Stream<Dialog | null>(null);
protected currentDialogId: string | null = null;
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode);
@ -49,6 +51,7 @@ export default class MessagesPage<CustomAttrs extends IMessagesPageAttrs = IMess
protected async initDialog() {
const dialogId = m.route.param('id');
this.currentDialogId = dialogId;
const title = app.translator.trans('flarum-messages.forum.messages_page.title', {}, true);
@ -94,19 +97,12 @@ export default class MessagesPage<CustomAttrs extends IMessagesPageAttrs = IMess
) : !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
className={classList('MessagesPage-content', {
'MessagesPage-content--onDialog': this.currentDialogId,
})}
>
{this.contentItems().toArray()}
</div>
)}
</PageStructure>
@ -128,6 +124,40 @@ export default class MessagesPage<CustomAttrs extends IMessagesPageAttrs = IMess
);
}
contentItems() {
const items = new ItemList<Mithril.Children>();
items.add(
'sidebar',
<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>,
100
);
items.add(
'dialog',
this.selectedDialog() ? (
<DialogSection
key="dialog"
dialog={this.selectedDialog()}
onback={() => {
this.currentDialogId = null;
}}
/>
) : (
<LoadingIndicator key="loading" display="block" />
),
80
);
return items;
}
/**
* 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
@ -168,7 +198,7 @@ export default class MessagesPage<CustomAttrs extends IMessagesPageAttrs = IMess
/**
* 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.
* on the results. By default, this is just a "mark all as read" button.
*/
actionItems() {
const items = new ItemList<Mithril.Children>();

View File

@ -14,8 +14,6 @@ export default class MessagesSidebar<CustomAttrs extends IMessagesSidebarAttrs =
items(): ItemList<Mithril.Children> {
const items = super.items();
const canSendAnyMessage = app.session.user!.attribute<boolean>('canSendAnyMessage');
items.remove('newDiscussion');
items.add(
@ -27,9 +25,11 @@ export default class MessagesSidebar<CustomAttrs extends IMessagesSidebarAttrs =
onclick={() => {
return this.newMessageAction();
}}
disabled={!canSendAnyMessage}
disabled={!app.session.user!.canSendAnyMessage()}
>
{app.translator.trans('flarum-messages.forum.messages_page.new_message_button')}
{app.session.user!.canSendAnyMessage()
? app.translator.trans('flarum-messages.forum.messages_page.send_message_button')
: app.translator.trans('flarum-messages.forum.messages_page.cannot_send_message_button')}
</Button>,
10
);

View File

@ -9,5 +9,6 @@ export default [
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() })),
.add('dialog.message', '/messages/dialog/:id/:near', () => import('./components/MessagesPage'))
.helper('dialog', (dialog: Dialog, near?: number) => app.route(near ? 'dialog.message' : 'dialog', { id: dialog.id(), near: near })),
];

View File

@ -8,6 +8,7 @@ import Button from 'flarum/common/components/Button';
import type Dialog from '../common/models/Dialog';
import DialogsDropdown from './components/DialogsDropdown';
import DialogListState from './states/DialogListState';
import type User from 'flarum/common/models/User';
export { default as extend } from './extend';
@ -44,14 +45,14 @@ app.initializers.add('flarum-messages', () => {
});
extend(HeaderSecondary.prototype, 'items', function (items) {
if (app.session.user?.attribute<boolean>('canSendAnyMessage')) {
if (app.session.user?.canSendAnyMessage()) {
items.add('messages', <DialogsDropdown state={app.dropdownDialogs} />, 15);
}
});
// @ts-ignore
extend(UserControls, 'userControls', (items, user) => {
if (app.session.user?.attribute<boolean>('canSendAnyMessage')) {
extend(UserControls, 'userControls', (items, user: User) => {
if (app.session.user?.canSendAnyMessage()) {
items.add(
'sendMessage',
<Button
@ -66,6 +67,7 @@ app.initializers.add('flarum-messages', () => {
.then(() => app.composer.show());
});
}}
helperText={user.canSendAnyMessage() ? null : app.translator.trans('flarum-messages.forum.user_controls.cannot_reply_text')}
>
{app.translator.trans('flarum-messages.forum.user_controls.send_message_button')}
</Button>

View File

@ -1,5 +1,6 @@
import PaginatedListState, { PaginatedListParams } from 'flarum/common/states/PaginatedListState';
import DialogMessage from '../../common/models/DialogMessage';
import { ApiQueryParamsPlural } from 'flarum/common/Store';
export interface MessageStreamParams extends PaginatedListParams {
//

View File

@ -0,0 +1,67 @@
import ItemList from 'flarum/common/utils/ItemList';
import Separator from 'flarum/common/components/Separator';
import type Mithril from 'mithril';
import type DialogMessage from '../../common/models/DialogMessage';
import type Message from '../components/Message';
import Button from 'flarum/common/components/Button';
import app from 'flarum/forum/app';
import extractText from 'flarum/common/utils/extractText';
const MessageControls = {
controls(message: DialogMessage, context: Message<any>) {
const items = new ItemList<Mithril.Children>();
Object.entries(this.sections()).forEach(([section, method]) => {
const controls = method.call(this, message, context).toArray();
if (controls.length) {
controls.forEach((item) => items.add(item.itemName, item));
items.add(section + 'Separator', <Separator />);
}
});
return items;
},
sections() {
return {
user: this.userControls,
moderation: this.moderationControls,
destructive: this.destructiveControls,
};
},
userControls(message: DialogMessage, context: Message) {
return new ItemList<Mithril.Children>();
},
moderationControls(message: DialogMessage, context: Message) {
return new ItemList<Mithril.Children>();
},
destructiveControls(message: DialogMessage, context: Message) {
const items = new ItemList<Mithril.Children>();
if (message.canDelete()) {
items.add(
'delete',
<Button icon="far fa-trash-alt" onclick={() => this.deleteAction(message, context)}>
{app.translator.trans('flarum-messages.forum.message_controls.delete_button')}
</Button>
);
}
return items;
},
deleteAction(message: DialogMessage, context: Message) {
if (!confirm(extractText(app.translator.trans('flarum-messages.forum.message_controls.delete_confirmation')))) return;
return message.delete().then(() => {
context.attrs.state.remove(message);
m.redraw();
});
},
};
export default MessageControls;

View File

@ -1,17 +1,68 @@
.MessagesPage-sidebar {
flex-shrink: 0;
width: 280px;
.MessagesPage {
padding-bottom: 0;
}
.MessagesPage-content {
--messages-page-gap: 32px;
display: flex;
gap: 32px;
gap: var(--messages-page-gap);
.Avatar {
--size: 40px;
}
}
.MessagesPage-sidebar {
flex-shrink: 0;
width: 100%;
.MessagesPage-content--onDialog & {
// margin-inline-start: calc(~"0px - 100% - var(--messages-page-gap)");
display: none;
}
@media @tablet-up {
width: 280px;
.MessagesPage-content--onDialog & {
// margin-inline-start: 0;
display: block;
}
}
}
.DialogSection {
flex-grow: 1;
min-width: 0;
@media @tablet-up {
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;
}
}
}
.MessageComposer-recipients {
display: flex;
align-items: center;
@ -145,34 +196,6 @@
}
}
.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;
@ -191,8 +214,41 @@
}
.MessageStream, .DialogList {
max-height: calc(100vh - var(--header-height) - 140px - 235px);
--additional-gap: 52px;
max-height: calc(100vh - var(--header-height) - 140px - var(--additional-gap));
overflow: auto;
@media @tablet-up {
--additional-gap: 235px;
}
}
.MessageStream .ReplyPlaceholder {
margin-bottom: 24px;
}
.DialogSection-header-actions {
display: flex;
gap: 6px;
}
.DialogSection-header-info-title {
display: flex;
flex-direction: column;
}
.DialogSection-header-info-helperText {
font-size: 0.8rem;
font-weight: normal;
color: var(--control-color);
}
.DialogSection-back {
display: flex;
@media @tablet-up {
display: none;
}
}
.DialogList-loadMore {

View File

@ -3,7 +3,8 @@ flarum-messages:
# Translations in this namespace are used by the admin interface.
admin:
permissions:
send_messages: Send private messages
send_messages_label: Send private messages
delete_own_messages_label: Delete own messages
# Translations in this namespace are used by the forum user interface.
forum:
@ -21,6 +22,8 @@ flarum-messages:
view_all: View all messages
dialog_section:
back_label: Go back
cannot_reply_text: This user cannot reply
controls:
details_button: Details
controls_toggle_label: Dialog control actions
@ -40,17 +43,22 @@ flarum-messages:
newest_button: Newest
oldest_button: Oldest
message_controls:
delete_button: Delete
delete_confirmation: Are you sure you want to delete this message? This action cannot be undone.
messages_page:
empty_text: You have no messages yet. When you send or receive messages, they
will appear here.
cannot_send_message_button: Can't Send a Message
empty_text: No new messages
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
send_message_button: Send a Message
stream:
load_previous_button: Load previous messages
load_next_button: Load next messages
start_of_the_conversation: Start of the conversation
time_lapsed_text: => core.forum.post_stream.time_lapsed_text
title: Messages
@ -63,6 +71,7 @@ flarum-messages:
user_controls:
send_message_button: Send a message
cannot_reply_text: This user cannot reply
notifications:
message_received_text: Message Received notification from {user}

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.
*/
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => function (Builder $schema) {
$schema->table('dialog_messages', function (Blueprint $table) {
$table->unsignedBigInteger('number')->nullable()->after('content');
});
$numbers = [];
$schema->getConnection()
->table('dialogs')
->orderBy('id')
->each(function (object $dialog) use ($schema, &$numbers) {
$numbers[$dialog->id] = 0;
$schema->getConnection()
->table('dialog_messages')
->where('dialog_id', $dialog->id)
->orderBy('id')
->each(function (object $message) use ($schema, &$numbers) {
$schema->getConnection()
->table('dialog_messages')
->where('id', $message->id)
->update(['number' => ++$numbers[$message->dialog_id]]);
});
unset($numbers[$dialog->id]);
});
$schema->table('dialog_messages', function (Blueprint $table) {
$table->unsignedBigInteger('number')->nullable(false)->change();
});
},
'down' => function (Builder $schema) {
$schema->table('dialog_messages', function (Blueprint $table) {
$table->dropColumn('number');
});
}
];

View File

@ -9,14 +9,36 @@
namespace Flarum\Messages\Access;
use Carbon\Carbon;
use Flarum\Messages\DialogMessage;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\Access\AbstractPolicy;
use Flarum\User\User;
class DialogMessagePolicy extends AbstractPolicy
{
public function update(User $actor, DialogMessage $dialogMessage): bool
public function __construct(
protected SettingsRepositoryInterface $settings
) {
}
public function update(User $actor, DialogMessage $message): ?bool
{
return null;
}
public function delete(User $actor, DialogMessage $message): bool|null|string
{
if ($message->user_id === $actor->id) {
$allowHiding = $this->settings->get('flarum-messages.allow_delete_own_messages');
if ($allowHiding === '-1'
|| ($allowHiding === 'reply' && $message->number >= $message->dialog->lastMessage->number)
|| (is_numeric($allowHiding) && $message->created_at->diffInMinutes(new Carbon, true) < $allowHiding)) {
return $this->allow();
}
}
return false;
}
}

View File

@ -27,6 +27,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Tobyz\JsonApiServer\Context as OriginalContext;
use Tobyz\JsonApiServer\Exception\BadRequestException;
/**
* @extends Resource\AbstractDatabaseResource<DialogMessage>
@ -77,6 +78,11 @@ class DialogMessageResource extends Resource\AbstractDatabaseResource
return $actor->can('sendAnyMessage');
}
}),
Endpoint\Delete::make()
->authenticated()
->visible(function (DialogMessage $message, Context $context): bool {
return $context->getActor()->can('delete', $message);
}),
Endpoint\Index::make()
->authenticated()
->defaultInclude([
@ -86,6 +92,7 @@ class DialogMessageResource extends Resource\AbstractDatabaseResource
'mentionsGroups',
'mentionsTags',
])
->defaultSort('-number')
->eagerLoad(function () {
if ($this->extensions->isEnabled('flarum-mentions')) {
return ['mentionsUsers', 'mentionsPosts', 'mentionsGroups', 'mentionsTags'];
@ -93,6 +100,35 @@ class DialogMessageResource extends Resource\AbstractDatabaseResource
return [];
})
->extractOffset(function (Context $context, array $defaultExtracts): int {
$queryParams = $context->request->getQueryParams();
$near = intval(Arr::get($queryParams, 'page.near'));
if ($near > 1) {
$sort = $defaultExtracts['sort'];
$filter = $defaultExtracts['filter'];
$dialogId = $filter['dialog'] ?? null;
if (count($filter) > 1 || ! $dialogId || ($sort && $sort !== ['number' => 'desc'])) {
throw new BadRequestException(
'You can only use page[near] with filter[dialog] and the default sort order'
);
}
$limit = $defaultExtracts['limit'];
$index = DialogMessage::query()
->where('dialog_id', $dialogId)
->where('number', '>=', $near)
->orderBy('number', 'desc')
->whereVisibleTo($context->getActor())
->count();
return max(0, $index - $limit / 2);
}
return $defaultExtracts['offset'];
})
->paginate(),
];
}
@ -101,6 +137,7 @@ class DialogMessageResource extends Resource\AbstractDatabaseResource
{
return [
Schema\Number::make('number'),
Schema\Str::make('content')
->requiredOnCreate()
->writableOnCreate()
@ -134,6 +171,12 @@ class DialogMessageResource extends Resource\AbstractDatabaseResource
->items(1)
->set(fn () => null),
// Read-only.
Schema\Boolean::make('canDelete')
->get(function (DialogMessage $message, Context $context) {
return $context->getActor()->can('delete', $message);
}),
Schema\Relationship\ToOne::make('user')
->type('users')
->includable(),
@ -161,7 +204,7 @@ class DialogMessageResource extends Resource\AbstractDatabaseResource
public function sorts(): array
{
return [
SortColumn::make('createdAt'),
SortColumn::make('number'),
];
}

View File

@ -21,12 +21,14 @@ use Flarum\Tags\Tag;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Query\Expression;
/**
* @property int $id
* @property int $dialog_id
* @property int|null $user_id
* @property string $content
* @property int|Expression $number
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property-read Dialog $dialog
@ -48,6 +50,28 @@ class DialogMessage extends AbstractModel implements Formattable
protected $guarded = [];
protected $casts = [
'dialog_id' => 'integer',
'user_id' => 'integer',
'number' => 'integer',
];
public static function boot()
{
parent::boot();
static::creating(function (self $message) {
$db = static::getConnectionResolver()->connection();
$message->number = new Expression('('.
$db->table('dialog_messages', 'dm')
->whereRaw($db->getTablePrefix().'dm.dialog_id = '.intval($message->dialog_id))
->selectRaw('COALESCE(MAX('.$db->getTablePrefix().'dm.number), 0) + 1')
->toSql()
.')');
});
}
public function dialog(): BelongsTo
{
return $this->belongsTo(Dialog::class);

View File

@ -39,12 +39,12 @@ class ListTest extends TestCase
['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!'],
['id' => 102, 'dialog_id' => 102, 'user_id' => 3, 'content' => 'Hello, Gale!', 'number' => 1],
['id' => 103, 'dialog_id' => 102, 'user_id' => 4, 'content' => 'Hello, Astarion!', 'number' => 2],
['id' => 104, 'dialog_id' => 103, 'user_id' => 3, 'content' => 'Hello, Karlach!', 'number' => 1],
['id' => 105, 'dialog_id' => 103, 'user_id' => 5, 'content' => 'Hello, Astarion!', 'number' => 2],
['id' => 106, 'dialog_id' => 104, 'user_id' => 4, 'content' => 'Hello, Karlach!', 'number' => 1],
['id' => 107, 'dialog_id' => 104, 'user_id' => 5, 'content' => 'Hello, Gale!', 'number' => 2],
],
'dialog_user' => [
['dialog_id' => 102, 'user_id' => 3, 'joined_at' => Carbon::now()],
@ -125,4 +125,49 @@ class ListTest extends TestCase
'Karlach can see messages in dialogs with Astarion and Gale' => [5, [104, 105, 106, 107]],
];
}
public function test_can_list_near_accessible_dialog_messages(): void
{
$messages = [];
for ($i = 1; $i <= 40; $i++) {
$messages[] = ['id' => 200 + $i, 'dialog_id' => 200, 'user_id' => $i % 2 === 0 ? 3 : 4, 'content' => '<t>Hello, Gale!</t>', 'number' => $i];
}
$this->prepareDatabase([
Dialog::class => [
['id' => 200, 'type' => 'direct'],
],
DialogMessage::class => $messages,
'dialog_user' => [
['dialog_id' => 200, 'user_id' => 3, 'joined_at' => Carbon::now()],
['dialog_id' => 200, 'user_id' => 4, 'joined_at' => Carbon::now()],
],
]);
$this->database()->table('dialogs')->where('id', '!=', 200)->delete();
$this->database()->table('dialog_messages')->where('dialog_id', '!=', 200)->delete();
$response = $this->send(
$this->request('GET', '/api/dialog-messages', [
'authenticatedAs' => 3,
])->withQueryParams([
'include' => 'dialog',
'page' => ['near' => 10],
'filter' => ['dialog' => 200],
]),
);
$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->assertEquals(40, $this->database()->table('dialog_messages')->count());
$this->assertCount(19, $data, $prettyJson);
}
}

View File

@ -36,7 +36,7 @@ class CreateTest extends TestCase
['id' => 102, 'type' => 'direct'],
],
DialogMessage::class => [
['id' => 102, 'dialog_id' => 102, 'user_id' => 4, 'content' => 'Hello, Karlach!'],
['id' => 102, 'dialog_id' => 102, 'user_id' => 4, 'content' => 'Hello, Karlach!', 'number' => 1],
],
'dialog_user' => [
['dialog_id' => 102, 'user_id' => 4, 'joined_at' => Carbon::now()],

View File

@ -37,16 +37,16 @@ class UpdateTest extends TestCase
['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>'],
['id' => 102, 'dialog_id' => 102, 'user_id' => 4, 'content' => '<p>Hello, Alice!</p>', 'number' => 1],
['id' => 103, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</p>', 'number' => 2],
['id' => 104, 'dialog_id' => 102, 'user_id' => 4, 'content' => '<p>Hello, Alice!</p>', 'number' => 3],
['id' => 105, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</p>', 'number' => 4],
['id' => 106, 'dialog_id' => 102, 'user_id' => 4, 'content' => '<p>Hello, Alice!</p>', 'number' => 5],
['id' => 107, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</p>', 'number' => 6],
['id' => 108, 'dialog_id' => 102, 'user_id' => 4, 'content' => '<p>Hello, Alice!</p>', 'number' => 7],
['id' => 109, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</p>', 'number' => 8],
['id' => 110, 'dialog_id' => 102, 'user_id' => 4, 'content' => '<p>Hello, Alice!</p>', 'number' => 9],
['id' => 111, 'dialog_id' => 102, 'user_id' => 3, 'content' => '<p>Hello, Bob!</p>', 'number' => 10],
],
'dialog_user' => [
['dialog_id' => 102, 'user_id' => 3, 'last_read_message_id' => 0, 'last_read_at' => null, 'joined_at' => Carbon::now()],

View File

@ -9,6 +9,7 @@
namespace Flarum\ExtensionManager\Command;
use Composer\Semver\Semver;
use Flarum\Extension\Extension;
use Flarum\Extension\ExtensionManager;
use Flarum\ExtensionManager\Composer\ComposerAdapter;
@ -16,16 +17,21 @@ use Flarum\ExtensionManager\Composer\ComposerJson;
use Flarum\ExtensionManager\Exception\ComposerCommandFailedException;
use Flarum\ExtensionManager\Settings\LastUpdateCheck;
use Flarum\ExtensionManager\Support\Util;
use Flarum\Foundation\Application;
use GuzzleHttp\Client;
use Illuminate\Support\Collection;
use Symfony\Component\Console\Input\ArrayInput;
class CheckForUpdatesHandler
{
protected array $meta = [];
public function __construct(
protected ComposerAdapter $composer,
protected LastUpdateCheck $lastUpdateCheck,
protected ExtensionManager $extensions,
protected ComposerJson $composerJson
protected ComposerJson $composerJson,
protected Client $http
) {
}
@ -97,6 +103,10 @@ class CheckForUpdatesHandler
$mainPackageUpdate['required-as'] = $composerJson['require'][$mainPackageUpdate['name']] ?? null;
if (! $this->compatibleWithCurrentFlarumVersion($mainPackageUpdate)) {
continue;
}
$updates->push($mainPackageUpdate);
}
@ -136,4 +146,49 @@ class CheckForUpdatesHandler
return $output->getContents();
}
private function compatibleWithCurrentFlarumVersion(array $mainPackageUpdate): bool
{
if (empty($mainPackageUpdate['latest-major']) || str_contains($mainPackageUpdate['latest-major'], 'dev-')) {
return true;
}
if (! empty($this->meta[$mainPackageUpdate['name']])) {
$json = $this->meta[$mainPackageUpdate['name']];
} else {
$response = $this->http->get("https://repo.packagist.org/p2/{$mainPackageUpdate['name']}.json");
$body = $response->getBody()->getContents();
if ($response->getStatusCode() > 299 || $response->getStatusCode() < 200) {
return true;
}
$json = json_decode($body, true);
$this->meta[$mainPackageUpdate['name']] = $json;
}
$packages = new Collection($json['packages'][$mainPackageUpdate['name']] ?? []);
if ($packages->isEmpty()) {
return true;
}
$package = $packages->firstWhere('version', $mainPackageUpdate['latest-major']);
if (! $package) {
return true;
}
$flarumVersion = Application::VERSION;
$require = $package['require']['flarum/core'] ?? null;
if (! $require || str_contains($require, 'dev-')) {
return true;
}
return Semver::satisfies($flarumVersion, $require);
}
}

View File

@ -21,6 +21,9 @@ class Util
if (str_starts_with($currentVersion, 'v')) {
$currentVersion = substr($currentVersion, 1);
}
if (str_starts_with($latestVersion, 'v')) {
$latestVersion = substr($latestVersion, 1);
}
$currentVersion = explode('.', $currentVersion);
$latestVersion = explode('.', $latestVersion);

View File

@ -26,7 +26,7 @@ class UserResourceFields
Schema\Str::make('suspendMessage')
->writable($canSuspend)
->visible(fn (User $user, Context $context) => $context->getActor()->id === $user->id || $canSuspend($user, $context)),
Schema\Date::make('suspendedUntil')
Schema\DateTime::make('suspendedUntil')
->writable($canSuspend)
->visible(fn (User $user, Context $context) => $context->getActor()->id === $user->id || $canSuspend($user, $context))
->nullable(),

View File

@ -1,5 +1,15 @@
export default class TagHero extends Component<import("flarum/common/Component").ComponentAttrs, undefined> {
constructor();
view(): JSX.Element;
/**
* @returns {ItemList<Mithril.Children>}
*/
viewItems(): ItemList<Mithril.Children>;
/**
* @returns {ItemList<Mithril.Children>}
*/
contentItems(): ItemList<Mithril.Children>;
}
import Component from "flarum/common/Component";
import ItemList from "flarum/common/utils/ItemList";
import Mithril from "mithril";

2
extensions/tags/js/dist/admin.js generated vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
extensions/tags/js/dist/forum.js generated vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -78,14 +78,14 @@ export default function () {
'tag',
<Dropdown
className="Dropdown--restrictByTag"
buttonClassName="Button Button--text"
buttonClassName="Button Button--link"
label={app.translator.trans('flarum-tags.admin.permissions.restrict_by_tag_heading')}
icon="fas fa-plus"
caretIcon={null}
>
{tags.map((tag) => (
<Button icon={true} onclick={() => tag.save({ isRestricted: true })}>
{[tagIcon(tag, { className: 'Button-icon' }), ' ', tag.name()]}
<Button icon={tagIcon(tag, { className: 'Button-icon' })} onclick={() => tag.save({ isRestricted: true })}>
{tag.name()}
</Button>
))}
</Dropdown>

View File

@ -19,7 +19,12 @@ function tagItem(tag) {
<div className="TagListItem-info">
{tagIcon(tag)}
<span className="TagListItem-name">{tag.name()}</span>
<Button className="Button Button--link" icon="fas fa-pencil-alt" onclick={() => app.modal.show(EditTagModal, { model: tag })} />
<Button
className="Button Button--link"
icon="fas fa-pencil-alt"
aria-label={app.translator.trans('flarum-tags.admin.tags.edit_tag_label', { tag: tag.name() })}
onclick={() => app.modal.show(EditTagModal, { model: tag })}
/>
</div>
{!tag.isChild() && tag.position() !== null && (
<ol className="TagListItem-children TagList">

View File

@ -2,6 +2,8 @@ import Component from 'flarum/common/Component';
import textContrastClass from 'flarum/common/helpers/textContrastClass';
import tagIcon from '../../common/helpers/tagIcon';
import classList from 'flarum/common/utils/classList';
import ItemList from 'flarum/common/utils/ItemList';
import Mithril from 'mithril';
export default class TagHero extends Component {
view() {
@ -13,15 +15,39 @@ export default class TagHero extends Component {
className={classList('Hero', 'TagHero', { 'TagHero--colored': color, [textContrastClass(color)]: color })}
style={color ? { '--hero-bg': color } : undefined}
>
<div className="container">
<div className="containerNarrow">
<h1 className="Hero-title">
{tag.icon() && tagIcon(tag, {}, { useColor: false })} {tag.name()}
</h1>
<div className="Hero-subtitle">{tag.description()}</div>
</div>
</div>
<div className="container">{this.viewItems().toArray()}</div>
</header>
);
}
/**
* @returns {ItemList<Mithril.Children>}
*/
viewItems() {
const items = new ItemList();
items.add('content', <div className="containerNarrow">{this.contentItems().toArray()}</div>, 80);
return items;
}
/**
* @returns {ItemList<Mithril.Children>}
*/
contentItems() {
const items = new ItemList();
const tag = this.attrs.model;
items.add(
'tag-title',
<h1 className="Hero-title">
{tag.icon() && tagIcon(tag, {}, { useColor: false })} {tag.name()}
</h1>,
100
);
items.add('tag-subtitle', <div className="Hero-subtitle">{tag.description()}</div>, 90);
return items;
}
}

View File

@ -56,6 +56,7 @@ flarum-tags:
about_tags_text: "Tags are used to categorize discussions. Primary tags are like traditional forum categories: they can be arranged in a two-level hierarchy. Secondary tags do not have hierarchy or order, and are useful for micro-categorization."
create_primary_tag_button: Create Primary Tag
create_secondary_tag_button: Create Secondary Tag
edit_tag_label: Edit Tag {tag}
primary_heading: Primary Tags
secondary_heading: Secondary Tags
settings_heading: Settings

View File

@ -7,6 +7,7 @@ export type SettingDropdownOption = {
export interface ISettingDropdownAttrs extends ISelectDropdownAttrs {
setting?: string;
options: Array<SettingDropdownOption>;
default: any;
}
export default class SettingDropdown<CustomAttrs extends ISettingDropdownAttrs = ISettingDropdownAttrs> extends SelectDropdown<CustomAttrs> {
static initAttrs(attrs: ISettingDropdownAttrs): void;

View File

@ -68,6 +68,7 @@ export default class UserListPage extends AdminPage {
* See `UserListPage.tsx` for examples.
*/
columns(): ItemList<ColumnData>;
userActionItems(user: User): ItemList<Mithril.Children>;
headerInfo(): {
className: string;
icon: string;

View File

@ -5,6 +5,10 @@ export default class SearchManager<State extends SearchState = SearchState> {
* The minimum query length before sources are searched.
*/
static MIN_SEARCH_LEN: number;
/**
* Time to wait (in milliseconds) after the user stops typing before triggering a search.
*/
static SEARCH_DEBOUNCE_TIME_MS: number;
/**
* An object which stores previously searched queries and provides convenient
* tools for retrieving and managing search values.

View File

@ -5,8 +5,10 @@ export interface IButtonAttrs extends ComponentAttrs {
* Class(es) of an optional icon to be rendered within the button.
*
* If provided, the button will gain a `has-icon` class.
*
* You may also provide a rendered icon element directly.
*/
icon?: string;
icon?: string | boolean | Mithril.Children;
/**
* Disables button from user input.
*
@ -36,6 +38,12 @@ export interface IButtonAttrs extends ComponentAttrs {
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#attr-type
*/
type?: string;
/**
* Helper text. Displayed under the button label.
*
* Default: `null`
*/
helperText?: Mithril.Children;
}
/**
* The `Button` component defines an element which, when clicked, performs an
@ -54,4 +62,5 @@ export default class Button<CustomAttrs extends IButtonAttrs = IButtonAttrs> ext
* Get the template for the button's content.
*/
protected getButtonContent(children: Mithril.Children): Mithril.ChildArray;
protected getButtonSubContent(): Mithril.Children;
}

View File

@ -13,6 +13,8 @@ export interface IDropdownAttrs extends ComponentAttrs {
caretIcon?: string;
/** The label of the dropdown toggle button. Defaults to 'Controls'. */
label: Mithril.Children;
/** The helper text to display under the button label. */
helperText: Mithril.Children;
/** The label used to describe the dropdown toggle button to assistive readers. Defaults to 'Toggle dropdown menu'. */
accessibleToggleLabel?: string;
/** An optional tooltip to show when hovering over the dropdown toggle button. */
@ -42,5 +44,6 @@ export default class Dropdown<CustomAttrs extends IDropdownAttrs = IDropdownAttr
* Get the template for the button's content.
*/
getButtonContent(children: Mithril.ChildArray): Mithril.ChildArray;
protected getButtonSubContent(): Mithril.Children;
getMenu(items: Mithril.Vnode<any, any>[]): Mithril.Vnode<any, any>;
}

View File

@ -0,0 +1,21 @@
import Component, { ComponentAttrs } from '../Component';
import ItemList from '../utils/ItemList';
import type Mithril from 'mithril';
export interface IIPAddressAttrs extends ComponentAttrs {
ip: string | undefined | null;
}
/**
* A component to wrap an IP address for display.
* Designed to be customizable for different use cases.
*
* @example
* <IPAddress ip="127.0.0.1" />
* @example
* <IPAddress ip={post.ipAddress()} />
*/
export default class IPAddress<CustomAttrs extends IIPAddressAttrs = IIPAddressAttrs> extends Component<CustomAttrs> {
ip: string;
oninit(vnode: Mithril.Vnode<CustomAttrs, this>): void;
view(): JSX.Element;
viewItems(): ItemList<Mithril.Children>;
}

View File

@ -11,6 +11,7 @@ export default class Post extends Model {
contentHtml(): string | null | undefined;
renderFailed(): boolean | undefined;
contentPlain(): string | null | undefined;
ipAddress(): string | null | undefined;
editedAt(): Date | null | undefined;
editedUser(): false | User | null;
isEdited(): boolean;

View File

@ -1,5 +1,5 @@
import Model from '../Model';
import { ApiQueryParamsPlural, ApiResponsePlural } from '../Store';
import type Model from '../Model';
import type { ApiQueryParamsPlural, ApiResponsePlural } from '../Store';
import type Mithril from 'mithril';
export type SortMapItem = string | {
sort: string;
@ -8,9 +8,9 @@ export type SortMapItem = string | {
export type SortMap = {
[key: string]: SortMapItem;
};
export interface Page<TModel> {
export interface Page<TModel extends Model> {
number: number;
items: TModel[];
items: ApiResponsePlural<TModel> | TModel[];
hasPrev?: boolean;
hasNext?: boolean;
}
@ -51,6 +51,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
* Load a new page of results.
*/
protected loadPage(page?: number): Promise<ApiResponsePlural<T>>;
protected mutateRequestParams(params: ApiQueryParamsPlural, page: number): ApiQueryParamsPlural;
/**
* Get the parameters that should be passed in the API request.
* Do not include page offset unless subclass overrides loadPage.
@ -110,4 +111,5 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
currentSort(): string | undefined;
changeSort(sort: string): void;
changeFilter(key: string, value: any): void;
remove(model: T): void;
}

View File

@ -13,6 +13,8 @@ export default class IndexPage<CustomAttrs extends IIndexPageAttrs = IIndexPageA
lastDiscussion?: Discussion;
oninit(vnode: Mithril.Vnode<CustomAttrs, this>): void;
view(): JSX.Element;
contentItems(): ItemList<Mithril.Children>;
toolbarItems(): ItemList<Mithril.Children>;
setTitle(): void;
oncreate(vnode: Mithril.VnodeDOM<CustomAttrs, this>): void;
onbeforeremove(vnode: Mithril.VnodeDOM<CustomAttrs, this>): void;

View File

@ -1,11 +1,13 @@
/// <reference types="mithril" />
import Component, { type ComponentAttrs } from '../../common/Component';
import Post from '../../common/models/Post';
import type Model from '../../common/Model';
import type User from '../../common/models/User';
import ItemList from '../../common/utils/ItemList';
import type Mithril from 'mithril';
type ModelType = Post | (Model & {
user: () => User | null | false;
createdAt: () => Date;
ipAddress: undefined | (() => string | null | undefined);
});
export interface IPostMetaAttrs extends ComponentAttrs {
/** Can be a post or similar model like private message */
@ -19,10 +21,16 @@ export interface IPostMetaAttrs extends ComponentAttrs {
*/
export default class PostMeta<CustomAttrs extends IPostMetaAttrs = IPostMetaAttrs> extends Component<CustomAttrs> {
view(): JSX.Element;
viewItems(): ItemList<Mithril.Children>;
metaItems(): ItemList<Mithril.Children>;
/**
* Get the permalink for the given post.
*/
getPermalink(post: ModelType): null | string;
/**
* Selects the permalink input when the dropdown is shown.
*/
selectPermalink(e: MouseEvent): void;
postIdentifier(post: ModelType): string | null;
}
export {};

View File

@ -9,5 +9,13 @@
export default class PostPreview extends Component<import("../../common/Component").ComponentAttrs, undefined> {
constructor();
view(): JSX.Element;
/**
* @returns {string|undefined|null}
*/
content(): string | undefined | null;
/**
* @returns {string}
*/
excerpt(): string;
}
import Component from "../../common/Component";

View File

@ -16,6 +16,10 @@ export default class PostStream extends Component<import("../../common/Component
stream: any;
scrollListener: ScrollListener | undefined;
view(): JSX.Element;
/**
* @returns {ItemList<import('mithril').Children>}
*/
afterFirstPostItems(): ItemList<import('mithril').Children>;
/**
* @returns {ItemList<import('mithril').Children>}
*/

View File

@ -14,6 +14,9 @@ export default class PostStreamScrubber extends Component<import("../../common/C
handlers: {} | undefined;
scrollListener: ScrollListener | undefined;
view(): JSX.Element;
firstPostLabel(): string | any[];
unreadLabel(unreadCount: any): any[];
lastPostLabel(): string | any[];
onupdate(vnode: any): void;
oncreate(vnode: any): void;
dragging: boolean | undefined;

View File

@ -10,6 +10,8 @@ export interface IWelcomeHeroAttrs {
export default class WelcomeHero extends Component<IWelcomeHeroAttrs> {
oninit(vnode: Mithril.Vnode<IWelcomeHeroAttrs, this>): void;
view(vnode: Mithril.Vnode<IWelcomeHeroAttrs, this>): JSX.Element | null;
viewItems(): ItemList<Mithril.Children>;
contentItems(): ItemList<Mithril.Children>;
/**
* Hide the welcome hero.
*/
@ -20,5 +22,4 @@ export default class WelcomeHero extends Component<IWelcomeHeroAttrs> {
* @returns if the welcome hero is hidden.
*/
isHidden(): boolean;
welcomeItems(): ItemList<Mithril.Children>;
}

View File

@ -9,6 +9,11 @@ declare namespace PostControls {
* @return {ItemList<import('mithril').Children>}')}
*/
function controls(post: import("../../common/models/Post").default, context: import("../../common/Component").default<any, any>): ItemList<import("mithril").Children>;
function sections(): {
user: (post: import("../../common/models/Post").default, context: import("../../common/Component").default<any, any>) => ItemList<import("mithril").Children>;
moderation: (post: import("../../common/models/Post").default, context: import("../../common/Component").default<any, any>) => ItemList<import("mithril").Children>;
destructive: (post: import("../../common/models/Post").default, context: import("../../common/Component").default<any, any>) => ItemList<import("mithril").Children>;
};
/**
* Get controls for a post pertaining to the current user (e.g. report).
*

2
framework/core/js/dist/admin.js generated vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
framework/core/js/dist/forum.js generated vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -93,7 +93,7 @@ export default class MailPage<CustomAttrs extends IPageAttrs = IPageAttrs> exten
mailSettingItems(): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
const fields = this.driverFields![this.setting('mail_driver')()];
const fields = this.driverFields![this.setting('mail_driver')()] || {};
const fieldKeys = Object.keys(fields);
if (this.status!.sending) {

View File

@ -59,7 +59,12 @@ export default class PermissionGrid<CustomAttrs extends IPermissionGridAttrs = I
<th>
{scope.label}{' '}
{!!scope.onremove && (
<Button icon="fas fa-times" className="Button Button--text PermissionGrid-removeScope" onclick={scope.onremove} />
<Button
icon="fas fa-times"
className="Button Button--text PermissionGrid-removeScope"
aria-label={app.translator.trans('core.admin.permissions.remove_scope_label', { scope: scope.label })}
onclick={scope.onremove}
/>
)}
</th>
))}

View File

@ -28,7 +28,13 @@ export default class SessionDropdown<CustomAttrs extends ISessionDropdownAttrs =
getButtonContent() {
const user = app.session.user;
return [<Avatar user={user} />, ' ', <span className="Button-label">{username(user)}</span>];
return [
<Avatar user={user} />,
' ',
<span className="Button-label">
<span className="Button-labelText">{username(user)}</span>
</span>,
];
}
/**

View File

@ -12,6 +12,7 @@ export type SettingDropdownOption = {
export interface ISettingDropdownAttrs extends ISelectDropdownAttrs {
setting?: string;
options: Array<SettingDropdownOption>;
default: any;
}
export default class SettingDropdown<CustomAttrs extends ISettingDropdownAttrs = ISettingDropdownAttrs> extends SelectDropdown<CustomAttrs> {
@ -33,7 +34,7 @@ export default class SettingDropdown<CustomAttrs extends ISettingDropdownAttrs =
return super.view({
...vnode,
children: this.attrs.options.map(({ value, label }) => {
const active = app.data.settings[this.attrs.setting!] === value;
const active = (app.data.settings[this.attrs.setting!] ?? this.attrs.default) === value;
return (
<Button icon={active ? 'fas fa-check' : true} onclick={saveSettings.bind(this, { [this.attrs.setting!]: value })} active={active}>

View File

@ -4,6 +4,7 @@ import app from '../../admin/app';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import Button from '../../common/components/Button';
import Dropdown from '../../common/components/Dropdown';
import listItems from '../../common/helpers/listItems';
@ -363,17 +364,18 @@ export default class UserListPage extends AdminPage {
);
columns.add(
'editUser',
'userActions',
{
name: app.translator.trans('core.admin.users.grid.columns.edit_user.title'),
name: app.translator.trans('core.admin.users.grid.columns.user_actions.title'),
content: (user: User) => (
<Button
className="Button UserList-editModalBtn"
title={app.translator.trans('core.admin.users.grid.columns.edit_user.tooltip', { username: user.username() })}
onclick={() => app.modal.show(() => import('../../common/components/EditUserModal'), { user })}
<Dropdown
className="UserList-userActions"
buttonClassName="Button UserList-userActionsBtn Button--icon Button--flat"
menuClassName="Dropdown-menu--right"
icon="fas fa-ellipsis-h"
>
{app.translator.trans('core.admin.users.grid.columns.edit_user.button')}
</Button>
{this.userActionItems(user).toArray()}
</Dropdown>
),
},
-90
@ -382,6 +384,23 @@ export default class UserListPage extends AdminPage {
return columns;
}
userActionItems(user: User): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
items.add(
'editUser',
<Button
icon="fas fa-pencil-alt"
title={app.translator.trans('core.admin.users.grid.columns.user_actions.edit_user.tooltip', { username: user.displayName() }, true)}
onclick={() => app.modal.show(() => import('../../common/components/EditUserModal'), { user })}
>
{app.translator.trans('core.admin.users.grid.columns.user_actions.edit_user.button')}
</Button>
);
return items;
}
headerInfo() {
return {
className: 'UserListPage',

View File

@ -7,6 +7,11 @@ export default class SearchManager<State extends SearchState = SearchState> {
*/
public static MIN_SEARCH_LEN = 3;
/**
* Time to wait (in milliseconds) after the user stops typing before triggering a search.
*/
public static SEARCH_DEBOUNCE_TIME_MS = 250;
/**
* An object which stores previously searched queries and provides convenient
* tools for retrieving and managing search values.

View File

@ -11,8 +11,10 @@ export interface IButtonAttrs extends ComponentAttrs {
* Class(es) of an optional icon to be rendered within the button.
*
* If provided, the button will gain a `has-icon` class.
*
* You may also provide a rendered icon element directly.
*/
icon?: string;
icon?: string | boolean | Mithril.Children;
/**
* Disables button from user input.
*
@ -42,6 +44,12 @@ export interface IButtonAttrs extends ComponentAttrs {
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#attr-type
*/
type?: string;
/**
* Helper text. Displayed under the button label.
*
* Default: `null`
*/
helperText?: Mithril.Children;
}
/**
@ -56,7 +64,7 @@ export interface IButtonAttrs extends ComponentAttrs {
*/
export default class Button<CustomAttrs extends IButtonAttrs = IButtonAttrs> extends Component<CustomAttrs> {
view(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
let { type, 'aria-label': ariaLabel, icon: iconName, disabled, loading, className, class: _class, ...attrs } = this.attrs;
let { type, 'aria-label': ariaLabel, icon: iconName, disabled, loading, className, class: _class, helperText, ...attrs } = this.attrs;
// If no `type` attr provided, set to "button"
type ||= 'button';
@ -74,6 +82,7 @@ export default class Button<CustomAttrs extends IButtonAttrs = IButtonAttrs> ext
hasIcon: iconName,
disabled: disabled || loading,
loading: loading,
hasSubContent: !!this.getButtonSubContent(),
});
const buttonAttrs = {
@ -104,12 +113,21 @@ export default class Button<CustomAttrs extends IButtonAttrs = IButtonAttrs> ext
* Get the template for the button's content.
*/
protected getButtonContent(children: Mithril.Children): Mithril.ChildArray {
const iconName = this.attrs.icon;
const icon = this.attrs.icon;
return [
iconName && <Icon name={iconName} className="Button-icon" />,
children && <span className="Button-label">{children}</span>,
icon && (typeof icon === 'string' || icon === true ? <Icon name={icon} className="Button-icon" /> : icon),
children && (
<span className="Button-label">
<span className="Button-labelText">{children}</span>
{this.getButtonSubContent()}
</span>
),
this.attrs.loading && <LoadingIndicator size="small" display="inline" />,
];
}
protected getButtonSubContent(): Mithril.Children {
return this.attrs.helperText ? <span className="Button-helperText">{this.attrs.helperText}</span> : null;
}
}

View File

@ -19,6 +19,8 @@ export interface IDropdownAttrs extends ComponentAttrs {
caretIcon?: string;
/** The label of the dropdown toggle button. Defaults to 'Controls'. */
label: Mithril.Children;
/** The helper text to display under the button label. */
helperText: Mithril.Children;
/** The label used to describe the dropdown toggle button to assistive readers. Defaults to 'Toggle dropdown menu'. */
accessibleToggleLabel?: string;
/** An optional tooltip to show when hovering over the dropdown toggle button. */
@ -157,11 +159,18 @@ export default class Dropdown<CustomAttrs extends IDropdownAttrs = IDropdownAttr
getButtonContent(children: Mithril.ChildArray): Mithril.ChildArray {
return [
this.attrs.icon ? <Icon name={this.attrs.icon} className="Button-icon" /> : '',
<span className="Button-label">{this.attrs.label}</span>,
<span className="Button-label">
<span className="Button-labelText">{this.attrs.label}</span>
{this.getButtonSubContent()}
</span>,
this.attrs.caretIcon ? <Icon name={this.attrs.caretIcon} className="Button-caret" /> : '',
];
}
protected getButtonSubContent(): Mithril.Children {
return this.attrs.helperText ? <span className="Button-helperText">{this.attrs.helperText}</span> : null;
}
getMenu(items: Mithril.Vnode<any, any>[]): Mithril.Vnode<any, any> {
return <ul className={'Dropdown-menu dropdown-menu ' + this.attrs.menuClassName}>{items}</ul>;
}

View File

@ -0,0 +1,38 @@
import Component, { ComponentAttrs } from '../Component';
import ItemList from '../utils/ItemList';
import type Mithril from 'mithril';
export interface IIPAddressAttrs extends ComponentAttrs {
ip: string | undefined | null;
}
/**
* A component to wrap an IP address for display.
* Designed to be customizable for different use cases.
*
* @example
* <IPAddress ip="127.0.0.1" />
* @example
* <IPAddress ip={post.ipAddress()} />
*/
export default class IPAddress<CustomAttrs extends IIPAddressAttrs = IIPAddressAttrs> extends Component<CustomAttrs> {
ip!: string;
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode);
this.ip = this.attrs.ip || '';
}
view() {
return <span className="IPAddress">{this.viewItems().toArray()}</span>;
}
viewItems(): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
items.add('ip', <span className="IPAddress-value">{this.ip}</span>, 100);
return items;
}
}

View File

@ -23,6 +23,7 @@ export default class Pagination<CustomAttrs extends IPaginationInterface = IPagi
<Button
disabled={currentPage === 1}
title={app.translator.trans('core.lib.pagination.first_button')}
aria-label={app.translator.trans('core.lib.pagination.first_button')}
onclick={() => onChange(1)}
icon="fas fa-step-backward"
className="Button Button--icon Pagination-first"
@ -30,6 +31,7 @@ export default class Pagination<CustomAttrs extends IPaginationInterface = IPagi
<Button
disabled={currentPage === 1}
title={app.translator.trans('core.lib.pagination.back_button')}
aria-label={app.translator.trans('core.lib.pagination.back_button')}
onclick={() => onChange(currentPage - 1)}
icon="fas fa-chevron-left"
className="Button Button--icon Pagination-back"
@ -77,6 +79,7 @@ export default class Pagination<CustomAttrs extends IPaginationInterface = IPagi
<Button
disabled={!moreData}
title={app.translator.trans('core.lib.pagination.next_button')}
aria-label={app.translator.trans('core.lib.pagination.next_button')}
onclick={() => onChange(currentPage + 1)}
icon="fas fa-chevron-right"
className="Button Button--icon Pagination-next"
@ -84,6 +87,7 @@ export default class Pagination<CustomAttrs extends IPaginationInterface = IPagi
<Button
disabled={!moreData}
title={app.translator.trans('core.lib.pagination.last_button')}
aria-label={app.translator.trans('core.lib.pagination.last_button')}
onclick={() => onChange(totalPageCount)}
icon="fas fa-step-forward"
className="Button Button--icon Pagination-last"

View File

@ -324,7 +324,7 @@ export default class SearchModal<CustomAttrs extends ISearchModalAttrs = ISearch
this.searchState.cache(query);
m.redraw();
}, 250);
}, SearchManager.SEARCH_DEBOUNCE_TIME_MS);
}
/**

View File

@ -50,7 +50,9 @@ export default class SelectDropdown<CustomAttrs extends ISelectDropdownAttrs = I
let label = (activeChild && typeof activeChild === 'object' && 'children' in activeChild && activeChild.children) || this.attrs.defaultLabel;
return [
<span className="Button-label">{label}</span>,
<span className="Button-label">
<span className="Button-labelText">{label}</span>
</span>,
this.attrs.caretIcon ? <Icon name={this.attrs.caretIcon} className="Button-caret" /> : null,
];
}

View File

@ -41,6 +41,10 @@ export default class Post extends Model {
}).call(this);
}
ipAddress() {
return Model.attribute<string | null | undefined>('ipAddress').call(this);
}
editedAt() {
return Model.attribute('editedAt', Model.transformDate).call(this);
}

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