diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index e86ccd8289..ea2aa4f98c 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -4,9 +4,17 @@ "path": "./framework/core/js/dist/*.js", "maxSize": "150KB" }, + { + "path": "./framework/core/js/dist/*/**/*.js", + "maxSize": "30KB" + }, { "path": "./extensions/*/js/dist/*.js", "maxSize": "30KB" + }, + { + "path": "./extensions/*/js/dist/*/**/*.js", + "maxSize": "30KB" } ], "defaultCompression": "gzip", diff --git a/extensions/embed/js/src/forum/index.js b/extensions/embed/js/src/forum/index.js index ecdd52508c..f858688120 100644 --- a/extensions/embed/js/src/forum/index.js +++ b/extensions/embed/js/src/forum/index.js @@ -4,8 +4,6 @@ import { override, extend } from 'flarum/common/extend'; import app from 'flarum/forum/app'; import Stream from 'flarum/common/utils/Stream'; import ForumApplication from 'flarum/forum/ForumApplication'; -import Composer from 'flarum/forum/components/Composer'; -import PostStream from 'flarum/forum/components/PostStream'; import ModalManager from 'flarum/common/components/ModalManager'; import PostMeta from 'flarum/forum/components/PostMeta'; @@ -13,7 +11,7 @@ import DiscussionPage from 'flarum/forum/components/DiscussionPage'; extend(ForumApplication.prototype, 'mount', function () { if (m.route.param('hideFirstPost')) { - extend(PostStream.prototype, 'view', (vdom) => { + extend('flarum/forum/components/PostStream', 'view', (vdom) => { if (vdom.children[0].attrs['data-number'] === 1) { vdom.children.splice(0, 1); } @@ -42,7 +40,7 @@ const reposition = function () { }; extend(ModalManager.prototype, 'show', reposition); -extend(Composer.prototype, 'show', reposition); +extend('flarum/forum/components/Composer', 'show', reposition); window.iFrameResizer = { readyCallback: function () { @@ -50,7 +48,7 @@ window.iFrameResizer = { }, }; -extend(PostStream.prototype, 'goToNumber', function (promise, number) { +extend('flarum/forum/components/PostStream', 'goToNumber', function (promise, number) { if (number === 'reply' && 'parentIFrame' in window && app.composer.isFullScreen()) { const itemTop = this.$('.PostStream-item:last').offset().top; window.parentIFrame.scrollToOffset(0, itemTop); diff --git a/extensions/emoji/extend.php b/extensions/emoji/extend.php index 54a5d79780..00d190b91b 100644 --- a/extensions/emoji/extend.php +++ b/extensions/emoji/extend.php @@ -13,7 +13,8 @@ return [ (new Extend\Frontend('forum')) ->js(__DIR__.'/js/dist/forum.js') - ->css(__DIR__.'/less/forum.less'), + ->css(__DIR__.'/less/forum.less') + ->jsDirectory(__DIR__.'/js/dist/forum'), (new Extend\Formatter) ->configure(function (Configurator $config) { diff --git a/extensions/emoji/js/src/forum/addComposerAutocomplete.js b/extensions/emoji/js/src/forum/addComposerAutocomplete.js index 195dc605d0..4aa886fcf5 100644 --- a/extensions/emoji/js/src/forum/addComposerAutocomplete.js +++ b/extensions/emoji/js/src/forum/addComposerAutocomplete.js @@ -1,7 +1,4 @@ -import emojiMap from 'simple-emoji-map'; - import { extend } from 'flarum/common/extend'; -import TextEditor from 'flarum/common/components/TextEditor'; import TextEditorButton from 'flarum/common/components/TextEditorButton'; import KeyboardNavigatable from 'flarum/common/utils/KeyboardNavigatable'; @@ -10,11 +7,15 @@ import getEmojiIconCode from './helpers/getEmojiIconCode'; import cdn from './cdn'; export default function addComposerAutocomplete() { - const emojiKeys = Object.keys(emojiMap); const $container = $('
'); const dropdown = new AutocompleteDropdown(); + let emojiMap = null; + + extend('flarum/common/components/TextEditor', 'oninit', function () { + this._loaders.push(async () => await import('./emojiMap').then((m) => (emojiMap = m.default))); + }); - extend(TextEditor.prototype, 'oncreate', function () { + extend('flarum/common/components/TextEditor', 'onbuild', function () { const $editor = this.$('.TextEditor-editor').wrap('
'); this.navigator = new KeyboardNavigatable(); @@ -29,7 +30,9 @@ export default function addComposerAutocomplete() { $editor.after($container); }); - extend(TextEditor.prototype, 'buildEditorParams', function (params) { + extend('flarum/common/components/TextEditor', 'buildEditorParams', function (params) { + const emojiKeys = Object.keys(emojiMap); + let relEmojiStart; let absEmojiStart; let typed; @@ -166,7 +169,7 @@ export default function addComposerAutocomplete() { }); }); - extend(TextEditor.prototype, 'toolbarItems', function (items) { + extend('flarum/common/components/TextEditor', 'toolbarItems', function (items) { items.add( 'emoji', this.attrs.composer.editor.insertAtCursor(' :')} icon="far fa-smile"> diff --git a/extensions/emoji/js/src/forum/emojiMap.ts b/extensions/emoji/js/src/forum/emojiMap.ts new file mode 100644 index 0000000000..25d34d6cb2 --- /dev/null +++ b/extensions/emoji/js/src/forum/emojiMap.ts @@ -0,0 +1,3 @@ +import emojiMap from 'simple-emoji-map'; + +export default emojiMap; diff --git a/extensions/likes/js/src/forum/index.js b/extensions/likes/js/src/forum/index.js index 29d7a1d653..45adb6891f 100644 --- a/extensions/likes/js/src/forum/index.js +++ b/extensions/likes/js/src/forum/index.js @@ -1,6 +1,5 @@ import { extend } from 'flarum/common/extend'; import app from 'flarum/forum/app'; -import NotificationGrid from 'flarum/forum/components/NotificationGrid'; import addLikeAction from './addLikeAction'; import addLikesList from './addLikesList'; @@ -16,7 +15,7 @@ app.initializers.add('flarum-likes', () => { addLikesList(); addLikesTabToUserProfile(); - extend(NotificationGrid.prototype, 'notificationTypes', function (items) { + extend('flarum/forum/components/NotificationGrid', 'notificationTypes', function (items) { items.add('postLiked', { name: 'postLiked', icon: 'far fa-thumbs-up', diff --git a/extensions/lock/js/src/forum/index.js b/extensions/lock/js/src/forum/index.js index 0ac405d3cf..fd3f9cda02 100644 --- a/extensions/lock/js/src/forum/index.js +++ b/extensions/lock/js/src/forum/index.js @@ -1,6 +1,5 @@ import { extend } from 'flarum/common/extend'; import app from 'flarum/forum/app'; -import NotificationGrid from 'flarum/forum/components/NotificationGrid'; import DiscussionLockedNotification from './components/DiscussionLockedNotification'; import addLockBadge from './addLockBadge'; @@ -14,7 +13,7 @@ app.initializers.add('flarum-lock', () => { addLockBadge(); addLockControl(); - extend(NotificationGrid.prototype, 'notificationTypes', function (items) { + extend('flarum/forum/components/NotificationGrid', 'notificationTypes', function (items) { items.add('discussionLocked', { name: 'discussionLocked', icon: 'fas fa-lock', diff --git a/extensions/markdown/js/src/common/index.js b/extensions/markdown/js/src/common/index.js index 6e3bbdf13f..94249a88d5 100644 --- a/extensions/markdown/js/src/common/index.js +++ b/extensions/markdown/js/src/common/index.js @@ -9,7 +9,6 @@ import app from 'flarum/common/app'; import { extend, override } from 'flarum/common/extend'; -import TextEditor from 'flarum/common/components/TextEditor'; import BasicEditorDriver from 'flarum/common/utils/BasicEditorDriver'; import styleSelectedText from 'flarum/common/utils/styleSelectedText'; @@ -89,13 +88,9 @@ export function initialize(app) { items.add('italic', makeShortcut('italic', 'i', this)); }); - if (TextEditor.prototype.markdownToolbarItems) { - override(TextEditor.prototype, 'markdownToolbarItems', markdownToolbarItems); - } else { - TextEditor.prototype.markdownToolbarItems = markdownToolbarItems; - } + override('flarum/common/components/TextEditor', 'markdownToolbarItems', markdownToolbarItems); - extend(TextEditor.prototype, 'toolbarItems', function (items) { + extend('flarum/common/components/TextEditor', 'toolbarItems', function (items) { items.add( 'markdown', (shortcutHandler = handler)}> diff --git a/extensions/markdown/js/tsconfig.json b/extensions/markdown/js/tsconfig.json new file mode 100644 index 0000000000..519ed73b2d --- /dev/null +++ b/extensions/markdown/js/tsconfig.json @@ -0,0 +1,15 @@ +{ + // Use Flarum's tsconfig as a starting point + "extends": "flarum-tsconfig", + // This will match all .ts, .tsx, .d.ts, .js, .jsx files in your `src` folder + // and also tells your Typescript server to read core's global typings for + // access to `dayjs` and `$` in the global namespace. + "include": ["src/**/*", "../../../framework/core/js/dist-typings/@types/**/*", "@types/**/*"], + "compilerOptions": { + // This will output typings to `dist-typings` + "declarationDir": "./dist-typings", + "paths": { + "flarum/*": ["../../../framework/core/js/dist-typings/*"] + } + } +} diff --git a/extensions/mentions/js/src/forum/addComposerAutocomplete.js b/extensions/mentions/js/src/forum/addComposerAutocomplete.js index a4f02e20e2..5212dd5fc0 100644 --- a/extensions/mentions/js/src/forum/addComposerAutocomplete.js +++ b/extensions/mentions/js/src/forum/addComposerAutocomplete.js @@ -1,6 +1,5 @@ import app from 'flarum/forum/app'; import { extend } from 'flarum/common/extend'; -import TextEditor from 'flarum/common/components/TextEditor'; import TextEditorButton from 'flarum/common/components/TextEditorButton'; import KeyboardNavigatable from 'flarum/common/utils/KeyboardNavigatable'; @@ -11,7 +10,7 @@ export default function addComposerAutocomplete() { const $container = $('
'); const dropdown = new AutocompleteDropdown(); - extend(TextEditor.prototype, 'oncreate', function () { + extend('flarum/common/components/TextEditor', 'onbuild', function () { const $editor = this.$('.TextEditor-editor').wrap('
'); this.navigator = new KeyboardNavigatable(); @@ -26,7 +25,7 @@ export default function addComposerAutocomplete() { $editor.after($container); }); - extend(TextEditor.prototype, 'buildEditorParams', function (params) { + extend('flarum/common/components/TextEditor', 'buildEditorParams', function (params) { let relMentionStart; let absMentionStart; let matchTyped; @@ -128,7 +127,7 @@ export default function addComposerAutocomplete() { params.inputListeners.push(suggestionsInputListener); }); - extend(TextEditor.prototype, 'toolbarItems', function (items) { + extend('flarum/common/components/TextEditor', 'toolbarItems', function (items) { items.add( 'mention', this.attrs.composer.editor.insertAtCursor(' @')} icon="fas fa-at"> diff --git a/extensions/mentions/js/src/forum/index.js b/extensions/mentions/js/src/forum/index.js index 3022293c87..a8797b006e 100644 --- a/extensions/mentions/js/src/forum/index.js +++ b/extensions/mentions/js/src/forum/index.js @@ -1,6 +1,5 @@ import { extend } from 'flarum/common/extend'; import app from 'flarum/forum/app'; -import NotificationGrid from 'flarum/forum/components/NotificationGrid'; import { getPlainContent } from 'flarum/common/utils/string'; import textContrastClass from 'flarum/common/helpers/textContrastClass'; import Post from 'flarum/forum/components/Post'; @@ -46,7 +45,7 @@ app.initializers.add('flarum-mentions', function () { app.notificationComponents.groupMentioned = GroupMentionedNotification; // Add notification preferences. - extend(NotificationGrid.prototype, 'notificationTypes', function (items) { + extend('flarum/forum/components/NotificationGrid', 'notificationTypes', function (items) { items.add('postMentioned', { name: 'postMentioned', icon: 'fas fa-reply', diff --git a/extensions/mentions/js/src/forum/mentionables/PostMention.tsx b/extensions/mentions/js/src/forum/mentionables/PostMention.tsx index 172fa8f46b..e32c07789a 100644 --- a/extensions/mentions/js/src/forum/mentionables/PostMention.tsx +++ b/extensions/mentions/js/src/forum/mentionables/PostMention.tsx @@ -6,8 +6,6 @@ import usernameHelper from 'flarum/common/helpers/username'; import avatar from 'flarum/common/helpers/avatar'; import highlight from 'flarum/common/helpers/highlight'; import { truncate } from 'flarum/common/utils/string'; -import ReplyComposer from 'flarum/forum/components/ReplyComposer'; -import EditPostComposer from 'flarum/forum/components/EditPostComposer'; import getCleanDisplayName from '../utils/getCleanDisplayName'; import type AtMentionFormat from './formats/AtMentionFormat'; @@ -23,7 +21,10 @@ export default class PostMention extends MentionableModel * match any username characters that have been typed. */ initialResults(): Post[] { - if (!app.composer.bodyMatches(ReplyComposer) && !app.composer.bodyMatches(EditPostComposer)) { + const EditPostComposer = flarum.reg.checkModule('core', 'forum/components/EditPostComposer'); + const ReplyComposer = flarum.reg.checkModule('core', 'forum/components/ReplyComposer'); + + if ((!ReplyComposer || !app.composer.bodyMatches(ReplyComposer)) && (!EditPostComposer || !app.composer.bodyMatches(EditPostComposer))) { return []; } diff --git a/extensions/mentions/js/src/forum/utils/reply.js b/extensions/mentions/js/src/forum/utils/reply.js index 5a2961e77b..816ed83ef6 100644 --- a/extensions/mentions/js/src/forum/utils/reply.js +++ b/extensions/mentions/js/src/forum/utils/reply.js @@ -1,6 +1,5 @@ import app from 'flarum/forum/app'; import DiscussionControls from 'flarum/forum/utils/DiscussionControls'; -import EditPostComposer from 'flarum/forum/components/EditPostComposer'; export function insertMention(post, composer, quote) { return new Promise((resolve) => { @@ -27,7 +26,9 @@ export function insertMention(post, composer, quote) { } export default function reply(post, quote) { - if (app.composer.bodyMatches(EditPostComposer) && app.composer.body.attrs.post.discussion() === post.discussion()) { + const EditPostComposer = flarum.reg.checkModule('core', 'forum/components/EditPostComposer'); + + if (EditPostComposer && app.composer.bodyMatches(EditPostComposer) && app.composer.body.attrs.post.discussion() === post.discussion()) { // If we're already editing a post in the discussion of post we're quoting, // insert the mention directly. return insertMention(post, app.composer, quote); diff --git a/extensions/nicknames/js/src/forum/index.js b/extensions/nicknames/js/src/forum/index.js index 8c353494cc..aea6faed88 100644 --- a/extensions/nicknames/js/src/forum/index.js +++ b/extensions/nicknames/js/src/forum/index.js @@ -1,9 +1,6 @@ import app from 'flarum/forum/app'; import { extend } from 'flarum/common/extend'; import Button from 'flarum/common/components/Button'; -import EditUserModal from 'flarum/common/components/EditUserModal'; -import SignUpModal from 'flarum/forum/components/SignUpModal'; -import SettingsPage from 'flarum/forum/components/SettingsPage'; import extractText from 'flarum/common/utils/extractText'; import Stream from 'flarum/common/utils/Stream'; import NickNameModal from './components/NicknameModal'; @@ -11,7 +8,7 @@ import NickNameModal from './components/NicknameModal'; export { default as extend } from './extend'; app.initializers.add('flarum/nicknames', () => { - extend(SettingsPage.prototype, 'accountItems', function (items) { + extend('flarum/forum/components/SettingsPage', 'accountItems', function (items) { if (app.forum.attribute('displayNameDriver') !== 'nickname') return; if (this.user.canEditNickname()) { @@ -24,11 +21,11 @@ app.initializers.add('flarum/nicknames', () => { } }); - extend(EditUserModal.prototype, 'oninit', function () { + extend('flarum/common/components/EditUserModal', 'oninit', function () { this.nickname = Stream(this.attrs.user.displayName()); }); - extend(EditUserModal.prototype, 'fields', function (items) { + extend('flarum/common/components/EditUserModal', 'fields', function (items) { if (app.forum.attribute('displayNameDriver') !== 'nickname') return; if (!this.attrs.user.canEditNickname()) return; @@ -47,7 +44,7 @@ app.initializers.add('flarum/nicknames', () => { ); }); - extend(EditUserModal.prototype, 'data', function (data) { + extend('flarum/common/components/EditUserModal', 'data', function (data) { if (app.forum.attribute('displayNameDriver') !== 'nickname') return; if (!this.attrs.user.canEditNickname()) return; @@ -57,13 +54,13 @@ app.initializers.add('flarum/nicknames', () => { } }); - extend(SignUpModal.prototype, 'oninit', function () { + extend('flarum/forum/components/SignUpModal', 'oninit', function () { if (app.forum.attribute('displayNameDriver') !== 'nickname') return; this.nickname = Stream(this.attrs.username || ''); }); - extend(SignUpModal.prototype, 'onready', function () { + extend('flarum/forum/components/SignUpModal', 'onready', function () { if (app.forum.attribute('displayNameDriver') !== 'nickname') return; if (app.forum.attribute('setNicknameOnRegistration') && app.forum.attribute('randomizeUsernameOnRegistration')) { @@ -71,7 +68,7 @@ app.initializers.add('flarum/nicknames', () => { } }); - extend(SignUpModal.prototype, 'fields', function (items) { + extend('flarum/forum/components/SignUpModal', 'fields', function (items) { if (app.forum.attribute('displayNameDriver') !== 'nickname') return; if (app.forum.attribute('setNicknameOnRegistration')) { @@ -97,7 +94,7 @@ app.initializers.add('flarum/nicknames', () => { } }); - extend(SignUpModal.prototype, 'submitData', function (data) { + extend('flarum/forum/components/SignUpModal', 'submitData', function (data) { if (app.forum.attribute('displayNameDriver') !== 'nickname') return; if (app.forum.attribute('setNicknameOnRegistration')) { diff --git a/extensions/subscriptions/js/src/forum/addSubscriptionSettings.tsx b/extensions/subscriptions/js/src/forum/addSubscriptionSettings.tsx index 78aaf05155..3ad961dd84 100644 --- a/extensions/subscriptions/js/src/forum/addSubscriptionSettings.tsx +++ b/extensions/subscriptions/js/src/forum/addSubscriptionSettings.tsx @@ -1,10 +1,10 @@ import app from 'flarum/forum/app'; import { extend } from 'flarum/common/extend'; -import SettingsPage from 'flarum/forum/components/SettingsPage'; +import type SettingsPage from 'flarum/forum/components/SettingsPage'; import Switch from 'flarum/common/components/Switch'; export default function () { - extend(SettingsPage.prototype, 'notificationsItems', function (this: SettingsPage, items) { + extend('flarum/forum/components/SettingsPage', 'notificationsItems', function (this: SettingsPage, items) { items.add( 'followAfterReply', js(__DIR__.'/js/dist/forum.js') + ->jsDirectory(__DIR__.'/js/dist/forum') ->css(__DIR__.'/less/forum.less') ->route('/t/{slug}', 'tag', Content\Tag::class) ->route('/tags', 'tags', Content\Tags::class), + (new Extend\Frontend('common')) + ->jsDirectory(__DIR__.'/js/dist/common'), + (new Extend\Frontend('admin')) ->js(__DIR__.'/js/dist/admin.js') ->css(__DIR__.'/less/admin.less'), diff --git a/extensions/tags/js/src/admin/components/SelectTagsSettingComponent.tsx b/extensions/tags/js/src/admin/components/SelectTagsSettingComponent.tsx index ead367b580..acf01fcd46 100644 --- a/extensions/tags/js/src/admin/components/SelectTagsSettingComponent.tsx +++ b/extensions/tags/js/src/admin/components/SelectTagsSettingComponent.tsx @@ -2,7 +2,6 @@ import app from 'flarum/admin/app'; import Component from 'flarum/common/Component'; import Button from 'flarum/common/components/Button'; import LoadingIndicator from 'flarum/common/components/LoadingIndicator'; -import TagSelectionModal from '../../common/components/TagSelectionModal'; import tagsLabel from '../../common/helpers/tagsLabel'; import type { CommonSettingsItemOptions } from 'flarum/admin/components/AdminPage'; @@ -46,7 +45,7 @@ export default class SelectTagsSettingComponent< ); diff --git a/extensions/tags/js/src/forum/addTagFilter.tsx b/extensions/tags/js/src/forum/addTagFilter.tsx index e0972da3d3..b2742bdb72 100644 --- a/extensions/tags/js/src/forum/addTagFilter.tsx +++ b/extensions/tags/js/src/forum/addTagFilter.tsx @@ -13,7 +13,7 @@ import { ComponentAttrs } from 'flarum/common/Component'; const findTag = (slug: string) => app.store.all('tags').find((tag) => tag.slug().localeCompare(slug, undefined, { sensitivity: 'base' }) === 0); -export default function () { +export default function addTagFilter() { IndexPage.prototype.currentTag = function () { if (this.currentActiveTag) { return this.currentActiveTag; diff --git a/extensions/tags/js/src/forum/addTagLabels.js b/extensions/tags/js/src/forum/addTagLabels.js index 4f2ec89e22..99c4362c05 100644 --- a/extensions/tags/js/src/forum/addTagLabels.js +++ b/extensions/tags/js/src/forum/addTagLabels.js @@ -7,7 +7,7 @@ import classList from 'flarum/common/utils/classList'; import tagsLabel from '../common/helpers/tagsLabel'; import sortTags from '../common/utils/sortTags'; -export default function () { +export default function addTagLabels() { // Add tag labels to each discussion in the discussion list. extend(DiscussionListItem.prototype, 'infoItems', function (items) { const tags = this.attrs.discussion.tags(); diff --git a/extensions/tags/js/src/forum/addTagList.js b/extensions/tags/js/src/forum/addTagList.js index 5ae05d4227..54d72a229f 100644 --- a/extensions/tags/js/src/forum/addTagList.js +++ b/extensions/tags/js/src/forum/addTagList.js @@ -8,7 +8,7 @@ import TagsPage from './components/TagsPage'; import app from 'flarum/forum/app'; import sortTags from '../common/utils/sortTags'; -export default function () { +export default function addTagList() { // Add a link to the tags page, as well as a list of all the tags, // to the index page's sidebar. extend(IndexPage.prototype, 'navItems', function (items) { diff --git a/extensions/tags/js/src/forum/components/TagDiscussionModal.tsx b/extensions/tags/js/src/forum/components/TagDiscussionModal.tsx index c94eae7078..e94a28c45f 100644 --- a/extensions/tags/js/src/forum/components/TagDiscussionModal.tsx +++ b/extensions/tags/js/src/forum/components/TagDiscussionModal.tsx @@ -36,7 +36,7 @@ export default class TagDiscussionModal extends TagSelectionModal getSelectableTags(attrs.discussion); + attrs.selectableTags = () => getSelectableTags(attrs.discussion!); attrs.selectedTags ??= (attrs.discussion?.tags() as Tag[]) || []; attrs.canSelect = (tag) => tag.canStartDiscussion(); diff --git a/extensions/tags/js/src/forum/forum.ts b/extensions/tags/js/src/forum/forum.ts index 37960a91d9..1ff68e9927 100644 --- a/extensions/tags/js/src/forum/forum.ts +++ b/extensions/tags/js/src/forum/forum.ts @@ -3,7 +3,6 @@ import '../common/common'; import './utils/getSelectableTags'; import './components/TagHero'; -import './components/TagDiscussionModal'; import './components/TagsPage'; import './components/DiscussionTaggedPost'; import './components/TagLinkButton'; diff --git a/extensions/tags/js/src/forum/utils/getSelectableTags.js b/extensions/tags/js/src/forum/utils/getSelectableTags.js deleted file mode 100644 index 35caeb3dbe..0000000000 --- a/extensions/tags/js/src/forum/utils/getSelectableTags.js +++ /dev/null @@ -1,11 +0,0 @@ -export default function getSelectableTags(discussion) { - let tags = app.store.all('tags'); - - if (discussion) { - tags = tags.filter((tag) => tag.canAddToDiscussion() || discussion.tags().indexOf(tag) !== -1); - } else { - tags = tags.filter((tag) => tag.canStartDiscussion()); - } - - return tags; -} diff --git a/extensions/tags/js/src/forum/utils/getSelectableTags.ts b/extensions/tags/js/src/forum/utils/getSelectableTags.ts new file mode 100644 index 0000000000..f98f2c20ff --- /dev/null +++ b/extensions/tags/js/src/forum/utils/getSelectableTags.ts @@ -0,0 +1,15 @@ +import type Tag from '../../common/models/Tag'; +import type Discussion from 'flarum/common/models/Discussion'; + +export default function getSelectableTags(discussion: Discussion) { + let tags = app.store.all('tags'); + + if (discussion) { + const discussionTags = discussion.tags() || []; + tags = tags.filter((tag) => tag.canAddToDiscussion() || discussionTags.includes(tag)); + } else { + tags = tags.filter((tag) => tag.canStartDiscussion()); + } + + return tags; +} diff --git a/framework/core/js/src/@types/global.d.ts b/framework/core/js/src/@types/global.d.ts index 6476096aec..77fb705f8e 100644 --- a/framework/core/js/src/@types/global.d.ts +++ b/framework/core/js/src/@types/global.d.ts @@ -54,7 +54,7 @@ declare type VnodeElementTag, C extends Componen * import app from 'flarum/common/app'; * ``` */ -declare const app: never; +declare const app: import('../common/Application').default; declare const m: import('mithril').Static; declare const dayjs: typeof import('dayjs'); @@ -98,8 +98,16 @@ interface FlarumObject { * } */ extensions: Readonly>; - - reg: any; + /** + * Contains a registry of all exported modules, + * as well as chunks that can be imported and the modules + * each chunk contains. + */ + reg: import('../common/ExportRegistry').default; + /** + * For early operations, this object stores whether we are in debug mode or not. + */ + debug: boolean; } declare const flarum: FlarumObject; diff --git a/framework/core/js/src/admin/components/UserListPage.tsx b/framework/core/js/src/admin/components/UserListPage.tsx index aa06afbfc1..3519165e11 100644 --- a/framework/core/js/src/admin/components/UserListPage.tsx +++ b/framework/core/js/src/admin/components/UserListPage.tsx @@ -2,7 +2,6 @@ import Mithril from 'mithril'; import app from '../../admin/app'; -import EditUserModal from '../../common/components/EditUserModal'; import LoadingIndicator from '../../common/components/LoadingIndicator'; import Button from '../../common/components/Button'; @@ -432,7 +431,7 @@ export default class UserListPage extends AdminPage { diff --git a/framework/core/js/src/admin/resolvers/ExtensionPageResolver.ts b/framework/core/js/src/admin/resolvers/ExtensionPageResolver.ts index 90277ed554..c944633ad5 100644 --- a/framework/core/js/src/admin/resolvers/ExtensionPageResolver.ts +++ b/framework/core/js/src/admin/resolvers/ExtensionPageResolver.ts @@ -16,7 +16,7 @@ export default class ExtensionPageResolver< const extensionPage = app.extensionData.getPage(args.id); if (extensionPage) { - return extensionPage; + return Promise.resolve(extensionPage); } return super.onmatch(args, requestedPath, route); diff --git a/framework/core/js/src/common/Application.tsx b/framework/core/js/src/common/Application.tsx index e5033a74dd..cb086cfd80 100644 --- a/framework/core/js/src/common/Application.tsx +++ b/framework/core/js/src/common/Application.tsx @@ -60,6 +60,9 @@ export interface FlarumRequestOptions extends Omit string; } +export type NewComponent = new () => Comp; +export type AsyncNewComponent = () => Promise }>; + /** * A valid route definition. */ @@ -82,14 +85,14 @@ export type RouteItem< /** * The component to render when this route matches. */ - component: new () => Comp; + component: NewComponent | AsyncNewComponent; /** * A custom resolver class. * * This should be the class itself, and **not** an instance of the * class. */ - resolverClass?: new (component: new () => Comp, routeName: string) => DefaultResolver; + resolverClass?: new (component: NewComponent | AsyncNewComponent, routeName: string) => DefaultResolver; } | { /** @@ -113,7 +116,7 @@ export interface RouteResolver< * * @see https://mithril.js.org/route.html#routeresolveronmatch */ - onmatch(this: this, args: RouteArgs, requestedPath: string, route: string): { new (): Comp }; + onmatch(this: this, args: RouteArgs, requestedPath: string, route: string): Promise<{ new (): Comp }>; /** * A function which renders the provided component. * diff --git a/framework/core/js/src/common/ExportRegistry.ts b/framework/core/js/src/common/ExportRegistry.ts index 712b4f3cc5..649c55abee 100644 --- a/framework/core/js/src/common/ExportRegistry.ts +++ b/framework/core/js/src/common/ExportRegistry.ts @@ -7,6 +7,7 @@ export interface IExportRegistry { /** * Add an instance to the registry. + * Identified by a namespace (extension ID) and an ID (module path). */ add(namespace: string, id: string, object: any): void; @@ -17,14 +18,79 @@ export interface IExportRegistry { onLoad(namespace: string, id: string, handler: Function): void; /** - * Retrieve an object of type `id` from the registry. + * Retrieve a module from the registry by namespace and ID. */ get(namespace: string, id: string): any; } -export default class ExportRegistry implements IExportRegistry { +/** + * @internal + */ +export interface IChunkRegistry { + chunks: Map; + chunkModules: Map; + + /** + * Check if a module has been loaded. + * Return the module if so, false otherwise. + */ + checkModule(namespace: string, id: string): any | false; + + /** + * Register a module by the chunk ID it belongs to, the webpack module ID it belongs to, + * the namespace (extension ID), and its path. + */ + addChunkModule(chunkId: number | string, moduleId: number | string, namespace: string, urlPath: string): void; + + /** + * Get a registered chunk. Each chunk has at least one module (the default one). + */ + getChunk(chunkId: number | string): Chunk | null; + + /** + * The chunk loader which overrides the default Webpack chunk loader. + */ + loadChunk(original: Function, url: string, done: () => Promise, key: number, chunkId: number | string): Promise; + + /** + * Responsible for loading external chunks. + * Called automatically when an extension/package tries to async import a chunked module. + */ + asyncModuleImport(path: string): Promise; +} + +type Chunk = { + /** + * The extension id of the chunk or 'core'. + */ + namespace: string; + /** + * The relative URL path to the chunk. + */ + urlPath: string; + /** + * An array of modules included in the chunk, by relative module path. + */ + modules?: string[]; +}; + +type Module = { + /** + * The chunk ID the module belongs to. + */ + chunkId: string; + /** + * The module ID. Not unique, as most chunk modules are concatenated into one module. + */ + moduleId: string; +}; + +export default class ExportRegistry implements IExportRegistry, IChunkRegistry { moduleExports = new Map>(); onLoads = new Map>(); + chunks = new Map(); + chunkModules = new Map(); + private _revisions: any = null; add(namespace: string, id: string, object: any): void { this.moduleExports.set(namespace, this.moduleExports.get(namespace) || new Map()); @@ -36,7 +102,7 @@ export default class ExportRegistry implements IExportRegistry { ?.forEach((handler) => handler(object)); } - onLoad(namespace: string, id: string, handler: Function): void { + onLoad(namespace: string, id: string, handler: (module: any) => void): void { if (this.moduleExports.has(namespace) && this.moduleExports.get(namespace)?.has(id)) { handler(this.moduleExports.get(namespace)?.get(id)); } else { @@ -48,11 +114,126 @@ export default class ExportRegistry implements IExportRegistry { get(namespace: string, id: string): any { const module = this.moduleExports.get(namespace)?.get(id); + const error = `No module found for ${namespace}:${id}`; - if (!module) { - console.warn(`No module found for ${namespace}:${id}`); + // @ts-ignore + if (!module && flarum.debug) { + throw new Error(error); + } else if (!module) { + console.warn(error); } return module; } + + public checkModule(namespace: string, id: string): any | false { + const exists = (this.moduleExports.has(namespace) && this.moduleExports.get(namespace)?.has(id)) || false; + + return exists ? this.get(namespace, id) : false; + } + + addChunkModule(chunkId: number | string, moduleId: number | string, namespace: string, urlPath: string): void { + if (!this.chunks.has(chunkId.toString())) { + this.chunks.set(chunkId.toString(), { + namespace, + urlPath, + modules: [urlPath], + }); + } else { + this.chunks.get(chunkId.toString())?.modules?.push(urlPath); + } + + this.chunkModules.set(`${namespace}:${urlPath}`, { + chunkId: chunkId.toString(), + moduleId: moduleId.toString(), + }); + } + + getChunk(chunkId: number | string): Chunk | null { + const chunk = this.chunks.get(chunkId.toString()) ?? null; + + if (!chunk) { + console.warn(`[Export Registry] No chunk by the ID ${chunkId} found.`); + return null; + } + + return chunk; + } + + async loadChunk(original: Function, url: string, done: (...args: any) => Promise, key: number, chunkId: number | string): Promise { + // @ts-ignore + app.alerts.showLoading(); + + return await original( + this.chunkUrl(chunkId) || url, + (...args: any) => { + // @ts-ignore + app.alerts.clearLoading(); + + return done(...args); + }, + key, + chunkId + ); + } + + chunkUrl(chunkId: number | string): string | null { + const chunk = this.getChunk(chunkId.toString()); + + if (!chunk) return null; + + this._revisions ??= JSON.parse(document.getElementById('flarum-rev-manifest')?.textContent ?? '{}'); + + // @ts-ignore cannot import the app object here, so we use the global one. + const path = `${app.forum.attribute('jsChunksBaseUrl')}/${chunk.namespace}/${chunk.urlPath}.js`; + + // The paths in the revision are stored as (relative path from the assets path) + the path. + // @ts-ignore + const assetsPath = app.forum.attribute('assetsBaseUrl'); + const key = path.replace(assetsPath, '').replace(/^\//, ''); + const revision = this._revisions[key]; + + return revision ? `${path}?v=${revision}` : path; + } + + async asyncModuleImport(path: string): Promise { + const [namespace, id] = this.namespaceAndIdFromPath(path); + const module = this.chunkModules.get(`${namespace}:${id}`); + + if (!module) { + throw new Error(`No chunk found for module ${namespace}:${id}`); + } + + // @ts-ignore + const wr = __webpack_require__; + + return await wr.e(module.chunkId).then(() => { + // Needed to make sure the module is loaded. + // Taken care of by webpack. + wr.bind(wr, module.moduleId)(); + + const moduleExport = this.get(namespace, id); + + // For consistent access to async modules. + moduleExport.default = moduleExport.default || moduleExport; + + return moduleExport; + }); + } + + namespaceAndIdFromPath(path: string): [string, string] { + // Either we get a path like `flarum/forum/components/LogInModal` or `ext:flarum/tags/forum/components/TagPage`. + const matches = /^(?:ext:([^\/]+)\/(?:flarum-(?:ext-)?)?([^\/]+)|(flarum))(?:\/(.+))?$/.exec(path); + + const id = matches![4]; + let namespace; + + if (matches![1]) { + namespace = `${matches![1]}-${matches![2]}`; + } else { + namespace = 'core'; + } + + return [namespace, id]; + } } diff --git a/framework/core/js/src/common/common.ts b/framework/core/js/src/common/common.ts index e2699283fb..7d7531ed02 100644 --- a/framework/core/js/src/common/common.ts +++ b/framework/core/js/src/common/common.ts @@ -64,9 +64,7 @@ import './components/ModalManager'; import './components/Button'; import './components/Modal'; import './components/GroupBadge'; -import './components/TextEditor'; import './components/TextEditorButton'; -import './components/EditUserModal'; import './components/Tooltip'; import './helpers/fullTime'; diff --git a/framework/core/js/src/common/components/ModalManager.tsx b/framework/core/js/src/common/components/ModalManager.tsx index a5113fb4fa..7529cb16d0 100644 --- a/framework/core/js/src/common/components/ModalManager.tsx +++ b/framework/core/js/src/common/components/ModalManager.tsx @@ -6,6 +6,7 @@ import { disableBodyScroll, clearAllBodyScrollLocks } from 'body-scroll-lock'; import type ModalManagerState from '../states/ModalManagerState'; import type Mithril from 'mithril'; +import LoadingIndicator from './LoadingIndicator'; interface IModalManagerAttrs { state: ModalManagerState; @@ -60,13 +61,15 @@ export default class ModalManager extends Component { ); })} - {this.attrs.state.backdropShown && ( + {(this.attrs.state.backdropShown || this.attrs.state.loadingModal) && (
+ data-showing={!!this.attrs.state.modalList.length || this.attrs.state.loadingModal} + style={{ '--modal-count': this.attrs.state.modalList.length + Number(this.attrs.state.loadingModal) }} + > + {this.attrs.state.loadingModal && } +
)} ); diff --git a/framework/core/js/src/common/components/TextEditor.js b/framework/core/js/src/common/components/TextEditor.js index f34ee8f5a6..2360df1338 100644 --- a/framework/core/js/src/common/components/TextEditor.js +++ b/framework/core/js/src/common/components/TextEditor.js @@ -7,6 +7,7 @@ import Button from './Button'; import BasicEditorDriver from '../utils/BasicEditorDriver'; import Tooltip from './Tooltip'; +import LoadingIndicator from './LoadingIndicator'; /** * The `TextEditor` component displays a textarea with controls, including a @@ -36,17 +37,33 @@ export default class TextEditor extends Component { * Whether the editor is disabled. */ this.disabled = !!this.attrs.disabled; + + /** + * Whether the editor is loading. + */ + this.loading = true; + + /** + * Async operations to complete before the editor is ready. + */ + this._loaders = []; } view() { return (
-
- -
    - {listItems(this.controlItems().toArray())} -
  • {this.toolbarItems().toArray()}
  • -
+ {this.loading ? ( + + ) : ( + <> +
+ +
    + {listItems(this.controlItems().toArray())} +
  • {this.toolbarItems().toArray()}
  • +
+ + )}
); } @@ -54,6 +71,12 @@ export default class TextEditor extends Component { oncreate(vnode) { super.oncreate(vnode); + this._load().then(() => { + setTimeout(this.onbuild.bind(this), 50); + }); + } + + onbuild() { this.attrs.composer.editor = this.buildEditor(this.$('.TextEditor-editorContainer')[0]); } @@ -68,6 +91,13 @@ export default class TextEditor extends Component { } } + _load() { + return Promise.all(this._loaders.map((loader) => loader())).then(() => { + this.loading = false; + m.redraw(); + }); + } + buildEditorParams() { return { classNames: ['FormControl', 'Composer-flexible', 'TextEditor-editor'], diff --git a/framework/core/js/src/common/extend.ts b/framework/core/js/src/common/extend.ts index f3eb79df79..4bb3853a69 100644 --- a/framework/core/js/src/common/extend.ts +++ b/framework/core/js/src/common/extend.ts @@ -24,10 +24,19 @@ * @param callback A callback which mutates the method's output */ export function extend, K extends KeyOfType>( - object: T, + object: T | string, methods: K | K[], callback: (this: T, val: ReturnType, ...args: Parameters) => void ) { + // A lazy loaded module, only apply the function after the module is loaded. + if (typeof object === 'string') { + let [namespace, id] = flarum.reg.namespaceAndIdFromPath(object); + + return flarum.reg.onLoad(namespace, id, (module) => { + extend(module.prototype, methods, callback); + }); + } + const allMethods = Array.isArray(methods) ? methods : [methods]; allMethods.forEach((method: K) => { @@ -73,17 +82,26 @@ export function extend, K extends KeyOfType, K extends KeyOfType>( - object: T, + object: T | string, methods: K | K[], newMethod: (this: T, orig: T[K], ...args: Parameters) => void ) { + // A lazy loaded module, only apply the function after the module is loaded. + if (typeof object === 'string') { + let [namespace, id] = flarum.reg.namespaceAndIdFromPath(object); + + return flarum.reg.onLoad(namespace, id, (module) => { + override(module.prototype, methods, newMethod); + }); + } + const allMethods = Array.isArray(methods) ? methods : [methods]; allMethods.forEach((method) => { const original: Function = object[method]; object[method] = function (this: T, ...args: Parameters) { - return newMethod.apply(this, [original.bind(this), ...args]); + return newMethod.apply(this, [original?.bind(this), ...args]); } as T[K]; Object.assign(object[method], original); diff --git a/framework/core/js/src/common/extenders/Routes.ts b/framework/core/js/src/common/extenders/Routes.ts index c593b82048..a895cac66a 100644 --- a/framework/core/js/src/common/extenders/Routes.ts +++ b/framework/core/js/src/common/extenders/Routes.ts @@ -1,4 +1,4 @@ -import Application, { FlarumGenericRoute } from '../Application'; +import Application, { AsyncNewComponent, FlarumGenericRoute, NewComponent } from '../Application'; import IExtender, { IExtensionModule } from './IExtender'; type HelperRoute = (...args: any) => string; @@ -14,7 +14,7 @@ export default class Routes implements IExtender { * @param path The path of the route. * @param component must extend `Page` component. */ - add(name: string, path: `/${string}`, component: any): Routes { + add(name: string, path: `/${string}`, component: NewComponent | AsyncNewComponent): Routes { this.routes[name] = { path, component }; return this; diff --git a/framework/core/js/src/common/resolvers/DefaultResolver.ts b/framework/core/js/src/common/resolvers/DefaultResolver.tsx similarity index 66% rename from framework/core/js/src/common/resolvers/DefaultResolver.ts rename to framework/core/js/src/common/resolvers/DefaultResolver.tsx index a5473c31a8..37fddb277b 100644 --- a/framework/core/js/src/common/resolvers/DefaultResolver.ts +++ b/framework/core/js/src/common/resolvers/DefaultResolver.tsx @@ -1,6 +1,7 @@ import type Mithril from 'mithril'; -import type { RouteResolver } from '../Application'; -import type { default as Component, ComponentAttrs } from '../Component'; +import type { AsyncNewComponent, NewComponent, RouteResolver } from '../Application'; +import type { ComponentAttrs } from '../Component'; +import Component from '../Component'; /** * Generates a route resolver for a given component. @@ -15,10 +16,10 @@ export default class DefaultResolver< RouteArgs extends Record = {} > implements RouteResolver { - component: new () => Comp; + component: NewComponent | AsyncNewComponent; routeName: string; - constructor(component: new () => Comp, routeName: string) { + constructor(component: NewComponent | AsyncNewComponent, routeName: string) { this.component = component; this.routeName = routeName; } @@ -39,8 +40,12 @@ export default class DefaultResolver< }; } - onmatch(args: RouteArgs, requestedPath: string, route: string): { new (): Comp } { - return this.component; + async onmatch(args: RouteArgs, requestedPath: string, route: string): Promise> { + if (this.component.prototype instanceof Component) { + return this.component as NewComponent; + } + + return (await (this.component as AsyncNewComponent)()).default; } render(vnode: Mithril.Vnode): Mithril.Children { diff --git a/framework/core/js/src/common/states/AlertManagerState.ts b/framework/core/js/src/common/states/AlertManagerState.ts index 3551d78f40..8caa58adfe 100644 --- a/framework/core/js/src/common/states/AlertManagerState.ts +++ b/framework/core/js/src/common/states/AlertManagerState.ts @@ -1,5 +1,6 @@ import type Mithril from 'mithril'; import Alert, { AlertAttrs } from '../components/Alert'; +import app from '../app'; /** * Returned by `AlertManagerState.show`. Used to dismiss alerts. @@ -17,6 +18,7 @@ export interface AlertState { export default class AlertManagerState { protected activeAlerts: AlertArray = {}; protected alertId: AlertIdentifier = 0; + protected loadingPool: number = 0; getActiveAlerts() { return this.activeAlerts; @@ -71,4 +73,30 @@ export default class AlertManagerState { this.activeAlerts = {}; m.redraw(); } + + /** + * Shows a loading alert. + */ + showLoading(): AlertIdentifier | null { + this.loadingPool++; + + if (this.loadingPool > 1) return null; + + return this.show( + { + type: 'warning', + dismissible: false, + }, + app.translator.trans('core.lib.loading_indicator.accessible_label') + ); + } + + /** + * Hides a loading alert. + */ + clearLoading(): void { + this.loadingPool--; + + if (this.loadingPool === 0) this.clear(); + } } diff --git a/framework/core/js/src/common/states/ModalManagerState.ts b/framework/core/js/src/common/states/ModalManagerState.ts index 611e61fb91..b823c460cc 100644 --- a/framework/core/js/src/common/states/ModalManagerState.ts +++ b/framework/core/js/src/common/states/ModalManagerState.ts @@ -10,6 +10,12 @@ import Modal, { IDismissibleOptions } from '../components/Modal'; */ type UnsafeModalClass = ComponentClass & { get dismissibleOptions(): IDismissibleOptions; component: typeof Component.component }; +/** + * Alternatively, `show` takes an async function that returns a modal class. + * This is useful for lazy-loading modals. + */ +type AsyncModalClass = () => Promise; + type ModalItem = { componentClass: UnsafeModalClass; attrs?: Record; @@ -37,6 +43,11 @@ export default class ModalManagerState { */ backdropShown: boolean = false; + /** + * @internal + */ + loadingModal: boolean = false; + /** * Used to force re-initialization of modals if a modal * is replaced by another of the same type. @@ -61,14 +72,24 @@ export default class ModalManagerState { * @example Stacking modals * app.modal.show(MyCoolStackedModal, { attr: 'value' }, true); */ - show(componentClass: UnsafeModalClass, attrs: Record = {}, stackModal: boolean = false): void { - if (!(componentClass.prototype instanceof Modal)) { + async show(componentClass: UnsafeModalClass | AsyncModalClass, attrs: Record = {}, stackModal: boolean = false): Promise { + if (!(componentClass.prototype instanceof Modal) && typeof componentClass !== 'function') { // This is duplicated so that if the error is caught, an error message still shows up in the debug console. const invalidModalWarning = 'The ModalManager can only show Modals.'; console.error(invalidModalWarning); throw new Error(invalidModalWarning); } + if (!(componentClass.prototype instanceof Modal)) { + this.loadingModal = true; + m.redraw.sync(); + + componentClass = componentClass as AsyncModalClass; + componentClass = (await componentClass()).default; + + this.loadingModal = false; + } + this.backdropShown = true; m.redraw.sync(); @@ -79,6 +100,8 @@ export default class ModalManagerState { // skip this RAF call, the hook will attempt to add a focus trap as well as lock scroll // onto the newly added modal before it's in the DOM, creating an extra scrollbar. requestAnimationFrame(() => { + componentClass = componentClass as UnsafeModalClass; + // Set current modal this.modal = { componentClass, attrs, key: this.key++ }; diff --git a/framework/core/js/src/forum/ForumApplication.tsx b/framework/core/js/src/forum/ForumApplication.tsx index d8ea59b5ce..e4ce5584f9 100644 --- a/framework/core/js/src/forum/ForumApplication.tsx +++ b/framework/core/js/src/forum/ForumApplication.tsx @@ -3,10 +3,8 @@ import app from './app'; import History from './utils/History'; import Pane from './utils/Pane'; import DiscussionPage from './components/DiscussionPage'; -import SignUpModal from './components/SignUpModal'; import HeaderPrimary from './components/HeaderPrimary'; import HeaderSecondary from './components/HeaderSecondary'; -import Composer from './components/Composer'; import DiscussionRenamedNotification from './components/DiscussionRenamedNotification'; import CommentPost from './components/CommentPost'; import DiscussionRenamedPost from './components/DiscussionRenamedPost'; @@ -119,7 +117,6 @@ export default class ForumApplication extends Application { m.mount(document.getElementById('header-navigation')!, Navigation); m.mount(document.getElementById('header-primary')!, HeaderPrimary); m.mount(document.getElementById('header-secondary')!, HeaderSecondary); - m.mount(document.getElementById('composer')!, { view: () => }); alertEmailConfirmation(this); @@ -164,7 +161,7 @@ export default class ForumApplication extends Application { if (payload.loggedIn) { window.location.reload(); } else { - this.modal.show(SignUpModal, payload); + this.modal.show(() => import('./components/SignUpModal'), payload); } } } diff --git a/framework/core/js/src/forum/components/CommentPost.js b/framework/core/js/src/forum/components/CommentPost.js index f8f0980317..1a941670dc 100644 --- a/framework/core/js/src/forum/components/CommentPost.js +++ b/framework/core/js/src/forum/components/CommentPost.js @@ -4,7 +4,6 @@ import classList from '../../common/utils/classList'; import PostUser from './PostUser'; import PostMeta from './PostMeta'; import PostEdited from './PostEdited'; -import EditPostComposer from './EditPostComposer'; import ItemList from '../../common/utils/ItemList'; import listItems from '../../common/helpers/listItems'; import Button from '../../common/components/Button'; @@ -88,6 +87,10 @@ export default class CommentPost extends Post { } isEditing() { + const EditPostComposer = flarum.reg.checkModule('core', 'forum/components/EditPostComposer'); + + if (!EditPostComposer) return false; + return app.composer.bodyMatches(EditPostComposer, { post: this.attrs.post }); } diff --git a/framework/core/js/src/forum/components/DiscussionPage.tsx b/framework/core/js/src/forum/components/DiscussionPage.tsx index 12d70726c3..73685448dc 100644 --- a/framework/core/js/src/forum/components/DiscussionPage.tsx +++ b/framework/core/js/src/forum/components/DiscussionPage.tsx @@ -5,8 +5,6 @@ import Page, { IPageAttrs } from '../../common/components/Page'; import ItemList from '../../common/utils/ItemList'; import DiscussionHero from './DiscussionHero'; import DiscussionListPane from './DiscussionListPane'; -import PostStream from './PostStream'; -import PostStreamScrubber from './PostStreamScrubber'; import LoadingIndicator from '../../common/components/LoadingIndicator'; import SplitDropdown from '../../common/components/SplitDropdown'; import listItems from '../../common/helpers/listItems'; @@ -26,6 +24,11 @@ export interface IDiscussionPageAttrs extends IPageAttrs { * the discussion list pane, the hero, the posts, and the sidebar. */ export default class DiscussionPage extends Page { + protected loading: boolean = true; + + protected PostStream: any = null; + protected PostStreamScrubber: any = null; + /** * The discussion that is being viewed. */ @@ -83,7 +86,7 @@ export default class DiscussionPage -
{this.discussion ? this.pageContent().toArray() : this.loadingItems().toArray()}
+
{!this.loading ? this.pageContent().toArray() : this.loadingItems().toArray()}
); } @@ -140,7 +143,7 @@ export default class DiscussionPage - + , 10 ); @@ -152,18 +155,23 @@ export default class DiscussionPage(); - if (preloadedDiscussion) { - // We must wrap this in a setTimeout because if we are mounting this - // component for the first time on page load, then any calls to m.redraw - // will be ineffective and thus any configs (scroll code) will be run - // before stuff is drawn to the page. - setTimeout(this.show.bind(this, preloadedDiscussion), 0); - } else { - const params = this.requestParams(); - - app.store.find('discussions', m.route.param('id'), params).then(this.show.bind(this)); - } + Promise.all([import('./PostStream'), import('./PostStreamScrubber')]).then(([PostStreamImport, PostStreamScrubberImport]) => { + this.PostStream = PostStreamImport.default; + this.PostStreamScrubber = PostStreamScrubberImport.default; + + const preloadedDiscussion = app.preloadedApiDocument(); + if (preloadedDiscussion) { + // We must wrap this in a setTimeout because if we are mounting this + // component for the first time on page load, then any calls to m.redraw + // will be ineffective and thus any configs (scroll code) will be run + // before stuff is drawn to the page. + setTimeout(this.show.bind(this, preloadedDiscussion), 0); + } else { + const params = this.requestParams(); + + app.store.find('discussions', m.route.param('id'), params).then(this.show.bind(this)); + } + }); m.redraw(); } @@ -183,6 +191,8 @@ export default class DiscussionPage): void { + this.loading = false; + app.history.push('discussion', discussion.title()); app.setTitle(discussion.title()); app.setTitleCount(0); @@ -249,7 +259,7 @@ export default class DiscussionPage, -100); + items.add('scrubber', , -100); return items; } diff --git a/framework/core/js/src/forum/components/HeaderSecondary.js b/framework/core/js/src/forum/components/HeaderSecondary.js index 3339411280..95806cb5dd 100644 --- a/framework/core/js/src/forum/components/HeaderSecondary.js +++ b/framework/core/js/src/forum/components/HeaderSecondary.js @@ -1,8 +1,6 @@ import app from '../../forum/app'; import Component from '../../common/Component'; import Button from '../../common/components/Button'; -import LogInModal from './LogInModal'; -import SignUpModal from './SignUpModal'; import SessionDropdown from './SessionDropdown'; import SelectDropdown from '../../common/components/SelectDropdown'; import NotificationsDropdown from './NotificationsDropdown'; @@ -71,7 +69,7 @@ export default class HeaderSecondary extends Component { if (app.forum.attribute('allowSignUp')) { items.add( 'signUp', - , 10 @@ -80,7 +78,7 @@ export default class HeaderSecondary extends Component { items.add( 'logIn', - , 0 diff --git a/framework/core/js/src/forum/components/IndexPage.tsx b/framework/core/js/src/forum/components/IndexPage.tsx index 404eb3487d..87829d281f 100644 --- a/framework/core/js/src/forum/components/IndexPage.tsx +++ b/framework/core/js/src/forum/components/IndexPage.tsx @@ -4,8 +4,6 @@ import ItemList from '../../common/utils/ItemList'; import listItems from '../../common/helpers/listItems'; import DiscussionList from './DiscussionList'; import WelcomeHero from './WelcomeHero'; -import DiscussionComposer from './DiscussionComposer'; -import LogInModal from './LogInModal'; import DiscussionPage from './DiscussionPage'; import Dropdown from '../../common/components/Dropdown'; import Button from '../../common/components/Button'; @@ -284,12 +282,11 @@ export default class IndexPage { return new Promise((resolve, reject) => { if (app.session.user) { - app.composer.load(DiscussionComposer, { user: app.session.user }); - app.composer.show(); + app.composer.load(() => import('./DiscussionComposer'), { user: app.session.user }).then(() => app.composer.show()); return resolve(app.composer); } else { - app.modal.show(LogInModal); + app.modal.show(() => import('./LogInModal')); return reject(); } diff --git a/framework/core/js/src/forum/components/LogInModal.tsx b/framework/core/js/src/forum/components/LogInModal.tsx index c7d4b53725..c087e64310 100644 --- a/framework/core/js/src/forum/components/LogInModal.tsx +++ b/framework/core/js/src/forum/components/LogInModal.tsx @@ -1,7 +1,5 @@ import app from '../../forum/app'; import Modal, { IInternalModalAttrs } from '../../common/components/Modal'; -import ForgotPasswordModal from './ForgotPasswordModal'; -import SignUpModal from './SignUpModal'; import Button from '../../common/components/Button'; import LogInButtons from './LogInButtons'; import extractText from '../../common/utils/extractText'; @@ -141,7 +139,7 @@ export default class LogInModal import('./ForgotPasswordModal'), attrs); } /** @@ -155,7 +153,7 @@ export default class LogInModal import('./SignUpModal'), attrs); } onready() { diff --git a/framework/core/js/src/forum/components/PostStream.js b/framework/core/js/src/forum/components/PostStream.js index 5310cf6428..288c2d9fb2 100644 --- a/framework/core/js/src/forum/components/PostStream.js +++ b/framework/core/js/src/forum/components/PostStream.js @@ -1,7 +1,7 @@ import app from '../../forum/app'; import Component from '../../common/Component'; import ScrollListener from '../../common/utils/ScrollListener'; -import PostLoading from './LoadingPost'; +import LoadingPost from './LoadingPost'; import ReplyPlaceholder from './ReplyPlaceholder'; import Button from '../../common/components/Button'; import ItemList from '../../common/utils/ItemList'; @@ -75,7 +75,7 @@ export default class PostStream extends Component { } else { attrs.key = 'post' + postIds[this.stream.visibleStart + i]; - content = ; + content = ; } return ( diff --git a/framework/core/js/src/forum/components/SignUpModal.tsx b/framework/core/js/src/forum/components/SignUpModal.tsx index 98b7d687f2..c456cbb7f4 100644 --- a/framework/core/js/src/forum/components/SignUpModal.tsx +++ b/framework/core/js/src/forum/components/SignUpModal.tsx @@ -1,6 +1,5 @@ import app from '../../forum/app'; import Modal, { IInternalModalAttrs } from '../../common/components/Modal'; -import LogInModal from './LogInModal'; import Button from '../../common/components/Button'; import LogInButtons from './LogInButtons'; import extractText from '../../common/utils/extractText'; @@ -151,7 +150,7 @@ export default class SignUpModal import('./LogInModal'), attrs); } onready() { diff --git a/framework/core/js/src/forum/components/UserSecurityPage.tsx b/framework/core/js/src/forum/components/UserSecurityPage.tsx index bb32f9f31f..df9a35e7e1 100644 --- a/framework/core/js/src/forum/components/UserSecurityPage.tsx +++ b/framework/core/js/src/forum/components/UserSecurityPage.tsx @@ -8,7 +8,6 @@ import AccessTokensList from './AccessTokensList'; import LoadingIndicator from '../../common/components/LoadingIndicator'; import Button from '../../common/components/Button'; import NewAccessTokenModal from './NewAccessTokenModal'; -import { camelCaseToSnakeCase } from '../../common/utils/string'; import type AccessToken from '../../common/models/AccessToken'; import type Mithril from 'mithril'; import Tooltip from '../../common/components/Tooltip'; diff --git a/framework/core/js/src/forum/forum.ts b/framework/core/js/src/forum/forum.ts index c5cca074fb..8534f547d8 100644 --- a/framework/core/js/src/forum/forum.ts +++ b/framework/core/js/src/forum/forum.ts @@ -14,61 +14,40 @@ import './states/GlobalSearchState'; import './states/NotificationListState'; import './states/PostStreamState'; import './states/SearchState'; -import './states/UserSecurityPageState'; import './components/AffixedSidebar'; import './components/DiscussionPage'; import './components/DiscussionListPane'; -import './components/LogInModal'; -import './components/ComposerBody'; -import './components/ForgotPasswordModal'; import './components/Notification'; import './components/LogInButton'; -import './components/DiscussionsUserPage'; -import './components/Composer'; import './components/SessionDropdown'; import './components/HeaderPrimary'; import './components/PostEdited'; -import './components/PostStream'; -import './components/ChangePasswordModal'; import './components/IndexPage'; import './components/DiscussionRenamedNotification'; import './components/DiscussionsSearchSource'; import './components/HeaderSecondary'; -import './components/ComposerButton'; import './components/DiscussionList'; -import './components/ReplyPlaceholder'; import './components/AvatarEditor'; import './components/Post'; -import './components/SettingsPage'; import './components/TerminalPost'; -import './components/ChangeEmailModal'; import './components/NotificationsDropdown'; import './components/UserPage'; import './components/PostUser'; import './components/UserCard'; import './components/UsersSearchSource'; -import './components/UserSecurityPage'; -import './components/NotificationGrid'; import './components/PostPreview'; import './components/EventPost'; import './components/DiscussionHero'; import './components/PostMeta'; import './components/DiscussionRenamedPost'; -import './components/DiscussionComposer'; import './components/LogInButtons'; import './components/NotificationList'; import './components/WelcomeHero'; -import './components/SignUpModal'; import './components/CommentPost'; import './components/ComposerPostPreview'; -import './components/ReplyComposer'; -import './components/NotificationsPage'; -import './components/PostStreamScrubber'; -import './components/EditPostComposer'; import './components/RenameDiscussionModal'; import './components/Search'; import './components/DiscussionListItem'; -import './components/LoadingPost'; import './components/PostsUserPage'; import './resolvers/DiscussionPageResolver'; import './routes'; diff --git a/framework/core/js/src/forum/routes.ts b/framework/core/js/src/forum/routes.ts index 42c1beaf95..5b18b1c6d0 100644 --- a/framework/core/js/src/forum/routes.ts +++ b/framework/core/js/src/forum/routes.ts @@ -2,14 +2,10 @@ import ForumApplication from './ForumApplication'; import IndexPage from './components/IndexPage'; import DiscussionPage from './components/DiscussionPage'; import PostsUserPage from './components/PostsUserPage'; -import DiscussionsUserPage from './components/DiscussionsUserPage'; -import SettingsPage from './components/SettingsPage'; -import NotificationsPage from './components/NotificationsPage'; import DiscussionPageResolver from './resolvers/DiscussionPageResolver'; import Discussion from '../common/models/Discussion'; import type Post from '../common/models/Post'; import type User from '../common/models/User'; -import UserSecurityPage from './components/UserSecurityPage'; /** * Helper functions to generate URLs to form pages. @@ -32,11 +28,11 @@ export default function (app: ForumApplication) { user: { path: '/u/:username', component: PostsUserPage }, 'user.posts': { path: '/u/:username', component: PostsUserPage }, - 'user.discussions': { path: '/u/:username/discussions', component: DiscussionsUserPage }, + 'user.discussions': { path: '/u/:username/discussions', component: () => import('./components/DiscussionsUserPage') }, - settings: { path: '/settings', component: SettingsPage }, - 'user.security': { path: '/u/:username/security', component: UserSecurityPage }, - notifications: { path: '/notifications', component: NotificationsPage }, + settings: { path: '/settings', component: () => import('./components/SettingsPage') }, + 'user.security': { path: '/u/:username/security', component: () => import('./components/UserSecurityPage') }, + notifications: { path: '/notifications', component: () => import('./components/NotificationsPage') }, }; } diff --git a/framework/core/js/src/forum/states/ComposerState.js b/framework/core/js/src/forum/states/ComposerState.js index df58b88c51..4d88f8cc67 100644 --- a/framework/core/js/src/forum/states/ComposerState.js +++ b/framework/core/js/src/forum/states/ComposerState.js @@ -1,7 +1,7 @@ import app from '../../forum/app'; import subclassOf from '../../common/utils/subclassOf'; import Stream from '../../common/utils/Stream'; -import ReplyComposer from '../components/ReplyComposer'; +import Component from '../../common/Component'; class ComposerState { constructor() { @@ -34,15 +34,27 @@ class ComposerState { */ this.editor = null; + /** + * If the composer was loaded and mounted. + * + * @type {boolean} + */ + this.mounted = false; + this.clear(); } /** * Load a content component into the composer. * - * @param {typeof import('../components/ComposerBody').default} componentClass + * @param {() => Promise | typeof import('../components/ComposerBody').default} componentClass + * @param {object} attrs */ - load(componentClass, attrs) { + async load(componentClass, attrs) { + if (!(componentClass.prototype instanceof Component)) { + componentClass = (await componentClass()).default; + } + const body = { componentClass, attrs }; if (this.preventExit()) return; @@ -81,7 +93,13 @@ class ComposerState { /** * Show the composer. */ - show() { + async show() { + if (!this.mounted) { + const Composer = (await import('../components/Composer')).default; + m.mount(document.getElementById('composer'), { view: () => }); + this.mounted = true; + } + if (this.position === ComposerState.Position.NORMAL || this.position === ComposerState.Position.FULLSCREEN) return; this.position = ComposerState.Position.NORMAL; @@ -185,6 +203,10 @@ class ComposerState { * @return {boolean} */ composingReplyTo(discussion) { + const ReplyComposer = flarum.reg.checkModule('core', 'forum/components/ReplyComposer'); + + if (!ReplyComposer) return false; + return this.isVisible() && this.bodyMatches(ReplyComposer, { discussion }); } diff --git a/framework/core/js/src/forum/utils/DiscussionControls.js b/framework/core/js/src/forum/utils/DiscussionControls.js index 211a275b66..2ba15c7818 100644 --- a/framework/core/js/src/forum/utils/DiscussionControls.js +++ b/framework/core/js/src/forum/utils/DiscussionControls.js @@ -1,7 +1,5 @@ import app from '../../forum/app'; import DiscussionPage from '../components/DiscussionPage'; -import ReplyComposer from '../components/ReplyComposer'; -import LogInModal from '../components/LogInModal'; import Button from '../../common/components/Button'; import Separator from '../../common/components/Separator'; import RenameDiscussionModal from '../components/RenameDiscussionModal'; @@ -168,12 +166,15 @@ const DiscussionControls = { if (app.session.user) { if (this.canReply()) { if (!app.composer.composingReplyTo(this) || forceRefresh) { - app.composer.load(ReplyComposer, { - user: app.session.user, - discussion: this, - }); + app.composer + .load(() => import('../components/ReplyComposer'), { + user: app.session.user, + discussion: this, + }) + .then(() => app.composer.show()); + } else { + app.composer.show(); } - app.composer.show(); if (goToLast && app.viewingDiscussion(this) && !app.composer.isFullScreen()) { app.current.get('stream').goToNumber('reply'); @@ -185,7 +186,7 @@ const DiscussionControls = { } } - app.modal.show(LogInModal); + app.modal.show(() => import('../components/LogInModal')); return reject(); }); diff --git a/framework/core/js/src/forum/utils/PostControls.js b/framework/core/js/src/forum/utils/PostControls.js index 3ee67c28ce..9385126a45 100644 --- a/framework/core/js/src/forum/utils/PostControls.js +++ b/framework/core/js/src/forum/utils/PostControls.js @@ -1,5 +1,4 @@ import app from '../../forum/app'; -import EditPostComposer from '../components/EditPostComposer'; import Button from '../../common/components/Button'; import Separator from '../../common/components/Separator'; import ItemList from '../../common/utils/ItemList'; @@ -121,8 +120,7 @@ const PostControls = { */ editAction() { return new Promise((resolve) => { - app.composer.load(EditPostComposer, { post: this }); - app.composer.show(); + app.composer.load(() => import('../components/EditPostComposer'), { post: this }).then(() => app.composer.show()); return resolve(); }); diff --git a/framework/core/js/src/forum/utils/UserControls.js b/framework/core/js/src/forum/utils/UserControls.js index adbe011f70..35abbf0f1d 100644 --- a/framework/core/js/src/forum/utils/UserControls.js +++ b/framework/core/js/src/forum/utils/UserControls.js @@ -1,7 +1,6 @@ import app from '../../forum/app'; import Button from '../../common/components/Button'; import Separator from '../../common/components/Separator'; -import EditUserModal from '../../common/components/EditUserModal'; import UserPage from '../components/UserPage'; import ItemList from '../../common/utils/ItemList'; @@ -143,7 +142,7 @@ const UserControls = { * @param {import('../../common/models/User').default} user */ editAction(user) { - app.modal.show(EditUserModal, { user }); + app.modal.show(() => import('../../common/components/EditUserModal'), { user }); }, }; diff --git a/framework/core/less/common/Alert.less b/framework/core/less/common/Alert.less index 337a4b848c..4c2d844d81 100644 --- a/framework/core/less/common/Alert.less +++ b/framework/core/less/common/Alert.less @@ -3,6 +3,7 @@ border-radius: var(--border-radius); line-height: 1.5; + --loading-indicator-color: var(--alert-color); background: var(--alert-bg); &, diff --git a/framework/core/less/common/LoadingIndicator.less b/framework/core/less/common/LoadingIndicator.less index 21aedd78db..09e8a0212f 100644 --- a/framework/core/less/common/LoadingIndicator.less +++ b/framework/core/less/common/LoadingIndicator.less @@ -22,7 +22,7 @@ --size: 24px; --thickness: 2px; - color: var(--muted-color); + color: var(--loading-indicator-color); // Center vertically and horizontally // Allows people to set `height` and it'll stay centered within the new height diff --git a/framework/core/less/common/Modal.less b/framework/core/less/common/Modal.less index d338ee9550..a5ff47a42c 100644 --- a/framework/core/less/common/Modal.less +++ b/framework/core/less/common/Modal.less @@ -20,16 +20,19 @@ } .Modal-backdrop { + --loading-indicator-color: var(--body-bg); + background: var(--overlay-bg); position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; + inset: 0; opacity: 0; transition: opacity 0.2s ease-out; z-index: ~"calc(var(--zindex-modal) + var(--modal-count) - 2)"; + display: flex; + align-items: center; + justify-content: center; + &[data-showing] { opacity: 1; } diff --git a/framework/core/less/common/root.less b/framework/core/less/common/root.less index 0bafe38ebc..21cdbbc710 100644 --- a/framework/core/less/common/root.less +++ b/framework/core/less/common/root.less @@ -81,6 +81,8 @@ --tooltip-bg: @tooltip-bg; --tooltip-color: @tooltip-color; + --loading-indicator-color: var(--muted-color); + --online-user-circle-color: @online-user-circle-color; --discussion-title-color: mix(@heading-color, @body-bg, 55%); diff --git a/framework/core/src/Api/Serializer/ForumSerializer.php b/framework/core/src/Api/Serializer/ForumSerializer.php index f5a9bfdb7f..1453578a01 100644 --- a/framework/core/src/Api/Serializer/ForumSerializer.php +++ b/framework/core/src/Api/Serializer/ForumSerializer.php @@ -90,6 +90,7 @@ protected function getDefaultAttributes(object|array $model): array 'canCreateAccessToken' => $this->actor->can('createAccessToken'), 'canModerateAccessTokens' => $this->actor->can('moderateAccessTokens'), 'assetsBaseUrl' => rtrim($this->assetsFilesystem->url(''), '/'), + 'jsChunksBaseUrl' => $this->assetsFilesystem->url('js'), ]; if ($this->actor->can('administrate')) { diff --git a/framework/core/src/Extend/Frontend.php b/framework/core/src/Extend/Frontend.php index 82abbb3898..67a204ffa8 100644 --- a/framework/core/src/Extend/Frontend.php +++ b/framework/core/src/Extend/Frontend.php @@ -36,6 +36,7 @@ class Frontend implements ExtenderInterface private array $content = []; private array $preloadArrs = []; private ?string $titleDriver = null; + private array $jsDirectory = []; /** * @param string $frontend: The name of the frontend. @@ -71,6 +72,20 @@ public function js(string $path): self return $this; } + /** + * Add a directory of JavaScript files to include in the JS assets public directory. + * Primarily used to copy JS chunks. + * + * @param string $path The path to the specific frontend chunks directory. + * @return $this + */ + public function jsDirectory(string $path): self + { + $this->jsDirectory[] = $path; + + return $this; + } + /** * Add a route to the frontend. * @@ -183,7 +198,7 @@ public function extend(Container $container, Extension $extension = null): void private function registerAssets(Container $container, string $moduleName): void { - if (empty($this->css) && empty($this->js)) { + if (empty($this->css) && empty($this->js) && empty($this->jsDirectory)) { return; } @@ -209,6 +224,14 @@ private function registerAssets(Container $container, string $moduleName): void } }); } + + if (! empty($this->jsDirectory)) { + $assets->jsDirectory(function (SourceCollector $sources) use ($moduleName) { + foreach ($this->jsDirectory as $path) { + $sources->addDirectory($path, $moduleName); + } + }); + } }); if (! $container->bound($abstract)) { diff --git a/framework/core/src/Filesystem/FilesystemServiceProvider.php b/framework/core/src/Filesystem/FilesystemServiceProvider.php index 633fe64f33..31273ef95e 100644 --- a/framework/core/src/Filesystem/FilesystemServiceProvider.php +++ b/framework/core/src/Filesystem/FilesystemServiceProvider.php @@ -17,6 +17,7 @@ use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Arr; use Intervention\Image\ImageManager; +use League\Flysystem\Visibility; use RuntimeException; class FilesystemServiceProvider extends AbstractServiceProvider @@ -33,8 +34,9 @@ public function register(): void return [ 'flarum-assets' => function (Paths $paths, UrlGenerator $url) { return [ - 'root' => "$paths->public/assets", - 'url' => $url->to('forum')->path('assets') + 'root' => "$paths->public/assets", + 'url' => $url->to('forum')->path('assets'), + 'visibility' => Visibility::PUBLIC ]; }, 'flarum-avatars' => function (Paths $paths, UrlGenerator $url) { diff --git a/framework/core/src/Forum/ForumServiceProvider.php b/framework/core/src/Forum/ForumServiceProvider.php index 875fe27ca4..65eb75c21c 100644 --- a/framework/core/src/Forum/ForumServiceProvider.php +++ b/framework/core/src/Forum/ForumServiceProvider.php @@ -110,6 +110,10 @@ public function register(): void }); }); + $assets->jsDirectory(function (SourceCollector $sources) { + $sources->addDirectory(__DIR__.'/../../js/dist/forum', 'core'); + }); + $assets->css(function (SourceCollector $sources) use ($container) { $sources->addFile(__DIR__.'/../../less/forum.less'); $sources->addString(function () use ($container) { diff --git a/framework/core/src/Frontend/Assets.php b/framework/core/src/Frontend/Assets.php index 63415a9197..6de2d5fdf3 100644 --- a/framework/core/src/Frontend/Assets.php +++ b/framework/core/src/Frontend/Assets.php @@ -11,6 +11,7 @@ use Flarum\Frontend\Compiler\CompilerInterface; use Flarum\Frontend\Compiler\JsCompiler; +use Flarum\Frontend\Compiler\JsDirectoryCompiler; use Flarum\Frontend\Compiler\LessCompiler; use Flarum\Frontend\Compiler\Source\SourceCollector; use Illuminate\Contracts\Filesystem\Cloud; @@ -29,7 +30,8 @@ class Assets 'js' => [], 'css' => [], 'localeJs' => [], - 'localeCss' => [] + 'localeCss' => [], + 'jsDirectory' => [], ]; protected array $lessImportOverrides = []; @@ -72,6 +74,13 @@ public function localeCss(callable $callback): static return $this; } + public function jsDirectory(callable $callback): static + { + $this->addSources('jsDirectory', $callback); + + return $this; + } + private function addSources(string $type, callable $callback): void { $this->sources[$type][] = $callback; @@ -122,6 +131,15 @@ public function makeLocaleCss(string $locale): LessCompiler return $compiler; } + public function makeJsDirectory(): JsDirectoryCompiler + { + $compiler = $this->makeJsDirectoryCompiler('js'.DIRECTORY_SEPARATOR.'{ext}'.DIRECTORY_SEPARATOR.$this->name); + + $this->populate($compiler, 'jsDirectory'); + + return $compiler; + } + protected function makeJsCompiler(string $filename): JsCompiler { return resolve(JsCompiler::class, [ @@ -158,6 +176,14 @@ protected function makeLessCompiler(string $filename): LessCompiler return $compiler; } + protected function makeJsDirectoryCompiler(string $string): JsDirectoryCompiler + { + return resolve(JsDirectoryCompiler::class, [ + 'assetsDir' => $this->assetsDir, + 'destinationPath' => $string + ]); + } + public function getName(): string { return $this->name; diff --git a/framework/core/src/Frontend/Compiler/CompilerInterface.php b/framework/core/src/Frontend/Compiler/CompilerInterface.php index 6750c92335..b3de25c037 100644 --- a/framework/core/src/Frontend/Compiler/CompilerInterface.php +++ b/framework/core/src/Frontend/Compiler/CompilerInterface.php @@ -11,7 +11,7 @@ interface CompilerInterface { - public function getFilename(): string; + public function getFilename(): ?string; public function setFilename(string $filename): void; diff --git a/framework/core/src/Frontend/Compiler/Concerns/HasSources.php b/framework/core/src/Frontend/Compiler/Concerns/HasSources.php new file mode 100644 index 0000000000..3a0bd2f8d0 --- /dev/null +++ b/framework/core/src/Frontend/Compiler/Concerns/HasSources.php @@ -0,0 +1,42 @@ +sourcesCallbacks[] = $callback; + } + + /** + * @return SourceInterface[] + */ + protected function getSources(): array + { + $sources = new SourceCollector($this->allowedSourceTypes()); + + foreach ($this->sourcesCallbacks as $callback) { + $callback($sources); + } + + return $sources->getSources(); + } + + abstract protected function allowedSourceTypes(): array; +} diff --git a/framework/core/src/Frontend/Compiler/FileVersioner.php b/framework/core/src/Frontend/Compiler/FileVersioner.php index 4260ff3398..c600c64261 100644 --- a/framework/core/src/Frontend/Compiler/FileVersioner.php +++ b/framework/core/src/Frontend/Compiler/FileVersioner.php @@ -48,4 +48,13 @@ public function getRevision(string $file): ?string return null; } + + public function allRevisions(): array + { + if ($contents = $this->filesystem->get(static::REV_MANIFEST)) { + return json_decode($contents, true); + } + + return []; + } } diff --git a/framework/core/src/Frontend/Compiler/JsDirectoryCompiler.php b/framework/core/src/Frontend/Compiler/JsDirectoryCompiler.php new file mode 100644 index 0000000000..bc5406e5bf --- /dev/null +++ b/framework/core/src/Frontend/Compiler/JsDirectoryCompiler.php @@ -0,0 +1,137 @@ +versioner = new FileVersioner($assetsDir); + } + + public function getFilename(): ?string + { + return null; + } + + public function setFilename(string $filename): void + { + // + } + + public function commit(bool $force = false): void + { + foreach ($this->getSources() as $source) { + $this->compileSource($source, $force); + } + } + + public function getUrl(): ?string + { + return null; + } + + public function flush(): void + { + foreach ($this->getSources() as $source) { + $this->flushSource($source); + } + + // Delete the remaining empty directory. + $this->assetsDir->deleteDirectory($this->destinationPath); + } + + protected function allowedSourceTypes(): array + { + return [DirectorySource::class]; + } + + protected function compileSource(DirectorySource $source, bool $force = false): void + { + $this->eachFile($source, fn (JsCompiler $compiler) => $compiler->commit($force)); + } + + protected function flushSource(DirectorySource $source): void + { + $this->eachFile($source, fn (JsCompiler $compiler) => $compiler->flush()); + + $destinationDir = $this->destinationFor($source); + + // Destination can still contain stale chunks. + $this->assetsDir->deleteDirectory($destinationDir); + + // Delete stale revisions. + $remainingRevisions = $this->versioner->allRevisions(); + + foreach ($remainingRevisions as $filename => $revision) { + if (str_starts_with($filename, $destinationDir)) { + $this->versioner->putRevision($filename, null); + } + } + } + + protected function eachFile(DirectorySource $source, callable $callback): void + { + $filesystem = $source->getFilesystem(); + + foreach ($filesystem->allFiles() as $relativeFilePath) { + // Skip non-JS files. + if ($filesystem->mimeType($relativeFilePath) !== 'application/javascript') { + continue; + } + + $jsCompiler = $this->compilerFor($source, $filesystem, $relativeFilePath); + $callback($jsCompiler); + } + } + + protected function compilerFor(DirectorySource $source, FilesystemAdapter $filesystem, string $relativeFilePath): JsCompiler + { + // Filesystem's root is the actual directory we want to copy. + // The destination path is relative to the assets' filesystem. + + $jsCompiler = resolve(JsCompiler::class, [ + 'assetsDir' => $this->assetsDir, + // We put each file in `js/extensionId/frontend` (path provided) `/relativeFilePath` (such as `components/LogInModal.js`). + 'filename' => $this->destinationFor($source, $relativeFilePath), + ]); + + $jsCompiler->addSources( + fn (SourceCollector $sources) => $sources->addFile($filesystem->path($relativeFilePath), $source->getExtensionId()) + ); + + return $jsCompiler; + } + + protected function destinationFor(DirectorySource $source, ?string $relativeFilePath = null): string + { + $extensionId = $source->getExtensionId() ?? 'core'; + + return str_replace('{ext}', $extensionId, $this->destinationPath).DIRECTORY_SEPARATOR.$relativeFilePath; + } +} diff --git a/framework/core/src/Frontend/Compiler/RevisionCompiler.php b/framework/core/src/Frontend/Compiler/RevisionCompiler.php index e1eae71164..e47ef9bd74 100644 --- a/framework/core/src/Frontend/Compiler/RevisionCompiler.php +++ b/framework/core/src/Frontend/Compiler/RevisionCompiler.php @@ -9,8 +9,10 @@ namespace Flarum\Frontend\Compiler; -use Flarum\Frontend\Compiler\Source\SourceCollector; +use Flarum\Frontend\Compiler\Concerns\HasSources; +use Flarum\Frontend\Compiler\Source\FileSource; use Flarum\Frontend\Compiler\Source\SourceInterface; +use Flarum\Frontend\Compiler\Source\StringSource; use Illuminate\Contracts\Filesystem\Cloud; /** @@ -18,38 +20,17 @@ */ class RevisionCompiler implements CompilerInterface { - const EMPTY_REVISION = 'empty'; - - /** - * @var Cloud - */ - protected $assetsDir; + use HasSources; - /** - * @var VersionerInterface - */ - protected $versioner; - - /** - * @var string - */ - protected $filename; + const EMPTY_REVISION = 'empty'; - /** - * @var callable[] - */ - protected $sourcesCallbacks = []; + protected VersionerInterface $versioner; - /** - * @param Cloud $assetsDir - * @param string $filename - * @param VersionerInterface|null $versioner @deprecated nullable will be removed at v2.0 - */ - public function __construct(Cloud $assetsDir, string $filename, VersionerInterface $versioner = null) - { - $this->assetsDir = $assetsDir; - $this->filename = $filename; - $this->versioner = $versioner ?: new FileVersioner($assetsDir); + public function __construct( + protected Cloud $assetsDir, + protected string $filename, + ) { + $this->versioner = new FileVersioner($assetsDir); } public function getFilename(): string @@ -84,25 +65,6 @@ public function commit(bool $force = false): void } } - public function addSources(callable $callback): void - { - $this->sourcesCallbacks[] = $callback; - } - - /** - * @return SourceInterface[] - */ - protected function getSources(): array - { - $sources = new SourceCollector; - - foreach ($this->sourcesCallbacks as $callback) { - $callback($sources); - } - - return $sources->getSources(); - } - public function getUrl(): ?string { $revision = $this->versioner->getRevision($this->filename); @@ -197,4 +159,9 @@ protected function delete(string $file): void $this->assetsDir->delete($file); } } + + protected function allowedSourceTypes(): array + { + return [FileSource::class, StringSource::class]; + } } diff --git a/framework/core/src/Frontend/Compiler/Source/DirectorySource.php b/framework/core/src/Frontend/Compiler/Source/DirectorySource.php new file mode 100644 index 0000000000..494d5cfb0a --- /dev/null +++ b/framework/core/src/Frontend/Compiler/Source/DirectorySource.php @@ -0,0 +1,50 @@ +filesystem = new FilesystemAdapter( + new Filesystem($adapter = new LocalFilesystemAdapter($path)), + $adapter, + ['root' => $path] + ); + } + + public function getContent(): string + { + return ''; + } + + public function getCacheDifferentiator(): array + { + return [$this->path, filemtime($this->path)]; + } + + public function getFilesystem(): FilesystemAdapter + { + return $this->filesystem; + } + + public function getExtensionId(): ?string + { + return $this->extensionId; + } +} diff --git a/framework/core/src/Frontend/Compiler/Source/SourceCollector.php b/framework/core/src/Frontend/Compiler/Source/SourceCollector.php index 88baa69831..86619905c4 100644 --- a/framework/core/src/Frontend/Compiler/Source/SourceCollector.php +++ b/framework/core/src/Frontend/Compiler/Source/SourceCollector.php @@ -16,6 +16,11 @@ */ class SourceCollector { + public function __construct( + protected array $allowedSourceTypes = [] + ) { + } + /** * @var SourceInterface[] */ @@ -23,14 +28,27 @@ class SourceCollector public function addFile(string $file, string $extensionId = null): static { - $this->sources[] = new FileSource($file, $extensionId); + $this->sources[] = $this->validateSourceType( + new FileSource($file, $extensionId) + ); return $this; } public function addString(Closure $callback): static { - $this->sources[] = new StringSource($callback); + $this->sources[] = $this->validateSourceType( + new StringSource($callback) + ); + + return $this; + } + + public function addDirectory(string $directory, string $extensionId = null): static + { + $this->sources[] = $this->validateSourceType( + new DirectorySource($directory, $extensionId) + ); return $this; } @@ -42,4 +60,28 @@ public function getSources(): array { return $this->sources; } + + protected function validateSourceType(SourceInterface $source): SourceInterface + { + // allowedSourceTypes is an array of class names (or interface names) + // so we need to check if the $source is an instance of one of those classes/interfaces (could be a parent class as well) + $isInstanceOfOneOfTheAllowedSourceTypes = false; + + foreach ($this->allowedSourceTypes as $allowedSourceType) { + if ($source instanceof $allowedSourceType) { + $isInstanceOfOneOfTheAllowedSourceTypes = true; + break; + } + } + + if (! empty($this->allowedSourceTypes) && ! $isInstanceOfOneOfTheAllowedSourceTypes) { + throw new \InvalidArgumentException(sprintf( + 'Source type %s is not allowed for this collector. Allowed types are: %s', + get_class($source), + implode(', ', $this->allowedSourceTypes) + )); + } + + return $source; + } } diff --git a/framework/core/src/Frontend/Compiler/VersionerInterface.php b/framework/core/src/Frontend/Compiler/VersionerInterface.php index 13cd5324f3..9bd6eda866 100644 --- a/framework/core/src/Frontend/Compiler/VersionerInterface.php +++ b/framework/core/src/Frontend/Compiler/VersionerInterface.php @@ -14,4 +14,6 @@ interface VersionerInterface public function putRevision(string $file, ?string $revision): void; public function getRevision(string $file): ?string; + + public function allRevisions(): array; } diff --git a/framework/core/src/Frontend/Content/Assets.php b/framework/core/src/Frontend/Content/Assets.php index 493b31de00..bab92ac1f6 100644 --- a/framework/core/src/Frontend/Content/Assets.php +++ b/framework/core/src/Frontend/Content/Assets.php @@ -20,6 +20,7 @@ class Assets { protected FrontendAssets $assets; + protected FrontendAssets $commonAssets; public function __construct( protected Container $container, @@ -35,6 +36,7 @@ public function __construct( public function forFrontend(string $name): self { $this->assets = $this->container->make('flarum.assets.'.$name); + $this->commonAssets = $this->container->make('flarum.assets.common'); return $this; } @@ -59,10 +61,16 @@ public function __invoke(Document $document, Request $request): void */ protected function assembleCompilers(?string $locale): array { - return [ - 'js' => [$this->assets->makeJs(), $this->assets->makeLocaleJs($locale)], + $frontendCompilers = [ + 'js' => [$this->assets->makeJs(), $this->assets->makeLocaleJs($locale), $this->assets->makeJsDirectory()], 'css' => [$this->assets->makeCss(), $this->assets->makeLocaleCss($locale)] ]; + + $commonCompilers = [ + 'js' => [$this->commonAssets->makeJsDirectory()], + ]; + + return array_merge_recursive($commonCompilers, $frontendCompilers); } /** diff --git a/framework/core/src/Frontend/Document.php b/framework/core/src/Frontend/Document.php index 157dfeea85..3b03602cc1 100644 --- a/framework/core/src/Frontend/Document.php +++ b/framework/core/src/Frontend/Document.php @@ -9,7 +9,11 @@ namespace Flarum\Frontend; +use Flarum\Foundation\Config; +use Flarum\Frontend\Compiler\FileVersioner; +use Flarum\Frontend\Compiler\VersionerInterface; use Flarum\Frontend\Driver\TitleDriverInterface; +use Illuminate\Contracts\Filesystem\Factory as FilesystemFactory; use Illuminate\Contracts\Support\Renderable; use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\View; @@ -131,12 +135,22 @@ class Document implements Renderable */ public array $preloads = []; + /** + * We need the versioner to get the revisions of split chunks. + */ + protected VersionerInterface $versioner; + public function __construct( protected Factory $view, protected array $forumApiDocument, protected Request $request, - protected TitleDriverInterface $titleDriver + protected TitleDriverInterface $titleDriver, + protected Config $config, + FilesystemFactory $filesystem ) { + $this->versioner = new FileVersioner( + $filesystem->disk('flarum-assets') + ); } public function render(): string @@ -157,6 +171,8 @@ protected function makeView(): View 'js' => $this->makeJs(), 'head' => $this->makeHead(), 'foot' => $this->makeFoot(), + 'revisions' => $this->versioner->allRevisions(), + 'debug' => $this->config->inDebugMode(), ]); } diff --git a/framework/core/src/Frontend/Frontend.php b/framework/core/src/Frontend/Frontend.php index 8fa755f502..e8ac8b35d1 100644 --- a/framework/core/src/Frontend/Frontend.php +++ b/framework/core/src/Frontend/Frontend.php @@ -10,8 +10,7 @@ namespace Flarum\Frontend; use Flarum\Api\Client; -use Flarum\Frontend\Driver\TitleDriverInterface; -use Illuminate\Contracts\View\Factory; +use Illuminate\Contracts\Container\Container; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; @@ -23,9 +22,8 @@ class Frontend protected array $content = []; public function __construct( - protected Factory $view, protected Client $api, - protected TitleDriverInterface $titleDriver + protected Container $container ) { } @@ -36,9 +34,9 @@ public function content(callable $content): void public function document(Request $request): Document { - $forumDocument = $this->getForumDocument($request); + $forumApiDocument = $this->getForumDocument($request); - $document = new Document($this->view, $forumDocument, $request, $this->titleDriver); + $document = $this->container->makeWith(Document::class, compact('forumApiDocument', 'request')); $this->populate($document, $request); diff --git a/framework/core/src/Frontend/FrontendServiceProvider.php b/framework/core/src/Frontend/FrontendServiceProvider.php index 4b3b01588d..c7a8aa98ec 100644 --- a/framework/core/src/Frontend/FrontendServiceProvider.php +++ b/framework/core/src/Frontend/FrontendServiceProvider.php @@ -9,15 +9,20 @@ namespace Flarum\Frontend; +use Flarum\Extension\Event\Disabled; +use Flarum\Extension\Event\Enabled; use Flarum\Foundation\AbstractServiceProvider; +use Flarum\Foundation\Event\ClearingCache; use Flarum\Foundation\Paths; use Flarum\Frontend\Compiler\Source\SourceCollector; use Flarum\Frontend\Driver\BasicTitleDriver; use Flarum\Frontend\Driver\TitleDriverInterface; use Flarum\Http\SlugManager; use Flarum\Http\UrlGenerator; +use Flarum\Locale\LocaleManager; use Flarum\Settings\SettingsRepositoryInterface; use Illuminate\Contracts\Container\Container; +use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\View\Factory as ViewFactory; class FrontendServiceProvider extends AbstractServiceProvider @@ -163,9 +168,20 @@ function (Container $container) { return []; } ); + + $this->container->bind('flarum.assets.common', function (Container $container) { + /** @var \Flarum\Frontend\Assets $assets */ + $assets = $container->make('flarum.assets.factory')('common'); + + $assets->jsDirectory(function (SourceCollector $sources) { + $sources->addDirectory(__DIR__.'/../../js/dist/common', 'core'); + }); + + return $assets; + }); } - public function boot(Container $container, ViewFactory $views): void + public function boot(Container $container, Dispatcher $events, ViewFactory $views): void { $this->loadViewsFrom(__DIR__.'/../../views', 'flarum'); @@ -174,6 +190,17 @@ public function boot(Container $container, ViewFactory $views): void 'url' => $container->make(UrlGenerator::class), 'slugManager' => $container->make(SlugManager::class) ]); + + $events->listen( + [Enabled::class, Disabled::class, ClearingCache::class], + function () use ($container) { + $recompile = new RecompileFrontendAssets( + $container->make('flarum.assets.common'), + $container->make(LocaleManager::class) + ); + $recompile->flush(); + } + ); } public function addBaseCss(SourceCollector $sources): void diff --git a/framework/core/src/Frontend/RecompileFrontendAssets.php b/framework/core/src/Frontend/RecompileFrontendAssets.php index 799dbd4791..49c85bbb17 100644 --- a/framework/core/src/Frontend/RecompileFrontendAssets.php +++ b/framework/core/src/Frontend/RecompileFrontendAssets.php @@ -53,5 +53,7 @@ protected function flushJs(): void foreach ($this->locales->getLocales() as $locale => $name) { $this->assets->makeLocaleJs($locale)->flush(); } + + $this->assets->makeJsDirectory()->flush(); } } diff --git a/framework/core/views/frontend/app.blade.php b/framework/core/views/frontend/app.blade.php index 2b3b871186..ff025f38d2 100644 --- a/framework/core/views/frontend/app.blade.php +++ b/framework/core/views/frontend/app.blade.php @@ -16,11 +16,13 @@ {!! $js !!} + +