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( $insertTags(textElem, tag.open, tag.close)}>
+
+ );
};
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( $insertTags(bodyElem, tag.open, tag.close)}>
+
+ );
+ };
+
+ 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 = ``;
+ }
+
+ $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 =
-
-
-
{text}
- {buttonsElem =
}
-
-
;
-
- let firstButton;
- if(buttons.length < 1) {
- firstButton = html.remove()}>OK ;
- buttonsElem.appendChild(firstButton);
- } else {
- for(const button of buttons) {
- const buttonElem = { html.remove(); if(typeof button === 'function') button.callback(); }}>
- {button.text}
- ;
- 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 =
+
+
+
{text}
+ {buttonsElem =
}
+
+
;
- return true;
+ let firstButton;
+ if(buttons.length < 1) {
+ firstButton = {
+ html.remove();
+ resolve();
+ }}>OK ;
+ buttonsElem.appendChild(firstButton);
+ } else {
+ for(const button of buttons) {
+ const buttonElem = {
+ html.remove();
+
+ if(typeof button.callback === 'function')
+ button.callback().finally(() => resolve());
+ else
+ resolve();
+ }}>{button.text} ;
+ 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 @@
)
) }}
-
Submit
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 %}
+
+
+
+
+
+
+ {{ container_title('
Writing a message') }}
+
+
+
+
+
+{% 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 %}
+
+
+
+
+
+
+ {{ 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 %}
+
+
+
+
+
+
+ {% if reply_to is defined and reply_to is not empty %}
+
+
+
+
{{ reply_to.info.title }}
+
+
+
{{ reply_to.info.body }}
+
+
+ {% endif %}
+
+
+
+
+
{{ message.info.title }}
+
+ {{ message.info.body|escape|parse_text(message.info.parser)|raw }}
+
+
+ {% 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) }}
+
+
+ {{ draft_info.body|default('') }}
+
+
+
+
+ {{ input_select('parser', constant('\\Misuzu\\Parsers\\Parser::NAMES'), draft_info.parser|default('1'), null, null, null, 'js-messages-reply-parser') }}
+
+
+ Save draft
+ Reply
+
+
+
+
+ {% endif %}
+ {% endif %}
+
+ {% if replies_for is defined and replies_for is iterable %}
+ {% for reply_for in replies_for %}
+
+
+
+
{{ reply_for.info.title }}
+
+
+
{{ reply_for.info.body }}
+
+
+ {% 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 @@
- {% elseif profile_can_edit %}
-
+ {% else %}
+ {% if profile_can_edit %}
+
+ {% endif %}
+ {% if profile_can_send_messages %}
+
+ {% endif %}
{% endif %}
{% else %}