mirror of
https://github.com/flarum/framework.git
synced 2025-03-06 18:21:46 +08:00
Merge branch '2.x' into dk/info-v2
This commit is contained in:
commit
2ac40e3e03
@ -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"
|
||||
},
|
||||
|
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -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]
|
||||
|
@ -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]
|
||||
|
@ -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, []],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -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())
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
2
extensions/messages/js/dist-typings/admin/extend.d.ts
generated
vendored
2
extensions/messages/js/dist-typings/admin/extend.d.ts
generated
vendored
@ -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;
|
||||
|
2
extensions/messages/js/dist-typings/common/extend.d.ts
generated
vendored
2
extensions/messages/js/dist-typings/common/extend.d.ts
generated
vendored
@ -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;
|
||||
|
2
extensions/messages/js/dist-typings/common/models/DialogMessage.d.ts
generated
vendored
2
extensions/messages/js/dist-typings/common/models/DialogMessage.d.ts
generated
vendored
@ -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;
|
||||
}
|
||||
|
1
extensions/messages/js/dist-typings/forum/components/DialogSection.d.ts
generated
vendored
1
extensions/messages/js/dist-typings/forum/components/DialogSection.d.ts
generated
vendored
@ -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>;
|
||||
|
2
extensions/messages/js/dist-typings/forum/components/Message.d.ts
generated
vendored
2
extensions/messages/js/dist-typings/forum/components/Message.d.ts
generated
vendored
@ -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
|
||||
|
4
extensions/messages/js/dist-typings/forum/components/MessagesPage.d.ts
generated
vendored
4
extensions/messages/js/dist-typings/forum/components/MessagesPage.d.ts
generated
vendored
@ -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>;
|
||||
}
|
||||
|
2
extensions/messages/js/dist-typings/forum/extend.d.ts
generated
vendored
2
extensions/messages/js/dist-typings/forum/extend.d.ts
generated
vendored
@ -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;
|
||||
|
17
extensions/messages/js/dist-typings/forum/utils/MessageControls.d.ts
generated
vendored
Normal file
17
extensions/messages/js/dist-typings/forum/utils/MessageControls.d.ts
generated
vendored
Normal 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;
|
2
extensions/messages/js/dist/admin.js
generated
vendored
2
extensions/messages/js/dist/admin.js
generated
vendored
@ -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
|
2
extensions/messages/js/dist/admin.js.map
generated
vendored
2
extensions/messages/js/dist/admin.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
extensions/messages/js/dist/forum.js
generated
vendored
2
extensions/messages/js/dist/forum.js
generated
vendored
File diff suppressed because one or more lines are too long
2
extensions/messages/js/dist/forum.js.map
generated
vendored
2
extensions/messages/js/dist/forum.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
extensions/messages/js/dist/forum/components/MessagesPage.js
generated
vendored
2
extensions/messages/js/dist/forum/components/MessagesPage.js
generated
vendored
File diff suppressed because one or more lines are too long
2
extensions/messages/js/dist/forum/components/MessagesPage.js.map
generated
vendored
2
extensions/messages/js/dist/forum/components/MessagesPage.js.map
generated
vendored
File diff suppressed because one or more lines are too long
21
extensions/messages/js/src/@types/shims.d.ts
vendored
21
extensions/messages/js/src/@types/shims.d.ts
vendored
@ -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;
|
||||
}
|
||||
}
|
@ -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
|
||||
),
|
||||
];
|
45
extensions/messages/js/src/admin/extend.tsx
Normal file
45
extensions/messages/js/src/admin/extend.tsx
Normal 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
|
||||
),
|
||||
];
|
@ -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'),
|
||||
];
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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>();
|
||||
|
@ -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
|
||||
);
|
||||
|
@ -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 })),
|
||||
];
|
||||
|
@ -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>
|
||||
|
@ -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 {
|
||||
//
|
||||
|
67
extensions/messages/js/src/forum/utils/MessageControls.tsx
Normal file
67
extensions/messages/js/src/forum/utils/MessageControls.tsx
Normal 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;
|
@ -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 {
|
||||
|
@ -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}
|
||||
|
@ -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');
|
||||
});
|
||||
}
|
||||
];
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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'),
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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()],
|
||||
|
@ -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()],
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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(),
|
||||
|
10
extensions/tags/js/dist-typings/forum/components/TagHero.d.ts
generated
vendored
10
extensions/tags/js/dist-typings/forum/components/TagHero.d.ts
generated
vendored
@ -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
2
extensions/tags/js/dist/admin.js
generated
vendored
File diff suppressed because one or more lines are too long
2
extensions/tags/js/dist/admin.js.map
generated
vendored
2
extensions/tags/js/dist/admin.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
extensions/tags/js/dist/common/components/TagSelectionModal.js
generated
vendored
2
extensions/tags/js/dist/common/components/TagSelectionModal.js
generated
vendored
File diff suppressed because one or more lines are too long
2
extensions/tags/js/dist/common/components/TagSelectionModal.js.map
generated
vendored
2
extensions/tags/js/dist/common/components/TagSelectionModal.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
extensions/tags/js/dist/forum.js
generated
vendored
2
extensions/tags/js/dist/forum.js
generated
vendored
File diff suppressed because one or more lines are too long
2
extensions/tags/js/dist/forum.js.map
generated
vendored
2
extensions/tags/js/dist/forum.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
extensions/tags/js/dist/forum/components/TagDiscussionModal.js
generated
vendored
2
extensions/tags/js/dist/forum/components/TagDiscussionModal.js
generated
vendored
File diff suppressed because one or more lines are too long
2
extensions/tags/js/dist/forum/components/TagDiscussionModal.js.map
generated
vendored
2
extensions/tags/js/dist/forum/components/TagDiscussionModal.js.map
generated
vendored
File diff suppressed because one or more lines are too long
@ -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>
|
||||
|
@ -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">
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
1
framework/core/js/dist-typings/admin/components/SettingDropdown.d.ts
generated
vendored
1
framework/core/js/dist-typings/admin/components/SettingDropdown.d.ts
generated
vendored
@ -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;
|
||||
|
1
framework/core/js/dist-typings/admin/components/UserListPage.d.ts
generated
vendored
1
framework/core/js/dist-typings/admin/components/UserListPage.d.ts
generated
vendored
@ -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;
|
||||
|
4
framework/core/js/dist-typings/common/SearchManager.d.ts
generated
vendored
4
framework/core/js/dist-typings/common/SearchManager.d.ts
generated
vendored
@ -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.
|
||||
|
11
framework/core/js/dist-typings/common/components/Button.d.ts
generated
vendored
11
framework/core/js/dist-typings/common/components/Button.d.ts
generated
vendored
@ -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;
|
||||
}
|
||||
|
3
framework/core/js/dist-typings/common/components/Dropdown.d.ts
generated
vendored
3
framework/core/js/dist-typings/common/components/Dropdown.d.ts
generated
vendored
@ -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>;
|
||||
}
|
||||
|
21
framework/core/js/dist-typings/common/components/IPAddress.d.ts
generated
vendored
Normal file
21
framework/core/js/dist-typings/common/components/IPAddress.d.ts
generated
vendored
Normal 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>;
|
||||
}
|
1
framework/core/js/dist-typings/common/models/Post.d.ts
generated
vendored
1
framework/core/js/dist-typings/common/models/Post.d.ts
generated
vendored
@ -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;
|
||||
|
10
framework/core/js/dist-typings/common/states/PaginatedListState.d.ts
generated
vendored
10
framework/core/js/dist-typings/common/states/PaginatedListState.d.ts
generated
vendored
@ -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;
|
||||
}
|
||||
|
2
framework/core/js/dist-typings/forum/components/IndexPage.d.ts
generated
vendored
2
framework/core/js/dist-typings/forum/components/IndexPage.d.ts
generated
vendored
@ -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;
|
||||
|
10
framework/core/js/dist-typings/forum/components/PostMeta.d.ts
generated
vendored
10
framework/core/js/dist-typings/forum/components/PostMeta.d.ts
generated
vendored
@ -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 {};
|
||||
|
8
framework/core/js/dist-typings/forum/components/PostPreview.d.ts
generated
vendored
8
framework/core/js/dist-typings/forum/components/PostPreview.d.ts
generated
vendored
@ -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";
|
||||
|
4
framework/core/js/dist-typings/forum/components/PostStream.d.ts
generated
vendored
4
framework/core/js/dist-typings/forum/components/PostStream.d.ts
generated
vendored
@ -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>}
|
||||
*/
|
||||
|
3
framework/core/js/dist-typings/forum/components/PostStreamScrubber.d.ts
generated
vendored
3
framework/core/js/dist-typings/forum/components/PostStreamScrubber.d.ts
generated
vendored
@ -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;
|
||||
|
3
framework/core/js/dist-typings/forum/components/WelcomeHero.d.ts
generated
vendored
3
framework/core/js/dist-typings/forum/components/WelcomeHero.d.ts
generated
vendored
@ -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>;
|
||||
}
|
||||
|
5
framework/core/js/dist-typings/forum/utils/PostControls.d.ts
generated
vendored
5
framework/core/js/dist-typings/forum/utils/PostControls.d.ts
generated
vendored
@ -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
2
framework/core/js/dist/admin.js
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/admin.js.map
generated
vendored
2
framework/core/js/dist/admin.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/common/components/SearchModal.js
generated
vendored
2
framework/core/js/dist/common/components/SearchModal.js
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/common/components/SearchModal.js.map
generated
vendored
2
framework/core/js/dist/common/components/SearchModal.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/forum.js
generated
vendored
2
framework/core/js/dist/forum.js
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/forum.js.map
generated
vendored
2
framework/core/js/dist/forum.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/forum/components/PostStream.js
generated
vendored
2
framework/core/js/dist/forum/components/PostStream.js
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/forum/components/PostStream.js.map
generated
vendored
2
framework/core/js/dist/forum/components/PostStream.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/forum/components/PostStreamScrubber.js
generated
vendored
2
framework/core/js/dist/forum/components/PostStreamScrubber.js
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/forum/components/PostStreamScrubber.js.map
generated
vendored
2
framework/core/js/dist/forum/components/PostStreamScrubber.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/forum/components/UserSecurityPage.js
generated
vendored
2
framework/core/js/dist/forum/components/UserSecurityPage.js
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/forum/components/UserSecurityPage.js.map
generated
vendored
2
framework/core/js/dist/forum/components/UserSecurityPage.js.map
generated
vendored
File diff suppressed because one or more lines are too long
@ -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) {
|
||||
|
@ -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>
|
||||
))}
|
||||
|
@ -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>,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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}>
|
||||
|
@ -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',
|
||||
|
@ -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.
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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>;
|
||||
}
|
||||
|
38
framework/core/js/src/common/components/IPAddress.tsx
Normal file
38
framework/core/js/src/common/components/IPAddress.tsx
Normal 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;
|
||||
}
|
||||
}
|
@ -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"
|
||||
|
@ -324,7 +324,7 @@ export default class SearchModal<CustomAttrs extends ISearchModalAttrs = ISearch
|
||||
|
||||
this.searchState.cache(query);
|
||||
m.redraw();
|
||||
}, 250);
|
||||
}, SearchManager.SEARCH_DEBOUNCE_TIME_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user