From b80151583e7d1da3fffeed24d65e24dee65b8432 Mon Sep 17 00:00:00 2001 From: flashwave Date: Tue, 30 Jan 2024 23:47:02 +0000 Subject: [PATCH] Added private messages. --- assets/misuzu.css/forum/post.css | 3 + assets/misuzu.css/header.css | 11 +- assets/misuzu.css/main.css | 3 +- assets/misuzu.css/messagebox.css | 1 + assets/misuzu.css/messages/actions.css | 37 ++ assets/misuzu.css/messages/columns.css | 26 + assets/misuzu.css/messages/entry.css | 80 +++ assets/misuzu.css/messages/folder.css | 33 + assets/misuzu.css/messages/message.css | 135 ++++ assets/misuzu.css/messages/messages.css | 9 + assets/misuzu.css/messages/recipient.css | 17 + assets/misuzu.css/messages/reply.css | 52 ++ assets/misuzu.css/messages/sidebar.css | 11 + assets/misuzu.css/messages/thread.css | 5 + assets/misuzu.js/csrfp.js | 40 ++ assets/misuzu.js/embed/audio.js | 2 +- assets/misuzu.js/embed/video.js | 6 +- assets/misuzu.js/forum/editor.jsx | 20 +- assets/misuzu.js/main.js | 3 + assets/misuzu.js/messages/actbtn.js | 89 +++ assets/misuzu.js/messages/list.js | 167 +++++ assets/misuzu.js/messages/messages.js | 385 +++++++++++ assets/misuzu.js/messages/recipient.js | 56 ++ assets/misuzu.js/messages/reply.jsx | 154 +++++ assets/misuzu.js/messages/thread.js | 78 +++ assets/misuzu.js/msgbox.jsx | 87 ++- assets/misuzu.js/parsing.js | 56 ++ assets/misuzu.js/watcher.js | 2 +- ...024_01_30_233734_create_messages_table.php | 48 ++ public-legacy/profile.php | 10 +- src/Messages/MessageInfo.php | 148 +++++ src/Messages/MessagesContext.php | 15 + src/Messages/MessagesDatabase.php | 399 ++++++++++++ src/Messages/MessagesRoutes.php | 601 ++++++++++++++++++ src/MisuzuContext.php | 47 +- src/MisuzuSasaeExtension.php | 10 +- src/Perm.php | 20 +- src/Users/Users.php | 1 + templates/_layout/meta.twig | 8 +- templates/forum/posting.twig | 69 +- templates/master.twig | 3 +- templates/messages/compose.twig | 63 ++ templates/messages/index.twig | 89 +++ templates/messages/master.twig | 12 + templates/messages/thread.twig | 179 ++++++ templates/profile/_layout/header.twig | 9 +- 46 files changed, 3143 insertions(+), 156 deletions(-) create mode 100644 assets/misuzu.css/messages/actions.css create mode 100644 assets/misuzu.css/messages/columns.css create mode 100644 assets/misuzu.css/messages/entry.css create mode 100644 assets/misuzu.css/messages/folder.css create mode 100644 assets/misuzu.css/messages/message.css create mode 100644 assets/misuzu.css/messages/messages.css create mode 100644 assets/misuzu.css/messages/recipient.css create mode 100644 assets/misuzu.css/messages/reply.css create mode 100644 assets/misuzu.css/messages/sidebar.css create mode 100644 assets/misuzu.css/messages/thread.css create mode 100644 assets/misuzu.js/csrfp.js create mode 100644 assets/misuzu.js/messages/actbtn.js create mode 100644 assets/misuzu.js/messages/list.js create mode 100644 assets/misuzu.js/messages/messages.js create mode 100644 assets/misuzu.js/messages/recipient.js create mode 100644 assets/misuzu.js/messages/reply.jsx create mode 100644 assets/misuzu.js/messages/thread.js create mode 100644 assets/misuzu.js/parsing.js create mode 100644 database/2024_01_30_233734_create_messages_table.php create mode 100644 src/Messages/MessageInfo.php create mode 100644 src/Messages/MessagesContext.php create mode 100644 src/Messages/MessagesDatabase.php create mode 100644 src/Messages/MessagesRoutes.php create mode 100644 templates/messages/compose.twig create mode 100644 templates/messages/index.twig create mode 100644 templates/messages/master.twig create mode 100644 templates/messages/thread.twig diff --git a/assets/misuzu.css/forum/post.css b/assets/misuzu.css/forum/post.css index 630f835..0a2577b 100644 --- a/assets/misuzu.css/forum/post.css +++ b/assets/misuzu.css/forum/post.css @@ -154,6 +154,9 @@ } .forum__post__action { + background-color: transparent; + border: 0; + display: block; padding: 5px 10px; margin: 1px; color: inherit; diff --git a/assets/misuzu.css/header.css b/assets/misuzu.css/header.css index a9b3bda..6f7517b 100644 --- a/assets/misuzu.css/header.css +++ b/assets/misuzu.css/header.css @@ -146,9 +146,14 @@ } .header__desktop__user__button__count { position: absolute; - bottom: 1px; - right: 1px; - font-size: 10px; + top: -5px; + right: -3px; + z-index: 1; + font-size: .5em; + line-height: 1.4em; + text-align: right; + padding: 2px 2px 0; + border-radius: 4px; background-color: var(--header-accent-colour); opacity: .9; border-radius: 4px; diff --git a/assets/misuzu.css/main.css b/assets/misuzu.css/main.css index b6f2605..d761e8a 100644 --- a/assets/misuzu.css/main.css +++ b/assets/misuzu.css/main.css @@ -3,7 +3,6 @@ padding: 0; box-sizing: border-box; position: relative; - outline-style: none; } html, @@ -165,6 +164,8 @@ html { @include manage/_manage.css; +@include messages/messages.css; + @include news/container.css; @include news/feeds.css; @include news/list.css; diff --git a/assets/misuzu.css/messagebox.css b/assets/misuzu.css/messagebox.css index 46dcd24..8bb4656 100644 --- a/assets/misuzu.css/messagebox.css +++ b/assets/misuzu.css/messagebox.css @@ -17,4 +17,5 @@ display: flex; justify-content: center; padding: 5px; + gap: 5px; } diff --git a/assets/misuzu.css/messages/actions.css b/assets/misuzu.css/messages/actions.css new file mode 100644 index 0000000..f0f212d --- /dev/null +++ b/assets/misuzu.css/messages/actions.css @@ -0,0 +1,37 @@ +.messages-actions-item { + display: flex; + align-items: center; + height: 30px; + margin: 1px; + font-size: 1.3em; + line-height: 1.4em; + color: #fff; + text-decoration: none; + transition: background-color .1s; + width: 100%; + border: 0; + background-color: inherit; + text-align: left; +} +.messages-actions-item:hover, +.messages-actions-item:focus { + background-color: #444f; +} +.messages-actions-item:active, +.messages-actions-item-current { + background-color: var(--accent-colour) !important; +} +.messages-actions-item[disabled] { + background-color: inherit !important; + opacity: .4; +} +.messages-actions-item-icon { + text-align: center; + width: 30px; + flex-grow: 0; + flex-shrink: 0; +} +.messages-actions-item-label { + flex-grow: 1; + flex-shrink: 1; +} diff --git a/assets/misuzu.css/messages/columns.css b/assets/misuzu.css/messages/columns.css new file mode 100644 index 0000000..0c77398 --- /dev/null +++ b/assets/misuzu.css/messages/columns.css @@ -0,0 +1,26 @@ +.messages-columns { + display: flex; + gap: 2px; +} + +.messages-columns-sidebar { + width: 200px; + flex-shrink: 0; + flex-grow: 0; +} + +.messages-columns-content { + flex-shrink: 1; + flex-grow: 1; + overflow: hidden; +} + +@media (max-width: 800px) { + .messages-columns { + flex-direction: column; + } + + .messages-columns-sidebar { + width: 100%; + } +} diff --git a/assets/misuzu.css/messages/entry.css b/assets/misuzu.css/messages/entry.css new file mode 100644 index 0000000..012da94 --- /dev/null +++ b/assets/misuzu.css/messages/entry.css @@ -0,0 +1,80 @@ +.messages-entry { + color: inherit; + text-decoration: none; + display: flex; + flex-direction: column; + padding: 2px 4px; + gap: 4px; + overflow: hidden; + cursor: pointer; +} +.messages-entry-header { + display: flex; + font-size: 1.1em; + line-height: 1.6em; + border-bottom: 2px solid #9999; + gap: 2px; +} +.messages-entry-check { + flex-grow: 0; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 20px; +} +.messages-entry-check input { + display: block; +} +.messages-entry-unread { + flex-grow: 0; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 20px; +} +.messages-entry-unread-orb { + width: 8px; + height: 8px; + background-color: var(--accent-colour); + border-radius: 100%; +} +.messages-entry-author { + font-weight: bold; + border-bottom: 2px solid var(--user-colour, currentColor); + margin: 0 0 -2px; + flex-grow: 0; + flex-shrink: 1; + overflow: hidden; + white-space: nowrap; +} +.messages-entry-spacing { + flex-grow: 1; + flex-shrink: 1; +} +.messages-entry-datetime { + flex-grow: 0; + flex-shrink: 0; + color: #aaa; + align-self: flex-end; +} +.messages-entry-subject { + line-height: 1.4em; + color: #fff; + overflow: hidden; +} +.messages-entry-preview { + line-height: 1.4em; + color: #888; + overflow: hidden; +} +.messages-entry-preview .messages-entry-overflow { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.messages-entry-overflow { + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/assets/misuzu.css/messages/folder.css b/assets/misuzu.css/messages/folder.css new file mode 100644 index 0000000..53d7474 --- /dev/null +++ b/assets/misuzu.css/messages/folder.css @@ -0,0 +1,33 @@ +.messages-folder { + margin: 1px; + display: flex; + flex-direction: column; + gap: 1px; + padding: 1px; +} +.messages-folder-item { + background-color: #161616; + transition: background-color .1s; +} +.messages-folder-item:nth-child(2n) { + background-color: #1f1f1f; +} +.messages-folder-item:hover, +.messages-folder-item:focus { + background-color: #262626; +} +.messages-folder-item:active, +.messages-folder-item-current { + background-color: var(--accent-colour) !important; +} +.messages-folder-notice { + text-align: center; + margin: 10px; +} +.messages-folder-notice-text { + font-size: 1.4em; + line-height: 1.5em; +} +.messages-folder .pagination { + margin-top: 2px; +} diff --git a/assets/misuzu.css/messages/message.css b/assets/misuzu.css/messages/message.css new file mode 100644 index 0000000..4560045 --- /dev/null +++ b/assets/misuzu.css/messages/message.css @@ -0,0 +1,135 @@ +.messages-message { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px; +} +.messages-message-snippet { + cursor: pointer; + font-size: .9em; + line-height: 1.5em; + color: #888; + gap: 5px; + opacity: .8; + transition: opacity .1s; +} +.messages-message-snippet:hover, +.messages-message-snippet:focus, +.messages-message-snippet:focus-within { + opacity: 1; +} +.messages-message-draft { + border-top: 2px solid var(--accent-colour) !important; + border-left: 2px solid var(--accent-colour) !important; + border-right: 2px solid var(--accent-colour); + border-bottom: 2px solid var(--accent-colour); +} +.messages-message-deleted { + border-top: 2px solid red; + border-left: 2px solid red; + border-right: 2px solid red !important; + border-bottom: 2px solid red !important; +} + +.messages-message-overflow { + display: block; + overflow: hidden; + text-overflow: ellipsis; +} + +.messages-message-header { + display: flex; + gap: 10px; + border-bottom: 1px #444 solid; + padding-bottom: 10px; + align-items: center; +} + +.messages-message-sender-avatar { + flex-shrink: 0; + flex-grow: 0; + width: 40px; + height: 40px; +} +.messages-message-sender-avatar img { + object-fit: cover; +} + +.messages-message-details { + display: flex; + flex-direction: column; + flex-shrink: 1; + flex-grow: 1; + overflow: hidden; + gap: 2px; +} +.messages-message-details-spacing { + flex-grow: 1; + flex-shrink: 1; +} + +.messages-message-header-columns { + display: flex; + gap: 2px; +} +.messages-message-sender-name { + flex-grow: 0; + flex-shrink: 1; + overflow: hidden; + white-space: nowrap; +} +.messages-message-sender-name a { + color: inherit; + text-decoration: none; + font-weight: 700; + border-bottom: 2px solid var(--user-colour, currentColor); +} +.messages-message-datetime { + flex-shrink: 0; + flex-grow: 0; + align-self: flex-end; + padding-bottom: 2px; +} + +.messages-message-addressee { + display: flex; + gap: 4px; +} +.messages-message-addressee-to { + flex-shrink: 0; + flex-grow: 0; +} +.messages-message-addressee-user { + flex-shrink: 1; + flex-grow: 0; + overflow: hidden; + white-space: nowrap; +} +.messages-message-addressee-user a { + color: inherit; + text-decoration: none; + font-weight: 700; + border-bottom: 2px solid var(--user-colour, currentColor); +} + +.messages-message-subject { + line-height: 2em; +} + +.messages-message-body { + line-height: 1.4em; +} +.messages-message-body p:first-child { + margin-top: 0 !important; +} +.messages-message-body p:last-child { + margin-bottom: 0 !important; +} + +.messages-message-snippet-body { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.4em; +} diff --git a/assets/misuzu.css/messages/messages.css b/assets/misuzu.css/messages/messages.css new file mode 100644 index 0000000..c1ecde9 --- /dev/null +++ b/assets/misuzu.css/messages/messages.css @@ -0,0 +1,9 @@ +@include messages/actions.css; +@include messages/columns.css; +@include messages/entry.css; +@include messages/folder.css; +@include messages/message.css; +@include messages/recipient.css; +@include messages/reply.css; +@include messages/sidebar.css; +@include messages/thread.css; diff --git a/assets/misuzu.css/messages/recipient.css b/assets/misuzu.css/messages/recipient.css new file mode 100644 index 0000000..7086f88 --- /dev/null +++ b/assets/misuzu.css/messages/recipient.css @@ -0,0 +1,17 @@ +.messages-recipient { + display: flex; + flex-direction: column; +} + +.messages-recipient-avatar { + display: flex; + justify-content: center; + padding: 10px; +} + +.messages-recipient-name { + padding: 5px; +} +.messages-recipient-name-input { + width: 100%; +} diff --git a/assets/misuzu.css/messages/reply.css b/assets/misuzu.css/messages/reply.css new file mode 100644 index 0000000..d2caab6 --- /dev/null +++ b/assets/misuzu.css/messages/reply.css @@ -0,0 +1,52 @@ +.messages-reply-form { + display: flex; + flex-direction: column; + width: 100%; + gap: 5px; + padding: 5px; +} + +.messages-reply-subject-input { + width: 100%; +} + +.messages-reply-body-input { + min-width: 100%; + max-width: 100%; + min-height: 100px; +} +.messages-reply-compose .messages-reply-body-input { + min-height: 300px; +} + +.messages-reply-actions { + display: flex; + padding: 1px; + gap: 1px; +} +.messages-reply-action { + background-color: transparent; + border: 0; + display: block; + padding: 5px 10px; + color: inherit; + text-decoration: none; + transition: background-color .2s; + border-radius: 3px; + cursor: pointer; +} +.messages-reply-action:hover, +.messages-reply-action:focus { + background-color: rgba(0, 0, 0, .2); +} + +.messages-reply-options { + display: flex; + align-items: center; + justify-content: space-between; +} +.messages-reply-settings { + display: flex; + align-items: center; + gap: 5px; +} diff --git a/assets/misuzu.css/messages/sidebar.css b/assets/misuzu.css/messages/sidebar.css new file mode 100644 index 0000000..5d21821 --- /dev/null +++ b/assets/misuzu.css/messages/sidebar.css @@ -0,0 +1,11 @@ +.messages-sidebar { + position: sticky; + top: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.messages-sidebar-button { + text-align: center; + padding: 10px; +} diff --git a/assets/misuzu.css/messages/thread.css b/assets/misuzu.css/messages/thread.css new file mode 100644 index 0000000..da20abe --- /dev/null +++ b/assets/misuzu.css/messages/thread.css @@ -0,0 +1,5 @@ +.messages-thread { + display: flex; + flex-direction: column; + gap: 1px; +} diff --git a/assets/misuzu.js/csrfp.js b/assets/misuzu.js/csrfp.js new file mode 100644 index 0000000..86b0991 --- /dev/null +++ b/assets/misuzu.js/csrfp.js @@ -0,0 +1,40 @@ +#include utility.js + +const MszCSRFP = (() => { + let elem; + const getElement = () => { + if(elem === undefined) + elem = $q('meta[name="csrfp-token"]'); + return elem; + }; + + const getToken = () => { + const elem = getElement(); + return typeof elem.content === 'string' ? elem.content : ''; + }; + + const setToken = token => { + if(typeof token !== 'string') + throw 'token must be a string'; + + const elem = getElement(); + if(typeof elem.content === 'string') + elem.content = token; + }; + + return { + getToken: getToken, + setToken: setToken, + setFromHeaders: result => { + if(typeof result.headers !== 'function') + throw 'result.headers is not a function'; + + const headers = result.headers(); + if(!(headers instanceof Map)) + throw 'result of result.headers does not return a map'; + + if(headers.has('x-csrfp-token')) + setToken(headers.get('x-csrfp-token')); + }, + }; +})(); diff --git a/assets/misuzu.js/embed/audio.js b/assets/misuzu.js/embed/audio.js index aaee05a..ad61f37 100644 --- a/assets/misuzu.js/embed/audio.js +++ b/assets/misuzu.js/embed/audio.js @@ -56,7 +56,7 @@ const MszAudioEmbedPlayer = function(metadata, options) { if(haveNativeControls) playerAttrs.controls = 'controls'; - const watchers = new MszWatcherCollection; + const watchers = new MszWatchers; watchers.define(MszAudioEmbedPlayerEvents()); const player = $e({ diff --git a/assets/misuzu.js/embed/video.js b/assets/misuzu.js/embed/video.js index 122f08c..569816e 100644 --- a/assets/misuzu.js/embed/video.js +++ b/assets/misuzu.js/embed/video.js @@ -229,7 +229,7 @@ const MszVideoEmbedPlayer = function(metadata, options) { videoAttrs.style.width = initialSize[0].toString() + 'px'; videoAttrs.style.height = initialSize[1].toString() + 'px'; - const watchers = new MszWatcherCollection; + const watchers = new MszWatchers; watchers.define(MszVideoEmbedPlayerEvents()); const player = $e({ @@ -375,7 +375,7 @@ const MszVideoEmbedYouTube = function(metadata, options) { currentTime = undefined, isPlaying = undefined; - const watchers = new MszWatcherCollection; + const watchers = new MszWatchers; watchers.define(MszVideoEmbedPlayerEvents()); const player = $e({ @@ -576,7 +576,7 @@ const MszVideoEmbedNicoNico = function(metadata, options) { currentTime = undefined, isPlaying = false; - const watchers = new MszWatcherCollection; + const watchers = new MszWatchers; watchers.define(MszVideoEmbedPlayerEvents()); const player = $e({ diff --git a/assets/misuzu.js/forum/editor.jsx b/assets/misuzu.js/forum/editor.jsx index aebd89b..cc0608c 100644 --- a/assets/misuzu.js/forum/editor.jsx +++ b/assets/misuzu.js/forum/editor.jsx @@ -1,4 +1,5 @@ #include msgbox.jsx +#include parsing.js #include utility.js #include ext/eeprom.js @@ -13,10 +14,7 @@ const MszForumEditor = function(form) { parserElem = form.querySelector('.js-forum-posting-parser'), previewElem = form.querySelector('.js-forum-posting-preview'), modeElem = form.querySelector('.js-forum-posting-mode'), - markupBtns = form.querySelectorAll('.js-forum-posting-markup'); - - const bbBtns = $q('.forum__post__actions--bbcode'), - mdBtns = $q('.forum__post__actions--markdown'); + markupActs = form.querySelector('.js-forum-posting-actions'); let lastPostText = '', lastPostParser; @@ -204,13 +202,15 @@ const MszForumEditor = function(form) { } }); - for(const button of markupBtns) - button.addEventListener('click', () => $insertTags(textElem, button.dataset.tagOpen, button.dataset.tagClose)); - const switchButtons = parser => { - parser = parseInt(parser); - bbBtns.hidden = parser !== 1; - mdBtns.hidden = parser !== 2; + $rc(markupActs); + + const tags = MszParsing.getTagsFor(parser); + for(const tag of tags) + markupActs.appendChild(); }; const renderPreview = async (parser, text) => { diff --git a/assets/misuzu.js/main.js b/assets/misuzu.js/main.js index 8fd9e49..ba1774b 100644 --- a/assets/misuzu.js/main.js +++ b/assets/misuzu.js/main.js @@ -4,6 +4,7 @@ #include events/events.js #include ext/sakuya.js #include forum/editor.jsx +#include messages/messages.js (async () => { const initLoginPage = async () => { @@ -80,6 +81,8 @@ await initLoginPage(); + MszMessages(); + MszEmbed.handle($qa('.js-msz-embed-media')); } catch(ex) { console.error(ex); diff --git a/assets/misuzu.js/messages/actbtn.js b/assets/misuzu.js/messages/actbtn.js new file mode 100644 index 0000000..4f9ce39 --- /dev/null +++ b/assets/misuzu.js/messages/actbtn.js @@ -0,0 +1,89 @@ +#include watcher.js + +const MszMessagesActionButton = function(button, stateless) { + if(!(button instanceof Element)) + throw 'button must be an element'; + + const stateful = !stateless; + const pub = {}; + + const icon = button.querySelector('.js-messages-button-icon i'); + const label = button.querySelector('.js-messages-button-label'); + + const update = () => { + if(stateful) { + icon.className = button.dataset[`${button.dataset.state}Ico`]; + label.textContent = button.dataset[`${button.dataset.state}Str`]; + } + }; + pub.update = update; + + const stateWatcher = new MszWatcher; + const getState = () => button.dataset.state !== 'inactive'; + const setState = state => { + button.dataset.state = state ? 'active' : 'inactive'; + update(); + stateWatcher.call(getState()); + }; + + if(stateful) { + pub.getState = getState; + pub.setState = setState; + pub.watchState = handler => { stateWatcher.watch(handler, getState()); }; + pub.unwatchState = handler => { stateWatcher.unwatch(handler); }; + } + + let clickAction; + const click = async () => { + if(clickAction !== undefined) { + if(stateful) { + const result = await clickAction(getState()); + if(typeof result === 'boolean') + setState(result); + } else + await clickAction(); + } + }; + pub.click = click; + + button.addEventListener('click', () => click()); + + update(); + + pub.setAction = action => { + if(typeof action !== 'function') + throw 'action must be a function'; + clickAction = action; + }; + + let preventEnable = false; + + pub.getEnabled = () => !button.disabled; + pub.setEnabled = state => { + if(!preventEnable) + button.disabled = !state; + }; + pub.disableWith = async callback => { + if(typeof callback !== 'function') + throw 'callback must be a function'; + if(preventEnable) + throw 'preventEnable is true'; + + preventEnable = true; + const wasDisabled = button.disabled; + button.disabled = true; + + try { + return await callback(); + } finally { + button.disabled = wasDisabled; + preventEnable = false; + } + }; + + pub.setHidden = state => { + button.hidden = state; + }; + + return pub; +}; diff --git a/assets/misuzu.js/messages/list.js b/assets/misuzu.js/messages/list.js new file mode 100644 index 0000000..45f12c1 --- /dev/null +++ b/assets/misuzu.js/messages/list.js @@ -0,0 +1,167 @@ +#include utility.js +#include watcher.js + +const MsgMessagesList = function(list) { + if(!(list instanceof Element)) + throw 'list must be an element'; + + const watchers = new MszWatchers; + watchers.define(['select']); + + let selectedCount = 0; + + const items = Array.from(list.querySelectorAll('.js-messages-entry')).map(elem => { + const item = new MsgMessagesEntry(elem); + item.onSelectedChange((state, initial) => { + if(state) + ++selectedCount; + else if(!initial) + --selectedCount; + + if(!initial) + watchers.call('select', selectedCount, items.length); + }); + return item; + }); + + const recountSelected = () => { + selectedCount = 0; + + for(const item of items) + if(item.getSelected()) + ++selectedCount; + }; + + const onSelectedChange = handler => { + watchers.watch('select', handler, selectedCount, items.length); + }; + + onSelectedChange(selectedCount => { + const state = selectedCount > 0; + for(const item of items) + item.setClickIsSelect(state); + }); + + return { + getItems: () => items, + getItemsCount: () => items.length, + getSelectedItems: () => { + const selected = []; + + for(const item of items) + if(item.getSelected()) + selected.push(item); + + return selected; + }, + removeItem: item => { + $ari(items, item); + $r(item.getElement()); + recountSelected(); + watchers.call('select', selectedCount, items.length); + }, + getAllSelected: () => { + if(items.length < 1) + return false; + + for(const item of items) + if(!item.getSelected()) + return false; + + return true; + }, + setAllSelected: state => { + for(const item of items) + item.setSelected(state); + selectedCount = state ? items.length : 0; + watchers.call('select', selectedCount, items.length); + }, + onSelectedChange: onSelectedChange, + }; +}; + +const MsgMessagesEntry = function(entry) { + if(!(entry instanceof Element)) + throw 'entry must be an element'; + + const msgId = entry.dataset.msgId; + + const unreadElem = entry.querySelector('.js-messages-entry-unread'); + const isRead = () => entry.dataset.msgRead === 'read'; + const setRead = state => { + if(state) { + entry.dataset.msgRead = 'read'; + unreadElem.hidden = true; + } else { + entry.dataset.msgRead = 'unread'; + unreadElem.hidden = false; + } + }; + + const isSent = () => entry.dataset.msgSent === 'sent'; + const setSent = state => { + entry.dataset.msgRead = state ? 'sent' : 'draft'; + }; + + const checkbox = entry.querySelector('.js-entry-checkbox'); + const getSelected = () => checkbox.checked; + const setSelected = state => checkbox.checked = state; + const toggleSelected = () => checkbox.checked = !checkbox.checked; + + let clickIsSelect = false; + + const watchers = new MszWatchers; + watchers.define(['select']); + + checkbox.addEventListener('click', ev => ev.stopPropagation()); + checkbox.addEventListener('keydown', ev => ev.stopPropagation()); + + checkbox.addEventListener('change', () => { + watchers.call('select', getSelected()); + }); + + const navigateToMessage = () => { + const url = entry.dataset.msgUrl; + if(url !== undefined && url.startsWith('/') && !url.startsWith('//')) + location.assign(url); + }; + + entry.addEventListener('keydown', ev => { + if(ev.key === 'Enter' || ev.key === 'NumpadEnter') { + ev.preventDefault(); + entry.click(); + } + }); + + entry.addEventListener('click', ev => { + ev.preventDefault(); + + if(clickIsSelect) + checkbox.click(); + else + navigateToMessage(); + }); + + entry.addEventListener('dblclick', ev => { + ev.preventDefault(); + + if(clickIsSelect) + navigateToMessage(); + }); + + return { + getId: () => msgId, + getElement: () => entry, + isRead: isRead, + setRead: setRead, + isSent: isSent, + setSent: setSent, + getSelected: getSelected, + setSelected: setSelected, + toggleSelected: toggleSelected, + setClickIsSelect: state => clickIsSelect = state, + onSelectedChange: handler => { + watchers.watch('select', handler, getSelected()); + }, + }; +}; diff --git a/assets/misuzu.js/messages/messages.js b/assets/misuzu.js/messages/messages.js new file mode 100644 index 0000000..5edb46b --- /dev/null +++ b/assets/misuzu.js/messages/messages.js @@ -0,0 +1,385 @@ +#include csrfp.js +#include msgbox.js +#include utility.js +#include messages/actbtn.js +#include messages/list.js +#include messages/recipient.js +#include messages/reply.jsx +#include messages/thread.js + +const MszMessages = () => { + const extractMsgIds = msg => { + if(typeof msg.getId === 'function') + return msg.getId(); + if(typeof msg.toString === 'function') + return msg.toString(); + throw 'unsupported message type'; + }; + + const displayErrorMessage = async error => { + let text; + if(typeof error === 'string') + text = error; + else if(typeof error.text === 'string') + text = error.text; + else if(typeof error.toString === 'function') + text = error.toString(); + else + text = 'Something indescribable happened.'; + + await MszShowMessageBox(text, 'Error'); + return false; + }; + + const msgsCreate = async (title, text, parser, draft, recipient, replyTo) => { + const formData = new FormData; + formData.append('_csrfp', MszCSRFP.getToken()); + formData.append('title', title); + formData.append('body', text); + formData.append('parser', parser); + formData.append('draft', draft); + formData.append('recipient', recipient); + formData.append('reply', replyTo); + + const result = await $x.post('/messages/create', { type: 'json' }, formData); + + MszCSRFP.setFromHeaders(result); + + const body = result.body(); + if(body.error !== undefined) + throw body.error; + + return body; + }; + + const msgsUpdate = async (messageId, title, text, parser, draft) => { + const formData = new FormData; + formData.append('_csrfp', MszCSRFP.getToken()); + formData.append('title', title); + formData.append('body', text); + formData.append('parser', parser); + formData.append('draft', draft); + + const result = await $x.post(`/messages/${encodeURIComponent(messageId)}`, { type: 'json' }, formData); + + MszCSRFP.setFromHeaders(result); + + const body = result.body(); + if(body.error !== undefined) + throw body.error; + + return body; + }; + + const msgsMark = async (msgs, state) => { + const result = await $x.post('/messages/mark', { type: 'json' }, { + _csrfp: MszCSRFP.getToken(), + type: state, + messages: msgs.map(extractMsgIds).join(','), + }); + + MszCSRFP.setFromHeaders(result); + + const body = result.body(); + if(body.error !== undefined) + throw body.error; + + return true; + }; + + const msgsDelete = async msgs => { + const result = await $x.post('/messages/delete', { type: 'json' }, { + _csrfp: MszCSRFP.getToken(), + messages: msgs.map(extractMsgIds).join(','), + }); + + MszCSRFP.setFromHeaders(result); + + const body = result.body(); + if(body.error !== undefined) + throw body.error; + + return true; + }; + + const msgsRestore = async msgs => { + const result = await $x.post('/messages/restore', { type: 'json' }, { + _csrfp: MszCSRFP.getToken(), + messages: msgs.map(extractMsgIds).join(','), + }); + + MszCSRFP.setFromHeaders(result); + + const body = result.body(); + if(body.error !== undefined) + throw body.error; + + return true; + }; + + const msgsNuke = async msgs => { + const result = await $x.post('/messages/nuke', { type: 'json' }, { + _csrfp: MszCSRFP.getToken(), + messages: msgs.map(extractMsgIds).join(','), + }); + + MszCSRFP.setFromHeaders(result); + + const body = result.body(); + if(body.error !== undefined) + throw body.error; + + return true; + }; + + const msgsUserBtns = Array.from($qa('.js-header-pms-button')); + if(msgsUserBtns.length > 0) + $x.get('/messages/stats', { type: 'json' }).then(result => { + const body = result.body(); + if(typeof body === 'object' && typeof body.unread === 'number') + if(body.unread > 0) + for(const msgsUserBtn of msgsUserBtns) + msgsUserBtn.append($e({ child: body.unread.toLocaleString(), attrs: { className: 'header__desktop__user__button__count' } })); + }); + + const msgsListElem = $q('.js-messages-list'); + const msgsList = msgsListElem instanceof Element ? new MsgMessagesList(msgsListElem) : undefined; + + const msgsListEmptyNotice = $q('.js-messages-folder-empty'); + + const msgsThreadElem = $q('.js-messages-thread'); + const msgsThread = msgsThreadElem instanceof Element ? new MszMessagesThread(msgsThreadElem) : undefined; + + const msgsRecipientElem = $q('.js-messages-recipient'); + const msgsRecipient = msgsRecipientElem instanceof Element ? new MszMessagesRecipient(msgsRecipientElem) : undefined; + + const msgsReplyElem = $q('.js-messages-reply'); + const msgsReply = msgsReplyElem instanceof Element ? new MszMessagesReply(msgsReplyElem) : undefined; + + if(msgsReply !== undefined) { + if(msgsRecipient !== undefined) + msgsRecipient.onUpdate(async info => { + msgsReply.setRecipient(typeof info.id === 'string' ? info.id : ''); + }); + + msgsReply.onSubmit(async form => { + try { + let result; + if(typeof form.message === 'string') { + result = await msgsUpdate( + form.message, + form.title, + form.body, + form.parser, + form.draft + ); + } else { + result = await msgsCreate( + form.title, + form.body, + form.parser, + form.draft, + form.recipient, + form.reply || '' + ); + } + + if(typeof result.url === 'string') + location.assign(result.url); + } catch(ex) { + return await displayErrorMessage(ex); + } + }); + } + + let actSelectAll, actMarkRead, actMoveTrash, actNuke; + + const actSelectAllBtn = $q('.js-messages-actions-select-all'); + if(actSelectAllBtn instanceof Element) { + actSelectAll = new MszMessagesActionButton(actSelectAllBtn); + + if(msgsList !== undefined) { + actSelectAll.setAction(async state => { + msgsList.setAllSelected(!state); + return !state; + }); + msgsList.onSelectedChange((selectedNo, itemNo) => { + actSelectAll.setState(selectedNo >= itemNo); + }); + actSelectAll.setState(msgsList.getAllSelected()); + } + } + + const actMarkReadBtn = $q('.js-messages-actions-mark-read'); + if(actMarkReadBtn instanceof Element) { + actMarkRead = new MszMessagesActionButton(actMarkReadBtn); + + if(msgsList !== undefined) { + msgsList.onSelectedChange(selectedNo => { + const enabled = selectedNo > 0; + actMarkRead.setEnabled(enabled); + + if(enabled) { + const items = msgsList.getSelectedItems(); + let readNo = 0, unreadNo = 0; + + for(const item of items) { + if(item.isRead()) + ++readNo; + else + ++unreadNo; + } + + actMarkRead.setState(readNo > unreadNo); + } + }); + actMarkRead.setAction(async state => { + const items = msgsList.getSelectedItems(); + + const result = await actMarkRead.disableWith(async () => { + try { + return await msgsMark(items, state ? 'unread' : 'read'); + } catch(ex) { + return await displayErrorMessage(ex); + } + }); + + if(result) { + state = !state; + + for(const item of items) + item.setRead(state); + + return state; + } + }); + } else if(msgsThread !== undefined) { + actMarkRead.setAction(async state => { + const items = [msgsThread.getMessage()]; + + const result = await actMarkRead.disableWith(async () => { + try { + return await msgsMark(items, state ? 'unread' : 'read'); + } catch(ex) { + return await displayErrorMessage(ex); + } + }); + + return result ? !state : state; + }); + } + } + + const actMoveTrashBtn = $q('.js-messages-actions-move-trash'); + if(actMoveTrashBtn instanceof Element) { + actMoveTrash = new MszMessagesActionButton(actMoveTrashBtn); + + if(msgsList !== undefined) { + msgsList.onSelectedChange(selectedNo => actMoveTrash.setEnabled(selectedNo > 0)); + actMoveTrash.setAction(async state => { + const items = msgsList.getSelectedItems(); + + if(!state && !await MszShowConfirmBox(`Are you sure you wish to delete ${items.length} item${items.length === 1 ? '' : 's'}?`, 'Confirmation')) + return; + + const result = await actMoveTrash.disableWith(async () => { + try { + if(state) + return await msgsRestore(items); + return await msgsDelete(items); + } catch(ex) { + return await displayErrorMessage(ex); + } + }); + + if(result) + for(const message of items) + msgsList.removeItem(message); + + if(msgsListEmptyNotice instanceof Element) + msgsListEmptyNotice.hidden = msgsList.getItemsCount() > 0; + }); + } else if(msgsThread !== undefined) { + actMoveTrash.setAction(async state => { + if(!state && !await MszShowConfirmBox('Are you sure you wish to delete this message?', 'Confirmation')) + return; + + const items = [msgsThread.getMessage()]; + + const result = await actMoveTrash.disableWith(async () => { + try { + if(state) + return await msgsRestore(items); + return await msgsDelete(items); + } catch(ex) { + return await displayErrorMessage(ex); + } + }); + + if(result) { + state = !state; + + if(msgsReply !== undefined) + msgsReply.setHidden(state); + + const msg = msgsThread.getMessage(); + if(msg !== undefined) + msg.setDeleted(state); + + return state; + } + }); + } + } + + const actNukeBtn = $q('.js-messages-actions-nuke'); + if(actNukeBtn instanceof Element) { + actNuke = new MszMessagesActionButton(actNukeBtn, true); + + if(msgsList !== undefined) { + msgsList.onSelectedChange(selectedNo => actNuke.setEnabled(selectedNo > 0)); + actNuke.setAction(async () => { + const items = msgsList.getSelectedItems(); + + if(!await MszShowConfirmBox(`Are you sure you wish to PERMANENTLY delete ${items.length} item${items.length === 1 ? '' : 's'}?`, 'Confirmation')) + return; + + const result = await actNuke.disableWith(async () => { + try { + return await msgsNuke(items); + } catch(ex) { + return await displayErrorMessage(ex); + } + }); + + if(result) + for(const message of items) + msgsList.removeItem(message); + + if(msgsListEmptyNotice instanceof Element) + msgsListEmptyNotice.hidden = msgsList.getItemsCount() > 0; + }); + } else if(msgsThread !== undefined) { + actMoveTrash.watchState(state => { + actNuke.setHidden(!state); + }); + actNuke.setAction(async () => { + if(!await MszShowConfirmBox('Are you sure you wish to PERMANENTLY delete this message?', 'Confirmation')) + return; + + const items = [msgsThread.getMessage()]; + + const result = await actNuke.disableWith(async () => { + try { + return await msgsNuke(items); + } catch(ex) { + return await displayErrorMessage(ex); + } + }); + + if(result) + location.assign('/messages'); + }); + } + } +}; diff --git a/assets/misuzu.js/messages/recipient.js b/assets/misuzu.js/messages/recipient.js new file mode 100644 index 0000000..22374ad --- /dev/null +++ b/assets/misuzu.js/messages/recipient.js @@ -0,0 +1,56 @@ +#include csrfp.js +#include utility.js + +const MszMessagesRecipient = function(element) { + if(!(element instanceof Element)) + throw 'element must be an instance of Element'; + + const avatarElem = element.querySelector('.js-messages-recipient-avatar img'); + const nameInput = element.querySelector('.js-messages-recipient-name'); + + let updateHandler = undefined; + const update = async () => { + const result = await $x.post(element.dataset.msgLookup, { type: 'json' }, { + _csrfp: MszCSRFP.getToken(), + name: nameInput.value, + }); + + MszCSRFP.setFromHeaders(result); + + const body = result.body(); + + if(updateHandler !== undefined) + await updateHandler(body); + + if(typeof body.avatar === 'string') + avatarElem.src = body.avatar; + + if(typeof body.name === 'string') + nameInput.value = body.name; + }; + + let nameTimeout = null; + nameInput.addEventListener('input', () => { + if(nameTimeout !== undefined) + return; + + nameTimeout = setTimeout(() => { + update().finally(() => { + clearTimeout(nameTimeout); + nameTimeout = undefined; + }); + }, 750); + }); + + update().finally(() => nameTimeout = undefined); + + return { + getElement: () => element, + onUpdate: handler => { + if(typeof handler !== 'function') + throw 'handler must be a function'; + + updateHandler = handler; + }, + }; +}; diff --git a/assets/misuzu.js/messages/reply.jsx b/assets/misuzu.js/messages/reply.jsx new file mode 100644 index 0000000..3786899 --- /dev/null +++ b/assets/misuzu.js/messages/reply.jsx @@ -0,0 +1,154 @@ +#include parsing.js +#include ext/eeprom.js + +const MszMessagesReply = function(element) { + if(!(element instanceof Element)) + throw 'element must be an Element'; + + const form = element.querySelector('.js-messages-reply-form'); + const bodyElem = form.querySelector('.js-messages-reply-body'); + const actsElem = form.querySelector('.js-messages-reply-actions'); + const parserSelect = form.querySelector('.js-messages-reply-parser'); + const saveBtn = form.querySelector('.js-messages-reply-save'); + const sendBtn = form.querySelector('.js-messages-reply-send'); + + let submitHandler; + form.addEventListener('submit', ev => { + ev.preventDefault(); + + if(typeof submitHandler === 'function') { + const fields = Array.from(form.elements); + const result = {}; + + for(const field of fields) { + if((field instanceof HTMLButtonElement || (field instanceof HTMLInputElement && field.type === 'submit')) && ev.submitter !== field) + continue; + + if(typeof field.name === 'string' && field.name.length > 0) + result[field.name] = field.value; + } + + submitHandler(result); + } + }); + + bodyElem.addEventListener('keydown', ev => { + if((ev.code === 'Enter' || ev.code === 'NumpadEnter') && ev.ctrlKey && !ev.altKey && !ev.metaKey) { + ev.preventDefault(); + + if(ev.shiftKey) + saveBtn.click(); + else + sendBtn.click(); + } + }); + + const switchButtons = parser => { + $rc(actsElem); + + const tags = MszParsing.getTagsFor(parser); + actsElem.hidden = tags.length < 1; + for(const tag of tags) + actsElem.appendChild(); + }; + + switchButtons(parserSelect.value); + + parserSelect.addEventListener('change', () => { + switchButtons(parserSelect.value); + }); + + // this implementation is godawful but it'll do for now lol + // need to make it easier to share the forum's implementation + MszEEPROM.init() + .catch(() => console.error('Failed to initialise EEPROM')) + .then(() => { + const eepromClient = new EEPROM(peepApp, `${peepPath}/uploads`, ''); + const eepromHandleFileUpload = file => { + const uploadTask = eepromClient.createUpload(file); + + uploadTask.onFailure = errorInfo => { + if(!errorInfo.userAborted) + MszShowMessageBox('Was unable to upload file.', 'Upload Error'); + }; + + uploadTask.onComplete = fileInfo => { + const parserMode = parseInt(parserSelect.value); + let insertText = location.protocol + fileInfo.url; + + if(parserMode == 1) { // bbcode + if(fileInfo.isImage()) + insertText = `[img]${fileInfo.url}[/img]`; + else if(fileInfo.isAudio()) + insertText = `[audio]${fileInfo.url}[/audio]`; + else if(fileInfo.isVideo()) + insertText = `[video]${fileInfo.url}[/video]`; + } else if(parserMode == 2) { // markdown + if(fileInfo.isMedia()) + insertText = `![](${fileInfo.url})`; + } + + $insertTags(bodyElem, insertText, ''); + bodyElem.value = bodyElem.value.trim(); + }; + + uploadTask.start(); + }; + + bodyElem.addEventListener('paste', ev => { + if(ev.clipboardData && ev.clipboardData.files.length > 0) { + ev.preventDefault(); + + const files = ev.clipboardData.files; + for(const file of files) + eepromHandleFileUpload(file); + } + }); + + document.body.addEventListener('dragenter', ev => { + ev.preventDefault(); + ev.stopPropagation(); + }); + document.body.addEventListener('dragover', ev => { + ev.preventDefault(); + ev.stopPropagation(); + }); + document.body.addEventListener('dragleave', ev => { + ev.preventDefault(); + ev.stopPropagation(); + }); + document.body.addEventListener('drop', ev => { + ev.preventDefault(); + ev.stopPropagation(); + + if(ev.dataTransfer && ev.dataTransfer.files.length > 0) { + const files = ev.dataTransfer.files; + for(const file of files) + eepromHandleFileUpload(file); + } + }); + }); + + return { + getElement: () => element, + setRecipient: userId => { + for(const field of form.elements) + if(field.name === 'recipient') { + field.value = userId; + break; + } + }, + getHidden: () => element.hidden, + setHidden: state => { + element.hidden = state; + }, + onSubmit: handler => { + if(typeof handler !== 'function') + throw 'handler must be a function'; + + submitHandler = handler; + }, + }; +}; diff --git a/assets/misuzu.js/messages/thread.js b/assets/misuzu.js/messages/thread.js new file mode 100644 index 0000000..612ac2e --- /dev/null +++ b/assets/misuzu.js/messages/thread.js @@ -0,0 +1,78 @@ +const MszMessagesThread = function(thread) { + if(!(thread instanceof Element)) + throw 'thread must be an element'; + + const messages = Array.from(thread.querySelectorAll('.js-messages-message')).map(elem => new MszMessagesThreadMessage(elem)); + const message = messages.find(msg => msg.isFull()); + + return { + getMessage: () => message, + getMessages: () => messages, + }; +}; + +const MszMessagesThreadMessage = function(message) { + if(!(message instanceof Element)) + throw 'message must be an element'; + + const msgId = message.dataset.msgId; + const type = message.dataset.msgType; + const url = message.dataset.msgUrl; + + if(type === 'snip') { + message.addEventListener('click', ev => { + if(typeof url !== 'string') + return; + + let target = ev.target; + while(target !== message) { + if(target instanceof HTMLAnchorElement) + return; + + target = target.parentNode; + } + + ev.preventDefault(); + location.assign(url); + }); + } else if(type === 'full') { + message.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + } + + const isRead = () => message.dataset.msgRead === 'read'; + const setRead = state => { + message.dataset.msgRead = state ? 'read' : 'unread'; + }; + + const isSent = () => message.dataset.msgSent === 'sent'; + const setSent = state => { + message.dataset.msgRead = state ? 'sent' : 'draft'; + }; + + const isDeleted = () => message.dataset.msgDeleted === 'yes'; + const setDeleted = state => { + if(state) { + message.dataset.msgDeleted = 'yes'; + message.classList.add('messages-message-deleted'); + } else { + message.dataset.msgDeleted = 'no'; + message.classList.remove('messages-message-deleted'); + } + }; + + return { + getId: () => msgId, + getType: () => type, + isFull: () => type === 'full', + isSnippet: () => type === 'snip', + isRead: isRead, + setRead: setRead, + isSent: isSent, + setSent: setSent, + isDeleted: isDeleted, + setDeleted: setDeleted, + }; +}; diff --git a/assets/misuzu.js/msgbox.jsx b/assets/misuzu.js/msgbox.jsx index ba7e9e9..023fb93 100644 --- a/assets/misuzu.js/msgbox.jsx +++ b/assets/misuzu.js/msgbox.jsx @@ -1,49 +1,70 @@ #include utility.js -const MszShowMessageBox = async (text, title, buttons, target) => { +const MszShowConfirmBox = async (text, title, target) => { + let result = false; + + await MszShowMessageBox(text, title, [ + { text: 'Yes', callback: async () => result = true }, + { text: 'No' }, + ], target); + + return result; +}; + +const MszShowMessageBox = (text, title, buttons, target) => { if(typeof text !== 'string') throw 'text must be a string'; if(!(target instanceof Element)) target = document.body; - if(target.querySelector('.messagebox')) - return false; - if(typeof title !== 'string') title = 'Information'; if(!Array.isArray(buttons)) buttons = []; - let buttonsElem; - const html =
-
-
-
-
{title}
-
-
{text}
- {buttonsElem =
} -
-
; - - let firstButton; - if(buttons.length < 1) { - firstButton = ; - buttonsElem.appendChild(firstButton); - } else { - for(const button of buttons) { - const buttonElem = ; - buttonsElem.appendChild(buttonElem); - - if(firstButton === undefined) - firstButton = buttonElem; + return new Promise((resolve, reject) => { + if(target.querySelector('.messagebox')) { + reject(); + return; } - } - target.appendChild(html); - firstButton.focus(); + let buttonsElem; + const html =
+
+
+
+
{title}
+
+
{text}
+ {buttonsElem =
} +
+
; - return true; + let firstButton; + if(buttons.length < 1) { + firstButton = ; + buttonsElem.appendChild(firstButton); + } else { + for(const button of buttons) { + const buttonElem = ; + buttonsElem.appendChild(buttonElem); + + if(firstButton === undefined) + firstButton = buttonElem; + } + } + + target.appendChild(html); + firstButton.focus(); + }); }; diff --git a/assets/misuzu.js/parsing.js b/assets/misuzu.js/parsing.js new file mode 100644 index 0000000..d4fc08f --- /dev/null +++ b/assets/misuzu.js/parsing.js @@ -0,0 +1,56 @@ +// welcome to the shitty temporary file for managing the bbcode/markdown/whatever button +const MszParsing = (() => { + const defineTag = (name, open, close, summary, icon) => { + return { + name: name, + open: open, + close: close, + summary: summary, + icon: icon, + }; + }; + + const bbTags = [ + defineTag('bb-bold', '[b]', '[/b]', 'Bold [b][/b]', 'fas fa-bold fa-fw'), + defineTag('bb-italic', '[i]', '[/i]', 'Italic [i][/i]', 'fas fa-italic fa-fw'), + defineTag('bb-underline', '[u]', '[/u]', 'Underline [u][/u]', 'fas fa-underline fa-fw'), + defineTag('bb-strike', '[s]', '[/s]', 'Strikethrough [s][/s]', 'fas fa-strikethrough fa-fw'), + defineTag('bb-link', '[url=]', '[/url]', 'Link [url][/url] or [url=][/url]', 'fas fa-link fa-fw'), + defineTag('bb-image', '[img]', '[/img]', 'Image [img][/img]', 'fas fa-image fa-fw'), + defineTag('bb-audio', '[audio]', '[/audio]', 'Audio [audio][/audio]', 'fas fa-music fa-fw'), + defineTag('bb-video', '[video]', '[/video]', 'Video [video][/video]', 'fas fa-video fa-fw'), + defineTag('bb-code', '[code]', '[/code]', 'Code [code][/code]', 'fas fa-code fa-fw'), + defineTag('bb-zalgo', '[zalgo]', '[/zalgo]', 'Zalgo [zalgo][/zalgo]', 'fas fa-frog fa-fw'), + ]; + + const mdTags = [ + defineTag('md-bold', '**', '**', 'Bold ****', 'fas fa-bold fa-fw'), + defineTag('md-italic', '*', '*', 'Italic ** or __', 'fas fa-italic fa-fw'), + defineTag('md-underline', '__', '__', 'Underline ____', 'fas fa-underline fa-fw'), + defineTag('md-strike', '~~', '~~', 'Strikethrough ~~~~', 'fas fa-strikethrough fa-fw'), + defineTag('md-link', '[](', ')', 'Link []()', 'fas fa-link fa-fw'), + defineTag('md-image', '![](', ')', 'Image ![]()', 'fas fa-image fa-fw'), + defineTag('md-audio', '![](', ')', 'Audio ![]()', 'fas fa-music fa-fw'), + defineTag('md-video', '![](', ')', 'Video ![]()', 'fas fa-video fa-fw'), + defineTag('md-code', '```', '```', 'Code `` or ``````', 'fas fa-code fa-fw'), + ]; + + const getTagsFor = parser => { + if(typeof parser !== 'number') + parser = parseInt(parser); + + if(parser === 1) + return bbTags; + if(parser === 2) + return mdTags; + + return []; + }; + + return { + getTagsFor: getTagsFor, + getTagsForPlainText: () => getTagsFor(0), + getTagsForBBcode: () => getTagsFor(1), + getTagsForMarkdown: () => getTagsFor(2), + }; +})(); diff --git a/assets/misuzu.js/watcher.js b/assets/misuzu.js/watcher.js index f541210..fdca3c1 100644 --- a/assets/misuzu.js/watcher.js +++ b/assets/misuzu.js/watcher.js @@ -28,7 +28,7 @@ const MszWatcher = function() { }; }; -const MszWatcherCollection = function() { +const MszWatchers = function() { const watchers = new Map; const getWatcher = name => { diff --git a/database/2024_01_30_233734_create_messages_table.php b/database/2024_01_30_233734_create_messages_table.php new file mode 100644 index 0000000..d46d4d9 --- /dev/null +++ b/database/2024_01_30_233734_create_messages_table.php @@ -0,0 +1,48 @@ +execute(' + CREATE TABLE msz_messages ( + msg_id BINARY(8) NOT NULL, + msg_owner_id INT(10) UNSIGNED NOT NULL, + msg_author_id INT(10) UNSIGNED NULL DEFAULT NULL, + msg_recipient_id INT(10) UNSIGNED NULL DEFAULT NULL, + msg_reply_to BINARY(8) NULL DEFAULT NULL, + msg_title TINYTEXT NOT NULL COLLATE "utf8mb4_unicode_520_ci", + msg_body TEXT NOT NULL COLLATE "utf8mb4_unicode_520_ci", + msg_parser TINYINT(3) UNSIGNED NOT NULL, + msg_created TIMESTAMP NOT NULL DEFAULT current_timestamp(), + msg_sent TIMESTAMP NULL DEFAULT NULL, + msg_read TIMESTAMP NULL DEFAULT NULL, + msg_deleted TIMESTAMP NULL DEFAULT NULL, + PRIMARY KEY (msg_id, msg_owner_id), + KEY messages_owner_foreign (msg_owner_id), + KEY messages_author_foreign (msg_author_id), + KEY messages_recipient_foreign (msg_recipient_id), + KEY messages_reply_to_index (msg_reply_to), + KEY messages_created_index (msg_created), + KEY messages_sent_index (msg_sent), + KEY messages_read_index (msg_read), + KEY messages_deleted_index (msg_deleted), + CONSTRAINT messages_owner_foreign + FOREIGN KEY (msg_owner_id) + REFERENCES msz_users (user_id) + ON UPDATE CASCADE + ON DELETE CASCADE, + CONSTRAINT messages_author_foreign + FOREIGN KEY (msg_author_id) + REFERENCES msz_users (user_id) + ON UPDATE CASCADE + ON DELETE SET NULL, + CONSTRAINT messages_recipient_foreign + FOREIGN KEY (msg_recipient_id) + REFERENCES msz_users (user_id) + ON UPDATE CASCADE + ON DELETE SET NULL + ) ENGINE=InnoDB COLLATE=utf8mb4_bin; + '); + } +} diff --git a/public-legacy/profile.php b/public-legacy/profile.php index c5bd9e9..13ca96e 100644 --- a/public-legacy/profile.php +++ b/public-legacy/profile.php @@ -71,15 +71,16 @@ $notices = []; $userRank = $usersCtx->getUserRank($userInfo); $viewerRank = $usersCtx->getUserRank($viewerInfo); -$viewerPerms = $authInfo->getPerms('user'); +$viewerPermsGlobal = $authInfo->getPerms('global'); +$viewerPermsUser = $authInfo->getPerms('user'); $activeBanInfo = $usersCtx->tryGetActiveBan($userInfo); $isBanned = $activeBanInfo !== null; $profileFields = $msz->getProfileFields(); $viewingOwnProfile = (string)$viewerId === $userInfo->getId(); -$canManageWarnings = $viewerPerms->check(Perm::U_WARNINGS_MANAGE); +$canManageWarnings = $viewerPermsUser->check(Perm::U_WARNINGS_MANAGE); $canEdit = !$viewingAsGuest && ((!$isBanned && $viewingOwnProfile) || $viewerInfo->isSuperUser() || ( - $viewerPerms->check(Perm::U_USERS_MANAGE) && ($viewingOwnProfile || $viewerRank > $userRank) + $viewerPermsUser->check(Perm::U_USERS_MANAGE) && ($viewingOwnProfile || $viewerRank > $userRank) )); $avatarInfo = new UserAvatarAsset($userInfo); $backgroundInfo = new UserBackgroundAsset($userInfo); @@ -88,7 +89,7 @@ if($isEditing) { if(!$canEdit) Template::throwError(403); - $perms = $viewerPerms->checkMany([ + $perms = $viewerPermsUser->checkMany([ 'edit_profile' => Perm::U_PROFILE_EDIT, 'edit_avatar' => Perm::U_AVATAR_CHANGE, 'edit_background' => PERM::U_PROFILE_BACKGROUND_CHANGE, @@ -384,4 +385,5 @@ Template::render('profile.index', [ 'profile_ban_info' => $activeBanInfo, 'profile_avatar_info' => $avatarInfo, 'profile_background_info' => $backgroundInfo, + 'profile_can_send_messages' => $viewerPermsGlobal->check(Perm::G_MESSAGES_SEND), ]); diff --git a/src/Messages/MessageInfo.php b/src/Messages/MessageInfo.php new file mode 100644 index 0000000..70b8ec6 --- /dev/null +++ b/src/Messages/MessageInfo.php @@ -0,0 +1,148 @@ +messageId = $result->getString(0); + $this->ownerId = $result->getString(1); + $this->authorId = $result->getStringOrNull(2); + $this->recipientId = $result->getStringOrNull(3); + $this->replyTo = $result->getStringOrNull(4); + $this->title = $result->getString(5); + $this->body = $result->getString(6); + $this->parser = $result->getInteger(7); + $this->created = $result->getInteger(8); + $this->sent = $result->getIntegerOrNull(9); + $this->read = $result->getIntegerOrNull(10); + $this->deleted = $result->getIntegerOrNull(11); + } + + public function getId(): string { + return $this->messageId; + } + + public function getOwnerId(): string { + return $this->ownerId; + } + + public function hasAuthorId(): bool { + return $this->authorId !== null; + } + + public function getAuthorId(): ?string { + return $this->authorId; + } + + public function hasRecipientId(): bool { + return $this->recipientId !== null; + } + + public function getRecipientId(): ?string { + return $this->recipientId; + } + + public function hasReplyToId(): bool { + return $this->replyTo !== null; + } + + public function getReplyToId(): ?string { + return $this->replyTo; + } + + public function getTitle(): string { + return $this->title; + } + + public function getBody(): string { + return $this->body; + } + + public function getParser(): int { + return $this->parser; + } + + public function isBodyPlain(): bool { + return $this->parser === Parser::PLAIN; + } + + public function isBodyBBCode(): bool { + return $this->parser === Parser::BBCODE; + } + + public function isBodyMarkdown(): bool { + return $this->parser === Parser::MARKDOWN; + } + + public function getCreatedTime(): int { + return $this->created; + } + + public function getCreatedAt(): DateTime { + return DateTime::fromUnixTimeSeconds($this->created); + } + + public function isSent(): bool { + return $this->sent !== null; + } + + public function getSentTime(): ?int { + return $this->sent; + } + + public function getSentAt(): ?DateTime { + return $this->sent === null ? null : DateTime::fromUnixTimeSeconds($this->sent); + } + + public function isRead(): bool { + return $this->read !== null; + } + + public function getReadTime(): ?int { + return $this->read; + } + + public function getReadAt(): ?DateTime { + return $this->read === null ? null : DateTime::fromUnixTimeSeconds($this->read); + } + + public function isDeleted(): bool { + return $this->deleted !== null; + } + + public function getDeletedTime(): ?int { + return $this->deleted; + } + + public function getDeletedAt(): ?DateTime { + return $this->deleted === null ? null : DateTime::fromUnixTimeSeconds($this->deleted); + } + + public function getDisplayTime(): int { + if($this->isSent()) + return $this->getSentTime(); + return $this->getCreatedTime(); + } + + public function getDisplayAt(): DateTime { + if($this->isSent()) + return $this->getSentAt(); + return $this->getCreatedAt(); + } +} diff --git a/src/Messages/MessagesContext.php b/src/Messages/MessagesContext.php new file mode 100644 index 0000000..10d4500 --- /dev/null +++ b/src/Messages/MessagesContext.php @@ -0,0 +1,15 @@ +database = new MessagesDatabase($dbConn); + } + + public function getDatabase(): MessagesDatabase { + return $this->database; + } +} diff --git a/src/Messages/MessagesDatabase.php b/src/Messages/MessagesDatabase.php new file mode 100644 index 0000000..33bdfea --- /dev/null +++ b/src/Messages/MessagesDatabase.php @@ -0,0 +1,399 @@ +cache = new DbStatementCache($dbConn); + } + + public function countMessages( + UserInfo|string|null $ownerInfo = null, + UserInfo|string|null $authorInfo = null, + UserInfo|string|null $recipientInfo = null, + MessageInfo|string|null $repliesFor = null, + MessageInfo|string|null $replyTo = null, + ?bool $sent = null, + ?bool $read = null, + ?bool $deleted = null + ): int { + $hasOwnerInfo = $ownerInfo !== null; + $hasAuthorInfo = $authorInfo !== null; + $hasRecipientInfo = $recipientInfo !== null; + $hasRepliesFor = $repliesFor !== null; + $hasReplyTo = $replyTo !== null; + $hasSent = $sent !== null; + $hasRead = $read !== null; + $hasDeleted = $deleted !== null; + + $args = 0; + $query = 'SELECT COUNT(*) FROM msz_messages'; + if($hasOwnerInfo) { + ++$args; + $query .= ' WHERE msg_owner_id = ?'; + } + if($hasAuthorInfo) + $query .= sprintf(' %s msg_author_id = ?', ++$args > 1 ? 'AND' : 'WHERE'); + if($hasRecipientInfo) + $query .= sprintf(' %s msg_recipient_id = ?', ++$args > 1 ? 'AND' : 'WHERE'); + if($hasRepliesFor) + $query .= sprintf(' %s msg_reply_to = ?', ++$args > 1 ? 'AND' : 'WHERE'); + if($hasReplyTo) { + $query .= sprintf(' %s msg_id = ', ++$args > 1 ? 'AND' : 'WHERE'); + + if($replyTo instanceof MessageInfo) + $query .= '?'; + else + $query .= '(SELECT reply_to FROM msz_messages WHERE msg_id = ?)'; + } + if($hasSent) + $query .= sprintf(' %s msg_sent %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $sent ? 'IS NOT' : 'IS'); + if($hasRead) + $query .= sprintf(' %s msg_read %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $read ? 'IS NOT' : 'IS'); + if($hasDeleted) + $query .= sprintf(' %s msg_deleted %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $deleted ? 'IS NOT' : 'IS'); + $query .= ' ORDER BY msg_created DESC'; + + $args = 0; + $stmt = $this->cache->get($query); + + if($hasOwnerInfo) + $stmt->addParameter(++$args, $ownerInfo instanceof UserInfo ? $ownerInfo->getId() : $ownerInfo); + if($hasAuthorInfo) + $stmt->addParameter(++$args, $authorInfo instanceof UserInfo ? $authorInfo->getId() : $authorInfo); + if($hasRecipientInfo) + $stmt->addParameter(++$args, $recipientInfo instanceof UserInfo ? $recipientInfo->getId() : $recipientInfo); + if($hasRepliesFor) + $stmt->addParameter(++$args, $repliesFor instanceof MessageInfo ? $repliesFor->getId() : $repliesFor); + if($hasReplyTo) + $stmt->addParameter(++$args, $replyTo instanceof MessageInfo ? $replyTo->getReplyToId() : $replyTo); + + $stmt->execute(); + $result = $stmt->getResult(); + + return $result->next() ? $result->getInteger(0) : 0; + } + + public function getMessages( + UserInfo|string|null $ownerInfo = null, + UserInfo|string|null $authorInfo = null, + UserInfo|string|null $recipientInfo = null, + MessageInfo|string|null $repliesFor = null, + MessageInfo|string|null $replyTo = null, + ?bool $sent = null, + ?bool $read = null, + ?bool $deleted = null, + ?Pagination $pagination = null + ): array { + $hasOwnerInfo = $ownerInfo !== null; + $hasAuthorInfo = $authorInfo !== null; + $hasRecipientInfo = $recipientInfo !== null; + $hasRepliesFor = $repliesFor !== null; + $hasReplyTo = $replyTo !== null; + $hasSent = $sent !== null; + $hasRead = $read !== null; + $hasDeleted = $deleted !== null; + $hasPagination = $pagination !== null; + + $args = 0; + $query = 'SELECT msg_id, msg_owner_id, msg_author_id, msg_recipient_id, msg_reply_to, msg_title, msg_body, msg_parser, UNIX_TIMESTAMP(msg_created), UNIX_TIMESTAMP(msg_sent), UNIX_TIMESTAMP(msg_read), UNIX_TIMESTAMP(msg_deleted) FROM msz_messages'; + if($hasOwnerInfo) { + ++$args; + $query .= ' WHERE msg_owner_id = ?'; + } + if($hasAuthorInfo) + $query .= sprintf(' %s msg_author_id = ?', ++$args > 1 ? 'AND' : 'WHERE'); + if($hasRecipientInfo) + $query .= sprintf(' %s msg_recipient_id = ?', ++$args > 1 ? 'AND' : 'WHERE'); + if($hasRepliesFor) + $query .= sprintf(' %s msg_reply_to = ?', ++$args > 1 ? 'AND' : 'WHERE'); + if($hasReplyTo) { + $query .= sprintf(' %s msg_id = ', ++$args > 1 ? 'AND' : 'WHERE'); + + if($replyTo instanceof MessageInfo) + $query .= '?'; + else + $query .= '(SELECT reply_to FROM msz_messages WHERE msg_id = ?)'; + } + if($hasSent) + $query .= sprintf(' %s msg_sent %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $sent ? 'IS NOT' : 'IS'); + if($hasRead) + $query .= sprintf(' %s msg_read %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $read ? 'IS NOT' : 'IS'); + if($hasDeleted) + $query .= sprintf(' %s msg_deleted %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $deleted ? 'IS NOT' : 'IS'); + $query .= ' ORDER BY msg_created DESC'; + if($hasPagination) + $query .= ' LIMIT ? OFFSET ?'; + + $args = 0; + $stmt = $this->cache->get($query); + + if($hasOwnerInfo) + $stmt->addParameter(++$args, $ownerInfo instanceof UserInfo ? $ownerInfo->getId() : $ownerInfo); + if($hasAuthorInfo) + $stmt->addParameter(++$args, $authorInfo instanceof UserInfo ? $authorInfo->getId() : $authorInfo); + if($hasRecipientInfo) + $stmt->addParameter(++$args, $recipientInfo instanceof UserInfo ? $recipientInfo->getId() : $recipientInfo); + if($hasRepliesFor) + $stmt->addParameter(++$args, $repliesFor instanceof MessageInfo ? $repliesFor->getId() : $repliesFor); + if($hasReplyTo) + $stmt->addParameter(++$args, $replyTo instanceof MessageInfo ? $replyTo->getReplyToId() : $replyTo); + if($hasPagination) { + $stmt->addParameter(++$args, $pagination->getRange()); + $stmt->addParameter(++$args, $pagination->getOffset()); + } + + $stmt->execute(); + + $infos = []; + $result = $stmt->getResult(); + + while($result->next()) + $infos[] = new MessageInfo($result); + + return $infos; + } + + public function getMessageInfo( + UserInfo|string $ownerInfo, + MessageInfo|string $messageInfoOrId, + bool $useReplyTo = false + ): MessageInfo { + $stmt = $this->cache->get(sprintf( + 'SELECT msg_id, msg_owner_id, msg_author_id, msg_recipient_id, msg_reply_to, msg_title, msg_body, msg_parser, UNIX_TIMESTAMP(msg_created), UNIX_TIMESTAMP(msg_sent), UNIX_TIMESTAMP(msg_read), UNIX_TIMESTAMP(msg_deleted) FROM msz_messages WHERE msg_id = %s AND msg_owner_id = ?', + !$useReplyTo || $messageInfoOrId instanceof MessageInfo ? '?' : '(SELECT msg_reply_to FROM msz_messages WHERE msg_id = ?)' + )); + + if($messageInfoOrId instanceof MessageInfo) + $stmt->addParameter(1, $useReplyTo ? $messageInfoOrId->getReplyToId() : $messageInfoOrId->getId()); + else + $stmt->addParameter(1, $messageInfoOrId); + $stmt->addParameter(2, $ownerInfo instanceof UserInfo ? $ownerInfo->getId() : $ownerInfo); + $stmt->execute(); + + $result = $stmt->getResult(); + if(!$result->next()) + throw new RuntimeException('Message not found.'); + + return new MessageInfo($result); + } + + public function createMessage( + string $messageId, + UserInfo|string $ownerInfo, + UserInfo|string|null $authorInfo, + UserInfo|string|null $recipientInfo, + string $title, + string $body, + int $parser, + MessageInfo|string|null $replyTo = null, + DateTime|int|null $sentAt = null, + DateTime|int|null $readAt = null + ): MessageInfo { + $stmt = $this->cache->get('INSERT INTO msz_messages (msg_id, msg_owner_id, msg_author_id, msg_recipient_id, msg_reply_to, msg_title, msg_body, msg_parser, msg_sent, msg_read) VALUES (?, ?, ?, ?, ?, ?, ?, ?, FROM_UNIXTIME(?), FROM_UNIXTIME(?))'); + $stmt->addParameter(1, $messageId); + $stmt->addParameter(2, $ownerInfo instanceof UserInfo ? $ownerInfo->getId() : $ownerInfo); + $stmt->addParameter(3, $authorInfo instanceof UserInfo ? $authorInfo->getId() : $authorInfo); + $stmt->addParameter(4, $recipientInfo instanceof UserInfo ? $recipientInfo->getId() : $recipientInfo); + $stmt->addParameter(5, $replyTo instanceof MessageInfo ? $replyTo->getId() : $replyTo); + $stmt->addParameter(6, $title); + $stmt->addParameter(7, $body); + $stmt->addParameter(8, $parser); + $stmt->addParameter(9, $sentAt instanceof DateTime ? $sentAt->getUnixTimeSeconds() : $sentAt); + $stmt->addParameter(10, $readAt instanceof DateTime ? $readAt->getUnixTimeSeconds() : $readAt); + $stmt->execute(); + + return $this->getMessageInfo($ownerInfo, $messageId); + } + + public function updateMessage( + UserInfo|string|null $ownerInfo = null, + MessageInfo|string|null $messageInfo = null, + ?string $title = null, + ?string $body = null, + ?int $parser = null, + DateTime|int|null|false $sentAt = false, + DateTime|int|null|false $readAt = false + ): void { + $setQuery = []; + $setValues = []; + $whereQuery = []; + $whereValues = []; + + if($ownerInfo !== null) { + $whereQuery[] = 'msg_owner_id = ?'; + $whereValues[] = $ownerInfo instanceof UserInfo ? $ownerInfo->getId() : $ownerInfo; + } + + if($messageInfo !== null) { + $whereQuery[] = 'msg_id = ?'; + $whereValues[] = $messageInfo instanceof MessageInfo ? $messageInfo->getId() : $messageInfo; + } + + if($title !== null) { + $setQuery[] = 'msg_title = ?'; + $setValues[] = $title; + } + + if($body !== null) { + $setQuery[] = 'msg_body = ?'; + $setValues[] = $body; + } + + if($parser !== null) { + $setQuery[] = 'msg_parser = ?'; + $setValues[] = $parser; + } + + if($sentAt !== false) { + $setQuery[] = 'msg_sent = FROM_UNIXTIME(?)'; + $setValues[] = $sentAt instanceof DateTime ? $sentAt->getUnixTimeSeconds() : $sentAt; + } + + if($readAt !== false) { + $setQuery[] = 'msg_read = FROM_UNIXTIME(?)'; + $setValues[] = $readAt instanceof DateTime ? $readAt->getUnixTimeSeconds() : $readAt; + } + + if(empty($whereQuery)) + throw new InvalidArgumentException('$ownerInfo or $messageInfo must be specified.'); + if(empty($setQuery)) + return; + + $args = 0; + $stmt = $this->cache->get(sprintf( + 'UPDATE msz_messages SET %s WHERE %s', + implode(', ', $setQuery), + implode(' AND ', $whereQuery) + )); + + foreach($setValues as $value) + $stmt->addParameter(++$args, $value); + foreach($whereValues as $value) + $stmt->addParameter(++$args, $value); + + $stmt->execute(); + } + + public function deleteMessages( + UserInfo|string|null $ownerInfo, + MessageInfo|array|string|null $messageInfos + ): void { + $hasOwnerInfo = $ownerInfo !== null; + $hasMessageInfos = $messageInfos !== null; + + $query = 'UPDATE msz_messages SET msg_deleted = NOW() WHERE msg_deleted IS NULL'; + if($hasOwnerInfo) + $query .= ' AND msg_owner_id = ?'; + if($hasMessageInfos) { + if(!is_array($messageInfos)) + $messageInfos = [$messageInfos]; + + $query .= sprintf( + ' AND msg_id IN (%s)', + DbTools::prepareListString($messageInfos) + ); + } + + $args = 0; + $stmt = $this->cache->get($query); + + if($hasOwnerInfo) + $stmt->addParameter(++$args, $ownerInfo instanceof UserInfo ? $ownerInfo->getId() : $ownerInfo); + if($hasMessageInfos) + foreach($messageInfos as $messageInfo) { + if(is_string($messageInfo)) + $stmt->addParameter(++$args, $messageInfo); + elseif($messageInfo instanceof MessageInfo) + $stmt->addParameter(++$args, $messageInfo->getId()); + else + throw new InvalidArgumentException('$messageInfos must be an array of strings or MessageInfo instances.'); + } + + $stmt->execute(); + } + + public function restoreMessages( + UserInfo|string|null $ownerInfo, + MessageInfo|array|string|null $messageInfos + ): void { + $hasOwnerInfo = $ownerInfo !== null; + $hasMessageInfos = $messageInfos !== null; + + $query = 'UPDATE msz_messages SET msg_deleted = NULL WHERE msg_deleted IS NOT NULL'; + if($hasOwnerInfo) + $query .= ' AND msg_owner_id = ?'; + if($hasMessageInfos) { + if(!is_array($messageInfos)) + $messageInfos = [$messageInfos]; + + $query .= sprintf( + ' AND msg_id IN (%s)', + DbTools::prepareListString($messageInfos) + ); + } + + $args = 0; + $stmt = $this->cache->get($query); + + if($hasOwnerInfo) + $stmt->addParameter(++$args, $ownerInfo instanceof UserInfo ? $ownerInfo->getId() : $ownerInfo); + if($hasMessageInfos) + foreach($messageInfos as $messageInfo) { + if(is_string($messageInfo)) + $stmt->addParameter(++$args, $messageInfo); + elseif($messageInfo instanceof MessageInfo) + $stmt->addParameter(++$args, $messageInfo->getId()); + else + throw new InvalidArgumentException('$messageInfos must be an array of strings or MessageInfo instances.'); + } + + $stmt->execute(); + } + + public function nukeMessages( + UserInfo|string|null $ownerInfo, + MessageInfo|array|string|null $messageInfos + ): void { + $hasOwnerInfo = $ownerInfo !== null; + $hasMessageInfos = $messageInfos !== null; + + $query = 'DELETE FROM msz_messages WHERE msg_deleted IS NOT NULL'; + if($hasOwnerInfo) + $query .= ' AND msg_owner_id = ?'; + if($hasMessageInfos) { + if(!is_array($messageInfos)) + $messageInfos = [$messageInfos]; + + $query .= sprintf( + ' AND msg_id IN (%s)', + DbTools::prepareListString($messageInfos) + ); + } + + $args = 0; + $stmt = $this->cache->get($query); + + if($hasOwnerInfo) + $stmt->addParameter(++$args, $ownerInfo instanceof UserInfo ? $ownerInfo->getId() : $ownerInfo); + if($hasMessageInfos) + foreach($messageInfos as $messageInfo) { + if(is_string($messageInfo)) + $stmt->addParameter(++$args, $messageInfo); + elseif($messageInfo instanceof MessageInfo) + $stmt->addParameter(++$args, $messageInfo->getId()); + else + throw new InvalidArgumentException('$messageInfos must be an array of strings or MessageInfo instances.'); + } + + $stmt->execute(); + } +} diff --git a/src/Messages/MessagesRoutes.php b/src/Messages/MessagesRoutes.php new file mode 100644 index 0000000..f8b0b2a --- /dev/null +++ b/src/Messages/MessagesRoutes.php @@ -0,0 +1,601 @@ + [ 'title' => 'Inbox', 'icon' => 'fas fa-inbox fa-fw' ], + 'drafts' => [ 'title' => 'Drafts', 'icon' => 'fas fa-pencil-alt fa-fw' ], + 'sent' => [ 'title' => 'Sent', 'icon' => 'fas fa-paper-plane fa-fw' ], + 'trash' => [ 'title' => 'Trash', 'icon' => 'fas fa-trash-alt fa-fw' ], + ]; + + public function __construct( + private IConfig $config, + private URLRegistry $urls, + private AuthInfo $authInfo, + private MessagesContext $msgsCtx, + private UsersContext $usersCtx + ) {} + + private bool $canSendMessages; + + #[Route('/messages')] + public function checkAccess($response, $request) { + // should probably be a permission or something too + if(!$this->authInfo->isLoggedIn()) + return 401; + + $globalPerms = $this->authInfo->getPerms('global'); + if(!$globalPerms->check(Perm::G_MESSAGES_VIEW)) + return 403; + + $this->canSendMessages = $globalPerms->check(Perm::G_MESSAGES_SEND); + + if($request->getMethod() === 'POST' && $request->isFormContent()) { + $content = $request->getContent(); + if(!$content->hasParam('_csrfp') || !CSRF::validate((string)$content->getParam('_csrfp'))) + return [ + 'error' => [ + 'name' => 'msgs:verify', + 'text' => 'Request verification failed! Refresh the page and try again.', + ], + ]; + + $response->setHeader('X-CSRFP-Token', CSRF::token()); + } + } + + private function populateMessage(MessageInfo $messageInfo): object { + $message = new stdClass; + + $message->info = $messageInfo; + $message->author_info = $messageInfo->hasAuthorId() ? $this->usersCtx->getUserInfo($messageInfo->getAuthorId(), 'id') : null; + $message->author_colour = $this->usersCtx->getUserColour($message->author_info); + $message->recipient_info = $messageInfo->hasRecipientId() ? $this->usersCtx->getUserInfo($messageInfo->getRecipientId(), 'id') : null; + $message->recipient_colour = $this->usersCtx->getUserColour($message->recipient_info); + + return $message; + } + + #[Route('GET', '/messages')] + #[URLInfo('messages-index', '/messages', ['folder' => '', 'page' => ''])] + public function getIndex($response, $request, string $folderName = '') { + $folderName = (string)$request->getParam('folder'); + if($folderName === '') + $folderName = 'inbox'; + + if(!array_key_exists($folderName, self::FOLDER_META)) + return 404; + + $folderInbox = $folderName === 'inbox'; + $folderDrafts = $folderName === 'drafts'; + $folderSent = $folderName === 'sent'; + $folderTrash = $folderName === 'trash'; + + $selfInfo = $this->authInfo->getUserInfo(); + $msgsDb = $this->msgsCtx->getDatabase(); + + $authorInfo = !$folderTrash && $folderSent ? $selfInfo : null; + $recipientInfo = !$folderTrash && $folderInbox ? $selfInfo : null; + $sent = $folderTrash ? null : !$folderDrafts; + $deleted = $folderTrash; + + $pagination = new Pagination($msgsDb->countMessages( + ownerInfo: $selfInfo, + authorInfo: $authorInfo, + recipientInfo: $recipientInfo, + sent: $sent, + deleted: $deleted, + ), 50, 'page'); + + $messageInfos = $msgsDb->getMessages( + ownerInfo: $selfInfo, + authorInfo: $authorInfo, + recipientInfo: $recipientInfo, + sent: $sent, + deleted: $deleted, + pagination: $pagination, + ); + + $messages = []; + foreach($messageInfos as $messageInfo) + $messages[] = $this->populateMessage($messageInfo); + + return Template::renderRaw('messages.index', [ + 'can_send_messages' => $this->canSendMessages, + 'folder_name' => $folderName, + 'folder_meta' => self::FOLDER_META, + 'folder_messages' => $messages, + 'folder_pagination' => $pagination, + ]); + } + + #[Route('GET', '/messages/stats')] + #[URLInfo('messages-stats', '/messages/stats')] + public function getStats() { + $selfInfo = $this->authInfo->getUserInfo(); + $msgsDb = $this->msgsCtx->getDatabase(); + + return [ + 'unread' => $msgsDb->countMessages( + ownerInfo: $selfInfo, + recipientInfo: $selfInfo, + sent: true, + deleted: false, + read: false, + ), + ]; + } + + #[Route('POST', '/messages/recipient')] + #[URLInfo('messages-recipient', '/messages/recipient')] + public function postRecipient($response, $request) { + if(!$request->isFormContent()) + return 400; + if(!$this->canSendMessages) + return 403; + + $content = $request->getContent(); + $name = trim((string)$content->getParam('name')); + + // flappy hacks + if(str_starts_with(mb_strtolower($name), 'flappyzor')) + $name = 14; + + $userInfo = null; + if(!empty($name)) + try { + $userInfo = $this->usersCtx->getUserInfo($name, 'messaging'); + } catch(InvalidArgumentException $ex) { + } catch(RuntimeException $ex) {} + + if($userInfo === null) + return [ + 'avatar' => $this->urls->format('user-avatar', [ + 'res' => 200, + ]), + ]; + + return [ + 'id' => $userInfo->getId(), + 'name' => $userInfo->getName(), + 'avatar' => $this->urls->format('user-avatar', [ + 'user' => $userInfo->getId(), + 'res' => 200, + ]), + ]; + } + + #[Route('GET', '/messages/compose')] + #[URLInfo('messages-compose', '/messages/compose', ['recipient' => ''])] + public function getEditor($response, $request) { + if(!$this->canSendMessages) + return 403; + + return Template::renderRaw('messages.compose', [ + 'recipient' => (string)$request->getParam('recipient'), + ]); + } + + #[Route('GET', '/messages/:message')] + #[URLInfo('messages-view', '/messages/')] + public function getView($response, $request, string $messageId) { + if(strlen($messageId) !== 8) + return 404; + + $selfInfo = $this->authInfo->getUserInfo(); + $msgsDb = $this->msgsCtx->getDatabase(); + + try { + $messageInfo = $msgsDb->getMessageInfo($selfInfo, $messageId); + } catch(RuntimeException $ex) { + return 404; + } + + if(!$messageInfo->isRead()) + $msgsDb->updateMessage( + ownerInfo: $selfInfo, + messageInfo: $messageInfo, + readAt: time(), + ); + + $message = $this->populateMessage($messageInfo); + + $replyTo = null; + if($messageInfo->hasReplyToId()) { + try { + $replyTo = $this->populateMessage( + $msgsDb->getMessageInfo($selfInfo, $messageInfo, true) + ); + } catch(RuntimeException $ex) {} + } + + $repliesForInfos = $msgsDb->getMessages( + ownerInfo: $selfInfo, + repliesFor: $messageInfo, + deleted: false, + ); + + $draftInfo = null; + $repliesFor = []; + foreach($repliesForInfos as $repliesForInfo) { + $repliesFor[] = $this->populateMessage($repliesForInfo); + + if(!$repliesForInfo->isSent() && $draftInfo === null) + $draftInfo = $repliesForInfo; + } + + return Template::renderRaw('messages.thread', [ + 'can_send_messages' => $this->canSendMessages, + 'self_info' => $selfInfo, + 'reply_to' => $replyTo, + 'message' => $message, + 'draft_info' => $draftInfo, + 'replies_for' => $repliesFor, + ]); + } + + private function checkMessageFields(string $title, string $body, int $parser): ?array { + if(!Parser::isValid($parser)) + return [ + 'error' => [ + 'name' => 'msgs:invalid_parser', + 'text' => 'Invalid parser selected.', + ], + ]; + + $lengths = $this->config->getValues([ + ['title.minLength:i', 1], + ['title.maxLength:i', 200], + ['body.minLength:i', 1], + ['body.maxLength:i', 60000], + ]); + + $titleLength = mb_strlen(trim($title)); + + if($titleLength < $lengths['title.minLength']) + return [ + 'error' => [ + 'name' => 'msgs:title_too_short', + 'args' => [$lengths['title.minLength'], $titleLength], + 'text' => sprintf('Title may not be shorter than %d characters. You entered %d characters.', $lengths['title.minLength'], $titleLength), + ], + ]; + + if($titleLength > $lengths['title.maxLength']) + return [ + 'error' => [ + 'name' => 'msgs:title_too_long', + 'args' => [$lengths['title.maxLength'], $titleLength], + 'text' => sprintf('Title may not be longer than %d characters. You entered %d characters.', $lengths['title.maxLength'], $titleLength), + ], + ]; + + $bodyLength = mb_strlen(trim($body)); + + if($bodyLength < $lengths['body.minLength']) + return [ + 'error' => [ + 'name' => 'msgs:body_too_short', + 'args' => [$lengths['body.minLength'], $bodyLength], + 'text' => sprintf('Message may not be shorter than %d characters. You entered %d characters.', $lengths['body.minLength'], $bodyLength), + ], + ]; + + if($bodyLength > $lengths['body.maxLength']) + return [ + 'error' => [ + 'name' => 'msgs:body_too_long', + 'args' => [$lengths['body.maxLength'], $bodyLength], + 'text' => sprintf('Message may not be longer than %d characters. You entered %d characters.', $lengths['body.maxLength'], $bodyLength), + ], + ]; + + return null; + } + + #[Route('POST', '/messages/create')] + #[URLInfo('messages-create', '/messages/create')] + public function postCreate($response, $request) { + if(!$request->isFormContent()) + return 400; + if(!$this->canSendMessages) + return 403; + + $content = $request->getContent(); + $recipient = (string)$content->getParam('recipient'); + $replyTo = (string)$content->getParam('reply'); + $title = (string)$content->getParam('title'); + $body = (string)$content->getParam('body'); + $parser = (int)$content->getParam('parser', FILTER_SANITIZE_NUMBER_INT); + $draft = !empty($content->getParam('draft')); + + $error = $this->checkMessageFields($title, $body, $parser); + if($error !== null) + return $error; + + $selfInfo = $this->authInfo->getUserInfo(); + $msgsDb = $this->msgsCtx->getDatabase(); + + try { + $recipientInfo = $this->usersCtx->getUserInfo($recipient, 'messaging'); + } catch(InvalidArgumentException $ex) { + return [ + 'error' => [ + 'name' => 'msgs:recipient_invalid', + 'text' => 'Name of the recipient was incorrectly formatted.', + 'jeff' => $recipient, + ], + ]; + } catch(RuntimeException $ex) { + return [ + 'error' => [ + 'name' => 'msgs:recipient_not_found', + 'text' => 'Recipient does not exist.', + ], + ]; + } + + $replyToInfo = null; + if(!empty($replyTo)) { + try { + $replyToInfo = $msgsDb->getMessageInfo($selfInfo, $replyTo); + } catch(RuntimeException $ex) { + return [ + 'error' => [ + 'name' => 'msgs:reply_not_found', + 'text' => 'The message you are trying to reply to does not exist.', + ], + ]; + } + + if(!$replyToInfo->isSent()) + return [ + 'error' => [ + 'name' => 'msgs:draft_reply', + 'text' => 'You cannot reply to a draft.', + ], + ]; + } + + $msgId = XString::random(8); + $sentAt = $draft ? null : time(); + + // own copy + $msgsDb->createMessage( + messageId: $msgId, + ownerInfo: $selfInfo, + authorInfo: $selfInfo, + recipientInfo: $recipientInfo, + title: $title, + body: $body, + parser: $parser, + replyTo: $replyToInfo, + sentAt: $sentAt + ); + + // recipient copy + if($sentAt !== null && $recipientInfo->getId() !== $selfInfo->getId()) + $msgsDb->createMessage( + messageId: $msgId, + ownerInfo: $recipientInfo, + authorInfo: $selfInfo, + recipientInfo: $recipientInfo, + title: $title, + body: $body, + parser: $parser, + replyTo: $replyToInfo, + sentAt: $sentAt + ); + + return [ + 'id' => $msgId, + 'url' => $this->urls->format('messages-view', ['message' => $msgId]), + ]; + } + + #[Route('POST', '/messages/:message')] + #[URLInfo('messages-update', '/messages/')] + public function postUpdate($response, $request, string $messageId) { + if(!$request->isFormContent()) + return 400; + if(!$this->canSendMessages) + return 403; + + $content = $request->getContent(); + $title = (string)$content->getParam('title'); + $body = (string)$content->getParam('body'); + $parser = (int)$content->getParam('parser', FILTER_SANITIZE_NUMBER_INT); + $draft = !empty($content->getParam('draft')); + + $error = $this->checkMessageFields($title, $body, $parser); + if($error !== null) + return $error; + + $selfInfo = $this->authInfo->getUserInfo(); + $msgsDb = $this->msgsCtx->getDatabase(); + + try { + $messageInfo = $msgsDb->getMessageInfo($selfInfo, $messageId); + } catch(RuntimeException $ex) { + return [ + 'error' => [ + 'name' => 'msgs:edit_not_found', + 'text' => 'The message you are trying to edit does not exist.', + ], + ]; + } + + if(!$messageInfo->hasAuthorId() || $messageInfo->getAuthorId() !== $selfInfo->getId()) + return [ + 'error' => [ + 'name' => 'msgs:not_author', + 'text' => 'You are not the author of this message.', + ], + ]; + + if(!$messageInfo->hasRecipientId()) + return [ + 'error' => [ + 'name' => 'msgs:recipient_gone', + 'text' => 'The recipient of this message no longer exists, it cannot be sent or edited.', + ], + ]; + + if($messageInfo->isSent()) + return [ + 'error' => [ + 'name' => 'msgs:not_draft', + 'text' => 'You cannot edit a message that has already been sent.', + ], + ]; + + $sentAt = $draft ? null : time(); + + $msgsDb->updateMessage( + ownerInfo: $selfInfo, + messageInfo: $messageInfo, + title: $title, + body: $body, + parser: $parser, + sentAt: $sentAt, + ); + + // recipient copy + if($sentAt !== null && $messageInfo->getRecipientId() !== $selfInfo->getId()) + $msgsDb->createMessage( + messageId: $messageId, + ownerInfo: $messageInfo->getRecipientId(), + authorInfo: $selfInfo, + recipientInfo: $messageInfo->getRecipientId(), + title: $title, + body: $body, + parser: $parser, + replyTo: $messageInfo->getReplyToId(), + sentAt: $sentAt + ); + + return [ + 'id' => $messageId, + 'url' => $this->urls->format('messages-view', ['message' => $messageId]), + ]; + } + + #[Route('POST', '/messages/mark')] + #[URLInfo('messages-mark', '/messages/mark')] + public function postMark($response, $request) { + if(!$request->isFormContent()) + return 400; + + $content = $request->getContent(); + $type = (string)$content->getParam('type'); + $messages = explode(',', (string)$content->getParam('messages')); + + if($type !== 'read' && $type !== 'unread') + return [ + 'error' => [ + 'name' => 'msgs:unsupported_mark', + 'text' => 'Attempting to mark message with an unsupported state.', + ], + ]; + + $selfInfo = $this->authInfo->getUserInfo(); + $msgsDb = $this->msgsCtx->getDatabase(); + + foreach($messages as $messageId) + $msgsDb->updateMessage( + ownerInfo: $selfInfo, + messageInfo: $messageId, + readAt: $type === 'read' ? time() : null, + ); + + return []; + } + + #[Route('POST', '/messages/delete')] + #[URLInfo('messages-delete', '/messages/delete')] + public function postDelete($response, $request) { + if(!$request->isFormContent()) + return 400; + + $content = $request->getContent(); + $messages = explode(',', (string)$content->getParam('messages')); + + if(empty($messages)) + return [ + 'error' => [ + 'name' => 'msgs:empty', + 'text' => 'No messages were supplied.', + ], + ]; + + $this->msgsCtx->getDatabase()->deleteMessages( + $this->authInfo->getUserInfo(), + $messages + ); + + return []; + } + + #[Route('POST', '/messages/restore')] + #[URLInfo('messages-restore', '/messages/restore')] + public function postRestore($response, $request) { + if(!$request->isFormContent()) + return 400; + + $content = $request->getContent(); + $messages = explode(',', (string)$content->getParam('messages')); + + if(empty($messages)) + return [ + 'error' => [ + 'name' => 'msgs:empty', + 'text' => 'No messages were supplied.', + ], + ]; + + $this->msgsCtx->getDatabase()->restoreMessages( + $this->authInfo->getUserInfo(), + $messages + ); + + return []; + } + + #[Route('POST', '/messages/nuke')] + #[URLInfo('messages-nuke', '/messages/nuke')] + public function postNuke($response, $request) { + if(!$request->isFormContent()) + return 400; + + $content = $request->getContent(); + $messages = explode(',', (string)$content->getParam('messages')); + + if(empty($messages)) + return [ + 'error' => [ + 'name' => 'msgs:empty', + 'text' => 'No messages were supplied.', + ], + ]; + + $this->msgsCtx->getDatabase()->nukeMessages( + $this->authInfo->getUserInfo(), + $messages + ); + + return []; + } +} diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php index f69f98a..85a2018 100644 --- a/src/MisuzuContext.php +++ b/src/MisuzuContext.php @@ -3,33 +3,23 @@ namespace Misuzu; use Index\Environment; use Index\Data\IDbConnection; -use Index\Data\Migration\IDbMigrationRepo; -use Index\Data\Migration\DbMigrationManager; -use Index\Data\Migration\FsDbMigrationRepo; +use Index\Data\Migration\{IDbMigrationRepo,DbMigrationManager,FsDbMigrationRepo}; use Sasae\SasaeEnvironment; use Syokuhou\IConfig; use Misuzu\Template; -use Misuzu\Auth\AuthContext; -use Misuzu\Auth\AuthInfo; +use Misuzu\Auth\{AuthContext,AuthInfo}; use Misuzu\AuditLog\AuditLog; use Misuzu\Changelog\Changelog; -use Misuzu\Changelog\ChangelogRoutes; use Misuzu\Comments\Comments; use Misuzu\Counters\Counters; use Misuzu\Emoticons\Emotes; use Misuzu\Forum\ForumContext; -use Misuzu\Home\HomeRoutes; -use Misuzu\Info\InfoRoutes; +use Misuzu\Messages\MessagesContext; use Misuzu\News\News; -use Misuzu\News\NewsRoutes; use Misuzu\Perms\Permissions; use Misuzu\Profile\ProfileFields; -use Misuzu\Satori\SatoriRoutes; -use Misuzu\SharpChat\SharpChatRoutes; use Misuzu\URLs\URLRegistry; -use Misuzu\Users\UsersContext; -use Misuzu\Users\UserInfo; -use Misuzu\Users\Assets\AssetsRoutes; +use Misuzu\Users\{UsersContext,UserInfo}; // this class should function as the root for everything going forward // no more magical static classes that are just kind of assumed to exist @@ -51,8 +41,9 @@ class MisuzuContext { private Comments $comments; private AuthContext $authCtx; - private UsersContext $usersCtx; private ForumContext $forumCtx; + private MessagesContext $messagesCtx; + private UsersContext $usersCtx; private ProfileFields $profileFields; @@ -72,8 +63,9 @@ class MisuzuContext { $this->siteInfo = new SiteInfo($config->scopeTo('site')); $this->authCtx = new AuthContext($dbConn, $config->scopeTo('auth')); - $this->usersCtx = new UsersContext($dbConn); $this->forumCtx = new ForumContext($dbConn); + $this->messagesCtx = new MessagesContext($dbConn); + $this->usersCtx = new UsersContext($dbConn); $this->auditLog = new AuditLog($dbConn); $this->changelog = new Changelog($dbConn); @@ -196,6 +188,7 @@ class MisuzuContext { $globals = $this->config->getValues([ ['eeprom.path:s', '', 'eeprom_path'], ['eeprom.app:s', '', 'eeprom_app'], + ['eeprom.appmsgs:s', '', 'eeprom_app_messages'], ]); $isDebug = Environment::isDebug(); @@ -221,7 +214,7 @@ class MisuzuContext { $this->urls = $routingCtx->getURLs(); $routingCtx->registerDefaultErrorPages(); - $routingCtx->register(new HomeRoutes( + $routingCtx->register(new \Misuzu\Home\HomeRoutes( $this->config, $this->dbConn, $this->siteInfo, @@ -233,15 +226,15 @@ class MisuzuContext { $this->usersCtx )); - $routingCtx->register(new AssetsRoutes( + $routingCtx->register(new \Misuzu\Users\Assets\AssetsRoutes( $this->authInfo, $this->urls, $this->usersCtx )); - $routingCtx->register(new InfoRoutes); + $routingCtx->register(new \Misuzu\Info\InfoRoutes); - $routingCtx->register(new NewsRoutes( + $routingCtx->register(new \Misuzu\News\NewsRoutes( $this->siteInfo, $this->authInfo, $this->urls, @@ -250,7 +243,15 @@ class MisuzuContext { $this->comments )); - $routingCtx->register(new ChangelogRoutes( + $routingCtx->register(new \Misuzu\Messages\MessagesRoutes( + $this->config->scopeTo('messages'), + $this->urls, + $this->authInfo, + $this->messagesCtx, + $this->usersCtx + )); + + $routingCtx->register(new \Misuzu\Changelog\ChangelogRoutes( $this->siteInfo, $this->urls, $this->changelog, @@ -259,7 +260,7 @@ class MisuzuContext { $this->comments )); - $routingCtx->register(new SharpChatRoutes( + $routingCtx->register(new \Misuzu\SharpChat\SharpChatRoutes( $this->config->scopeTo('sockChat'), $this->urls, $this->usersCtx, @@ -269,7 +270,7 @@ class MisuzuContext { $this->authInfo )); - $routingCtx->register(new SatoriRoutes( + $routingCtx->register(new \Misuzu\Satori\SatoriRoutes( $this->config->scopeTo('satori'), $this->usersCtx, $this->forumCtx, diff --git a/src/MisuzuSasaeExtension.php b/src/MisuzuSasaeExtension.php index 1f38078..527dfdb 100644 --- a/src/MisuzuSasaeExtension.php +++ b/src/MisuzuSasaeExtension.php @@ -141,12 +141,20 @@ final class MisuzuSasaeExtension extends AbstractExtension { if($authInfo->isLoggedIn()) { $userInfo = $authInfo->getUserInfo(); + $globalPerms = $authInfo->getPerms('global'); $menu[] = [ 'title' => 'Profile', 'url' => $urls->format('user-profile', ['user' => $userInfo->getId()]), 'icon' => 'fas fa-user fa-fw', ]; + if($globalPerms->check(Perm::G_MESSAGES_VIEW)) + $menu[] = [ + 'title' => 'Messages', + 'url' => $urls->format('messages-index'), + 'icon' => 'fas fa-envelope fa-fw', + 'class' => 'js-header-pms-button', + ]; $menu[] = [ 'title' => 'Settings', 'url' => $urls->format('settings-index'), @@ -158,7 +166,7 @@ final class MisuzuSasaeExtension extends AbstractExtension { 'icon' => 'fas fa-search fa-fw', ]; - if(!$usersCtx->hasActiveBan($userInfo) && $authInfo->getPerms('global')->check(Perm::G_IS_JANITOR)) { + if(!$usersCtx->hasActiveBan($userInfo) && $globalPerms->check(Perm::G_IS_JANITOR)) { // restore behaviour where clicking this button switches between // site version and broom version if($inBroomCloset) diff --git a/src/Perm.php b/src/Perm.php index d7c4346..f8c497c 100644 --- a/src/Perm.php +++ b/src/Perm.php @@ -7,7 +7,8 @@ use stdClass; // To avoid future conflicts, unused/deprecated permissions should remain defined for any given category final class Perm { // GLOBAL ALLOCATION: - // 0bXXXXX_XXXXXXXX_CCCCCCCC_FFFFFFFF_NNNNNNNN_LLLLLLLL_GGGGGGGG + // 0bXXXXX_XXXXXXXX_CCCCCCCC_MMMMFFFF_NNNNNNNN_LLLLLLLL_GGGGGGGG + // M -> Messages permissions // G -> General global permissions // L -> Changelog permissions // N -> News permissions @@ -34,6 +35,9 @@ final class Perm { public const G_FORUM_LEADERBOARD_VIEW = 0b00000_00000000_00000000_00000010_00000000_00000000_00000000; public const G_FORUM_TOPIC_REDIRS_MANAGE = 0b00000_00000000_00000000_00000100_00000000_00000000_00000000; + public const G_MESSAGES_VIEW = 0b00000_00000000_00000000_00010000_00000000_00000000_00000000; + public const G_MESSAGES_SEND = 0b00000_00000000_00000000_00100000_00000000_00000000_00000000; + public const G_COMMENTS_CREATE = 0b00000_00000000_00000001_00000000_00000000_00000000_00000000; public const G_COMMENTS_EDIT_OWN = 0b00000_00000000_00000010_00000000_00000000_00000000_00000000; // unused: editing not implemented public const G_COMMENTS_EDIT_ANY = 0b00000_00000000_00000100_00000000_00000000_00000000_00000000; // unused: editing not implemented @@ -115,7 +119,7 @@ final class Perm { public const INFO_FOR_ROLE = self::INFO_FOR_USER; // just alias for now, no clue if this will ever desync public const INFO_FOR_FORUM_CATEGORY = ['forum']; - public const LISTS_FOR_USER = ['global:general', 'global:changelog', 'global:news', 'global:forum', 'global:comments', 'user:personal', 'user:manage', 'chat:general']; + public const LISTS_FOR_USER = ['global:general', 'global:changelog', 'global:news', 'global:forum', 'global:messages', 'global:comments', 'user:personal', 'user:manage', 'chat:general']; public const LISTS_FOR_ROLE = self::LISTS_FOR_USER; // idem public const LISTS_FOR_FORUM_CATEGORY = ['forum:category', 'forum:topic', 'forum:post']; @@ -161,6 +165,15 @@ final class Perm { ], ], + 'global:messages' => [ + 'title' => 'Private Messages Permissions', + 'perms' => [ + 'global', + self::G_MESSAGES_VIEW, + self::G_MESSAGES_SEND, + ], + ], + 'global:comments' => [ 'title' => 'Comments Permissions', 'perms' => [ @@ -290,6 +303,9 @@ final class Perm { self::G_FORUM_LEADERBOARD_VIEW => 'Can view forum leaderboard.', self::G_FORUM_TOPIC_REDIRS_MANAGE => 'Can create redirects for deleted forum topics.', + self::G_MESSAGES_VIEW => 'Can view private messages.', + self::G_MESSAGES_SEND => 'Can send private messages.', + self::G_COMMENTS_CREATE => 'Can post comments.', self::G_COMMENTS_EDIT_OWN => 'Can edit own comments.', self::G_COMMENTS_EDIT_ANY => 'Can edit ANY comment.', diff --git a/src/Users/Users.php b/src/Users/Users.php index 1aafc8b..d963bed 100644 --- a/src/Users/Users.php +++ b/src/Users/Users.php @@ -230,6 +230,7 @@ class Users { 'email' => self::GET_USER_MAIL, 'profile' => self::GET_USER_ID | self::GET_USER_NAME, 'search' => self::GET_USER_ID | self::GET_USER_NAME, + 'messaging' => self::GET_USER_ID | self::GET_USER_NAME, 'login' => self::GET_USER_NAME | self::GET_USER_MAIL, 'recovery' => self::GET_USER_MAIL, ]; diff --git a/templates/_layout/meta.twig b/templates/_layout/meta.twig index 4df6057..8c20066 100644 --- a/templates/_layout/meta.twig +++ b/templates/_layout/meta.twig @@ -7,17 +7,17 @@ {% set browser_title = globals.site_info.name %} {% endif %} - {{ browser_title }} + {{ browser_title }} - - + + {% if description|length > 0 %} {% endif %} - + {% if image is defined %} {% if image|slice(0, 1) == '/' %} diff --git a/templates/forum/posting.twig b/templates/forum/posting.twig index 8e5a365..af330ef 100644 --- a/templates/forum/posting.twig +++ b/templates/forum/posting.twig @@ -1,7 +1,7 @@ {% extends 'forum/master.twig' %} {% from 'macros.twig' import avatar %} {% from 'forum/macros.twig' import forum_header %} -{% from '_layout/input.twig' import input_hidden, input_csrf, input_text, input_button, input_select, input_checkbox %} +{% from '_layout/input.twig' import input_hidden, input_csrf, input_text, input_select, input_checkbox %} {% set title = 'Posting' %} {% set is_reply = posting_topic is defined %} @@ -74,73 +74,9 @@ {% endif %}
- - - - - - +
{{ input_select( @@ -167,7 +103,6 @@ ) ) }}
-
diff --git a/templates/master.twig b/templates/master.twig index 88b3696..51b3b1b 100644 --- a/templates/master.twig +++ b/templates/master.twig @@ -4,6 +4,7 @@ {% include '_layout/meta.twig' %} + {% if site_background is defined %} @@ -43,7 +44,7 @@ {% endif %} {% block content %} -
+
This page is empty, populate it.
{% endblock %} diff --git a/templates/messages/compose.twig b/templates/messages/compose.twig new file mode 100644 index 0000000..7b4fda6 --- /dev/null +++ b/templates/messages/compose.twig @@ -0,0 +1,63 @@ +{% extends 'messages/master.twig' %} +{% from 'macros.twig' import avatar, container_title %} +{% from '_layout/input.twig' import input_hidden, input_text, input_select %} + +{% set title = 'Composing message' %} +{% set canonical_url = url('messages-compose') %} + +{% block messages_content %} +
+
+
+
+ Return +
+ +
+ {{ container_title(' Recipient') }} + +
+
+ {{ avatar(0, 100) }} +
+
+ {{ input_text('name', 'messages-recipient-name-input js-messages-recipient-name', recipient, 'text', 'Recipient name') }} +
+
+
+ +
+
+

UI is VERY not final. It will be not awful before 2025 I promise for real this time!!!

+

I need to clean up a lot of code first because a lot of things are specifically written for the forum editor and it will become a big mess otherwise.

+
+
+
+
+
+
+ {{ container_title(' Writing a message') }} + +
+ {{ input_hidden('recipient', '') }} +
+ {{ input_text('title', 'messages-reply-subject-input', '', 'text', 'Subject', true) }} +
+
+ +
+ +
+
+ {{ input_select('parser', constant('\\Misuzu\\Parsers\\Parser::NAMES'), '1', null, null, null, 'js-messages-reply-parser') }} +
+
+ + +
+
+
+
+
+
+{% endblock %} diff --git a/templates/messages/index.twig b/templates/messages/index.twig new file mode 100644 index 0000000..113f03b --- /dev/null +++ b/templates/messages/index.twig @@ -0,0 +1,89 @@ +{% extends 'messages/master.twig' %} +{% from 'macros.twig' import container_title, pagination %} + +{% set title = folder_name == 'inbox' ? 'Messages' : ('%s :: Messages'|format(folder_meta[folder_name].title)) %} +{% set canonical_url = url('messages-index') %} + +{% block messages_content %} +
+
+
+ {% if can_send_messages %} + + {% endif %} + +
+ {{ container_title(' Folders') }} + + {% for name, meta in folder_meta %} + +
+
{{ meta.title }}
+
+ {% endfor %} +
+ +
+ {{ container_title(' Actions') }} + + + {% if folder_name == 'inbox' %} + + {% endif %} + + {% if folder_name == 'trash' %} + + {% endif %} +
+
+
+
+
+ {{ container_title(' %s'|format(folder_meta[folder_name].icon, folder_meta[folder_name].title)) }} + +
+
+
+
There are no messages to display.
+
+ {% if folder_messages is not empty %} + {% for message in folder_messages %} + {% set user_info = (folder_name == 'drafts' or folder_name == 'sent' ? message.recipient_info : message.author_info) %} + {% set user_colour = (folder_name == 'drafts' or folder_name == 'sent' ? message.recipient_colour : message.author_colour) %} +
+
+
+
+ +
+ +
+
+
{{ message.info.title }}
+
+
+
{{ message.info.body }}
+
+
+ {% endfor %} + {% endif %} + {{ pagination(folder_pagination, 'messages-index', {folder: (folder_name == 'index' ? '' : folder_name)}) }} +
+
+
+
+{% endblock %} diff --git a/templates/messages/master.twig b/templates/messages/master.twig new file mode 100644 index 0000000..fa224d6 --- /dev/null +++ b/templates/messages/master.twig @@ -0,0 +1,12 @@ +{% extends 'master.twig' %} + +{% block content %} + {% block messages_content %} + {% endblock %} + + {% if globals.eeprom_path is not empty and globals.eeprom_app_messages is not empty %} + + {% endif %} +{% endblock %} diff --git a/templates/messages/thread.twig b/templates/messages/thread.twig new file mode 100644 index 0000000..4628428 --- /dev/null +++ b/templates/messages/thread.twig @@ -0,0 +1,179 @@ +{% extends 'messages/master.twig' %} +{% from 'macros.twig' import avatar, container_title %} +{% from '_layout/input.twig' import input_hidden, input_text, input_select %} + +{% set title = 'Viewing message' %} +{% set canonical_url = url('messages-view', { message: message.info.id }) %} + +{% block messages_content %} +
+
+
+
+ Back +
+ +
+ {{ container_title(' Actions') }} + + + + +
+
+
+
+
+ {% if reply_to is defined and reply_to is not empty %} + + {% endif %} + + + + {% if can_send_messages %} + {% set has_draft_info = draft_info is defined and draft_info is not null %} + {% set reply_field_is_draft = false %} + {% if not has_draft_info and not message.info.isSent %} + {% set has_draft_info = true %} + {% set reply_field_is_draft = true %} + {% set draft_info = message.info %} + {% endif %} + + {% set msg_author_id = message.info.authorId|default(0) %} + {% set msg_recipient_id = message.info.recipientId|default(0) %} + + {% if has_draft_info or (msg_author_id > 0 and msg_recipient_id > 0) %} +
+ {{ container_title(has_draft_info ? ' Edit' : ' Reply') }} + +
+ {% if has_draft_info %} + {{ input_hidden('message', draft_info.id) }} + {% else %} + {{ input_hidden('reply', message.info.id) }} + {{ input_hidden('recipient', self_info.id == msg_author_id ? msg_recipient_id : msg_author_id) }} + {% endif %} +
+ {{ input_text('title', 'messages-reply-subject-input', draft_info.title|default('%s%s'|format((message.info.title|slice(0, 4) == 'Re: ' ? '' : 'Re: '), message.info.title)), 'text', 'Subject', true) }} +
+
+ +
+ +
+
+ {{ input_select('parser', constant('\\Misuzu\\Parsers\\Parser::NAMES'), draft_info.parser|default('1'), null, null, null, 'js-messages-reply-parser') }} +
+
+ + +
+
+
+
+ {% endif %} + {% endif %} + + {% if replies_for is defined and replies_for is iterable %} + {% for reply_for in replies_for %} + + {% endfor %} + {% endif %} +
+
+
+{% endblock %} diff --git a/templates/profile/_layout/header.twig b/templates/profile/_layout/header.twig index bf47a37..9ba4730 100644 --- a/templates/profile/_layout/header.twig +++ b/templates/profile/_layout/header.twig @@ -71,8 +71,13 @@ Discard Settings - {% elseif profile_can_edit %} - Edit Profile + {% else %} + {% if profile_can_edit %} + Edit Profile + {% endif %} + {% if profile_can_send_messages %} + Send Message + {% endif %} {% endif %} {% else %} Return