Added private messages.
This commit is contained in:
parent
d8cc208a85
commit
b80151583e
46 changed files with 3143 additions and 156 deletions
|
@ -154,6 +154,9 @@
|
|||
}
|
||||
|
||||
.forum__post__action {
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
display: block;
|
||||
padding: 5px 10px;
|
||||
margin: 1px;
|
||||
color: inherit;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -17,4 +17,5 @@
|
|||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 5px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
|
37
assets/misuzu.css/messages/actions.css
Normal file
37
assets/misuzu.css/messages/actions.css
Normal file
|
@ -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;
|
||||
}
|
26
assets/misuzu.css/messages/columns.css
Normal file
26
assets/misuzu.css/messages/columns.css
Normal file
|
@ -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%;
|
||||
}
|
||||
}
|
80
assets/misuzu.css/messages/entry.css
Normal file
80
assets/misuzu.css/messages/entry.css
Normal file
|
@ -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;
|
||||
}
|
33
assets/misuzu.css/messages/folder.css
Normal file
33
assets/misuzu.css/messages/folder.css
Normal file
|
@ -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;
|
||||
}
|
135
assets/misuzu.css/messages/message.css
Normal file
135
assets/misuzu.css/messages/message.css
Normal file
|
@ -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;
|
||||
}
|
9
assets/misuzu.css/messages/messages.css
Normal file
9
assets/misuzu.css/messages/messages.css
Normal file
|
@ -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;
|
17
assets/misuzu.css/messages/recipient.css
Normal file
17
assets/misuzu.css/messages/recipient.css
Normal file
|
@ -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%;
|
||||
}
|
52
assets/misuzu.css/messages/reply.css
Normal file
52
assets/misuzu.css/messages/reply.css
Normal file
|
@ -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;
|
||||
}
|
11
assets/misuzu.css/messages/sidebar.css
Normal file
11
assets/misuzu.css/messages/sidebar.css
Normal file
|
@ -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;
|
||||
}
|
5
assets/misuzu.css/messages/thread.css
Normal file
5
assets/misuzu.css/messages/thread.css
Normal file
|
@ -0,0 +1,5 @@
|
|||
.messages-thread {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
40
assets/misuzu.js/csrfp.js
Normal file
40
assets/misuzu.js/csrfp.js
Normal file
|
@ -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'));
|
||||
},
|
||||
};
|
||||
})();
|
|
@ -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({
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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(<button class={['forum__post__action', 'forum__post__action--tag', `forum__post__action--${tag.name}`]}
|
||||
type="button" title={tag.summary} onclick={() => $insertTags(textElem, tag.open, tag.close)}>
|
||||
<i class={tag.icon}/>
|
||||
</button>);
|
||||
};
|
||||
|
||||
const renderPreview = async (parser, text) => {
|
||||
|
|
|
@ -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);
|
||||
|
|
89
assets/misuzu.js/messages/actbtn.js
Normal file
89
assets/misuzu.js/messages/actbtn.js
Normal file
|
@ -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;
|
||||
};
|
167
assets/misuzu.js/messages/list.js
Normal file
167
assets/misuzu.js/messages/list.js
Normal file
|
@ -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());
|
||||
},
|
||||
};
|
||||
};
|
385
assets/misuzu.js/messages/messages.js
Normal file
385
assets/misuzu.js/messages/messages.js
Normal file
|
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
56
assets/misuzu.js/messages/recipient.js
Normal file
56
assets/misuzu.js/messages/recipient.js
Normal file
|
@ -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;
|
||||
},
|
||||
};
|
||||
};
|
154
assets/misuzu.js/messages/reply.jsx
Normal file
154
assets/misuzu.js/messages/reply.jsx
Normal file
|
@ -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(<button class="messages-reply-action" type="button" title={tag.summary} onclick={() => $insertTags(bodyElem, tag.open, tag.close)}>
|
||||
<i class={tag.icon}/>
|
||||
</button>);
|
||||
};
|
||||
|
||||
switchButtons(parserSelect.value);
|
||||
|
||||
parserSelect.addEventListener('change', () => {
|
||||
switchButtons(parserSelect.value);
|
||||
});
|
||||
|
||||
// this implementation is godawful but it'll do for now lol
|
||||
// need to make it easier to share the forum's implementation
|
||||
MszEEPROM.init()
|
||||
.catch(() => console.error('Failed to initialise EEPROM'))
|
||||
.then(() => {
|
||||
const eepromClient = new EEPROM(peepApp, `${peepPath}/uploads`, '');
|
||||
const eepromHandleFileUpload = file => {
|
||||
const uploadTask = eepromClient.createUpload(file);
|
||||
|
||||
uploadTask.onFailure = errorInfo => {
|
||||
if(!errorInfo.userAborted)
|
||||
MszShowMessageBox('Was unable to upload file.', 'Upload Error');
|
||||
};
|
||||
|
||||
uploadTask.onComplete = fileInfo => {
|
||||
const parserMode = parseInt(parserSelect.value);
|
||||
let insertText = location.protocol + fileInfo.url;
|
||||
|
||||
if(parserMode == 1) { // bbcode
|
||||
if(fileInfo.isImage())
|
||||
insertText = `[img]${fileInfo.url}[/img]`;
|
||||
else if(fileInfo.isAudio())
|
||||
insertText = `[audio]${fileInfo.url}[/audio]`;
|
||||
else if(fileInfo.isVideo())
|
||||
insertText = `[video]${fileInfo.url}[/video]`;
|
||||
} else if(parserMode == 2) { // markdown
|
||||
if(fileInfo.isMedia())
|
||||
insertText = `![](${fileInfo.url})`;
|
||||
}
|
||||
|
||||
$insertTags(bodyElem, insertText, '');
|
||||
bodyElem.value = bodyElem.value.trim();
|
||||
};
|
||||
|
||||
uploadTask.start();
|
||||
};
|
||||
|
||||
bodyElem.addEventListener('paste', ev => {
|
||||
if(ev.clipboardData && ev.clipboardData.files.length > 0) {
|
||||
ev.preventDefault();
|
||||
|
||||
const files = ev.clipboardData.files;
|
||||
for(const file of files)
|
||||
eepromHandleFileUpload(file);
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('dragenter', ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
});
|
||||
document.body.addEventListener('dragover', ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
});
|
||||
document.body.addEventListener('dragleave', ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
});
|
||||
document.body.addEventListener('drop', ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if(ev.dataTransfer && ev.dataTransfer.files.length > 0) {
|
||||
const files = ev.dataTransfer.files;
|
||||
for(const file of files)
|
||||
eepromHandleFileUpload(file);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
getElement: () => element,
|
||||
setRecipient: userId => {
|
||||
for(const field of form.elements)
|
||||
if(field.name === 'recipient') {
|
||||
field.value = userId;
|
||||
break;
|
||||
}
|
||||
},
|
||||
getHidden: () => element.hidden,
|
||||
setHidden: state => {
|
||||
element.hidden = state;
|
||||
},
|
||||
onSubmit: handler => {
|
||||
if(typeof handler !== 'function')
|
||||
throw 'handler must be a function';
|
||||
|
||||
submitHandler = handler;
|
||||
},
|
||||
};
|
||||
};
|
78
assets/misuzu.js/messages/thread.js
Normal file
78
assets/misuzu.js/messages/thread.js
Normal file
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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 = <div class="messagebox">
|
||||
<div class="container messagebox__container">
|
||||
<div class="container__title">
|
||||
<div class="container__title__background"/>
|
||||
<div class="container__title__text">{title}</div>
|
||||
</div>
|
||||
<div class="container__content">{text}</div>
|
||||
{buttonsElem = <div class="messagebox__buttons"/>}
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
let firstButton;
|
||||
if(buttons.length < 1) {
|
||||
firstButton = <button class="input__button" onclick={() => html.remove()}>OK</button>;
|
||||
buttonsElem.appendChild(firstButton);
|
||||
} else {
|
||||
for(const button of buttons) {
|
||||
const buttonElem = <button class="input__button" onclick={() => { html.remove(); if(typeof button === 'function') button.callback(); }}>
|
||||
{button.text}
|
||||
</button>;
|
||||
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 = <div class="messagebox">
|
||||
<div class="container messagebox__container">
|
||||
<div class="container__title">
|
||||
<div class="container__title__background"/>
|
||||
<div class="container__title__text">{title}</div>
|
||||
</div>
|
||||
<div class="container__content">{text}</div>
|
||||
{buttonsElem = <div class="messagebox__buttons"/>}
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
return true;
|
||||
let firstButton;
|
||||
if(buttons.length < 1) {
|
||||
firstButton = <button class="input__button" onclick={() => {
|
||||
html.remove();
|
||||
resolve();
|
||||
}}>OK</button>;
|
||||
buttonsElem.appendChild(firstButton);
|
||||
} else {
|
||||
for(const button of buttons) {
|
||||
const buttonElem = <button class="input__button" onclick={() => {
|
||||
html.remove();
|
||||
|
||||
if(typeof button.callback === 'function')
|
||||
button.callback().finally(() => resolve());
|
||||
else
|
||||
resolve();
|
||||
}}>{button.text}</button>;
|
||||
buttonsElem.appendChild(buttonElem);
|
||||
|
||||
if(firstButton === undefined)
|
||||
firstButton = buttonElem;
|
||||
}
|
||||
}
|
||||
|
||||
target.appendChild(html);
|
||||
firstButton.focus();
|
||||
});
|
||||
};
|
||||
|
|
56
assets/misuzu.js/parsing.js
Normal file
56
assets/misuzu.js/parsing.js
Normal file
|
@ -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]<text>[/b]', 'fas fa-bold fa-fw'),
|
||||
defineTag('bb-italic', '[i]', '[/i]', 'Italic [i]<text>[/i]', 'fas fa-italic fa-fw'),
|
||||
defineTag('bb-underline', '[u]', '[/u]', 'Underline [u]<text>[/u]', 'fas fa-underline fa-fw'),
|
||||
defineTag('bb-strike', '[s]', '[/s]', 'Strikethrough [s]<text>[/s]', 'fas fa-strikethrough fa-fw'),
|
||||
defineTag('bb-link', '[url=]', '[/url]', 'Link [url]<url>[/url] or [url=<url>]<text>[/url]', 'fas fa-link fa-fw'),
|
||||
defineTag('bb-image', '[img]', '[/img]', 'Image [img]<url>[/img]', 'fas fa-image fa-fw'),
|
||||
defineTag('bb-audio', '[audio]', '[/audio]', 'Audio [audio]<url>[/audio]', 'fas fa-music fa-fw'),
|
||||
defineTag('bb-video', '[video]', '[/video]', 'Video [video]<url>[/video]', 'fas fa-video fa-fw'),
|
||||
defineTag('bb-code', '[code]', '[/code]', 'Code [code]<code>[/code]', 'fas fa-code fa-fw'),
|
||||
defineTag('bb-zalgo', '[zalgo]', '[/zalgo]', 'Zalgo [zalgo]<text>[/zalgo]', 'fas fa-frog fa-fw'),
|
||||
];
|
||||
|
||||
const mdTags = [
|
||||
defineTag('md-bold', '**', '**', 'Bold **<text>**', 'fas fa-bold fa-fw'),
|
||||
defineTag('md-italic', '*', '*', 'Italic *<text>* or _<text>_', 'fas fa-italic fa-fw'),
|
||||
defineTag('md-underline', '__', '__', 'Underline __<text>__', 'fas fa-underline fa-fw'),
|
||||
defineTag('md-strike', '~~', '~~', 'Strikethrough ~~<text>~~', 'fas fa-strikethrough fa-fw'),
|
||||
defineTag('md-link', '[](', ')', 'Link [<text>](<url>)', 'fas fa-link fa-fw'),
|
||||
defineTag('md-image', '![](', ')', 'Image ![<alt text>](<url>)', 'fas fa-image fa-fw'),
|
||||
defineTag('md-audio', '![](', ')', 'Audio ![<alt text>](<url>)', 'fas fa-music fa-fw'),
|
||||
defineTag('md-video', '![](', ')', 'Video ![<alt text>](<url>)', 'fas fa-video fa-fw'),
|
||||
defineTag('md-code', '```', '```', 'Code `<code>` or ```<code>```', '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),
|
||||
};
|
||||
})();
|
|
@ -28,7 +28,7 @@ const MszWatcher = function() {
|
|||
};
|
||||
};
|
||||
|
||||
const MszWatcherCollection = function() {
|
||||
const MszWatchers = function() {
|
||||
const watchers = new Map;
|
||||
|
||||
const getWatcher = name => {
|
||||
|
|
48
database/2024_01_30_233734_create_messages_table.php
Normal file
48
database/2024_01_30_233734_create_messages_table.php
Normal file
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
use Index\Data\IDbConnection;
|
||||
use Index\Data\Migration\IDbMigration;
|
||||
|
||||
final class CreateMessagesTable_20240130_233734 implements IDbMigration {
|
||||
public function migrate(IDbConnection $conn): void {
|
||||
$conn->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;
|
||||
');
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
]);
|
||||
|
|
148
src/Messages/MessageInfo.php
Normal file
148
src/Messages/MessageInfo.php
Normal file
|
@ -0,0 +1,148 @@
|
|||
<?php
|
||||
namespace Misuzu\Messages;
|
||||
|
||||
use Index\DateTime;
|
||||
use Index\Data\IDbResult;
|
||||
use Misuzu\Parsers\Parser;
|
||||
|
||||
class MessageInfo {
|
||||
private string $messageId;
|
||||
private string $ownerId;
|
||||
private ?string $authorId;
|
||||
private ?string $recipientId;
|
||||
private ?string $replyTo;
|
||||
private string $title;
|
||||
private string $body;
|
||||
private int $parser;
|
||||
private int $created;
|
||||
private ?int $sent;
|
||||
private ?int $read;
|
||||
private ?int $deleted;
|
||||
|
||||
public function __construct(IDbResult $result) {
|
||||
$this->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();
|
||||
}
|
||||
}
|
15
src/Messages/MessagesContext.php
Normal file
15
src/Messages/MessagesContext.php
Normal file
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
namespace Misuzu\Messages;
|
||||
|
||||
use Index\Data\IDbConnection;
|
||||
class MessagesContext {
|
||||
private MessagesDatabase $database;
|
||||
|
||||
public function __construct(IDbConnection $dbConn) {
|
||||
$this->database = new MessagesDatabase($dbConn);
|
||||
}
|
||||
|
||||
public function getDatabase(): MessagesDatabase {
|
||||
return $this->database;
|
||||
}
|
||||
}
|
399
src/Messages/MessagesDatabase.php
Normal file
399
src/Messages/MessagesDatabase.php
Normal file
|
@ -0,0 +1,399 @@
|
|||
<?php
|
||||
namespace Misuzu\Messages;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Index\DateTime;
|
||||
use Index\Data\{DbStatementCache,DbTools,IDbConnection};
|
||||
use Misuzu\Pagination;
|
||||
use Misuzu\Users\UserInfo;
|
||||
|
||||
class MessagesDatabase {
|
||||
private DbStatementCache $cache;
|
||||
|
||||
public function __construct(private IDbConnection $dbConn) {
|
||||
$this->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();
|
||||
}
|
||||
}
|
601
src/Messages/MessagesRoutes.php
Normal file
601
src/Messages/MessagesRoutes.php
Normal file
|
@ -0,0 +1,601 @@
|
|||
<?php
|
||||
namespace Misuzu\Messages;
|
||||
|
||||
use stdClass;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Index\XString;
|
||||
use Index\Routing\{Route,RouteHandler};
|
||||
use Syokuhou\IConfig;
|
||||
use Misuzu\{CSRF,Pagination,Perm,Template};
|
||||
use Misuzu\Auth\AuthInfo;
|
||||
use Misuzu\Parsers\Parser;
|
||||
use Misuzu\URLs\{URLInfo,URLRegistry};
|
||||
use Misuzu\Users\UsersContext;
|
||||
|
||||
class MessagesRoutes extends RouteHandler {
|
||||
public const FOLDER_META = [
|
||||
'inbox' => [ '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' => '<folder>', 'page' => '<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' => '<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/<message>')]
|
||||
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/<message>')]
|
||||
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 [];
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
20
src/Perm.php
20
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.',
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -7,17 +7,17 @@
|
|||
{% set browser_title = globals.site_info.name %}
|
||||
{% endif %}
|
||||
|
||||
<title>{{ browser_title }}</title>
|
||||
<title>{{ browser_title }}</title>
|
||||
|
||||
<meta property="og:title" content="{{ title|default(globals.site_info.name) }}">
|
||||
<meta property="og:site_name" content="{{ globals.site_info.name }}">
|
||||
<meta property="og:title" content="{{ title|default(globals.site_info.name) }}">
|
||||
<meta property="og:site_name" content="{{ globals.site_info.name }}">
|
||||
|
||||
{% if description|length > 0 %}
|
||||
<meta name="description" content="{{ description }}">
|
||||
<meta property="og:description" content="{{ description }}">
|
||||
{% endif %}
|
||||
|
||||
<meta property="og:type" content="object">
|
||||
<meta property="og:type" content="object">
|
||||
|
||||
{% if image is defined %}
|
||||
{% if image|slice(0, 1) == '/' %}
|
||||
|
|
|
@ -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 %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<textarea name="post[text]" class="forum__post__text forum__post__text--edit js-forum-posting-text js-ctrl-enter-submit" placeholder="Type your post content here...">{{ posting_defaults.text|default(posting_post.info.body|default('')) }}</textarea>
|
||||
<div class="forum__post__text js-forum-posting-preview" hidden></div>
|
||||
|
||||
<div class="forum__post__actions forum__post__actions--bbcode" hidden>
|
||||
<div class="forum__post__action forum__post__action--tag forum__post__action--bb-bold js-forum-posting-markup" title="Bold [b]<text>[/b]" data-tag-open="[b]" data-tag-close="[/b]">
|
||||
<i class="fas fa-bold fa-fw"></i>
|
||||
</div>
|
||||
<div class="forum__post__action forum__post__action--tag forum__post__action--bb-italic js-forum-posting-markup" title="Italic [i]<text>[/i]" data-tag-open="[i]" data-tag-close="[/i]">
|
||||
<i class="fas fa-italic fa-fw"></i>
|
||||
</div>
|
||||
<div class="forum__post__action forum__post__action--tag forum__post__action--bb-underline js-forum-posting-markup" title="Underline [u]<text>[/u]" data-tag-open="[u]" data-tag-close="[/u]">
|
||||
<i class="fas fa-underline fa-fw"></i>
|
||||
</div>
|
||||
<div class="forum__post__action forum__post__action--tag forum__post__action--bb-strike js-forum-posting-markup" title="Strikethrough [s]<text>[/s]" data-tag-open="[s]" data-tag-close="[/s]">
|
||||
<i class="fas fa-strikethrough fa-fw"></i>
|
||||
</div>
|
||||
<div class="forum__post__action forum__post__action--tag forum__post__action--bb-link js-forum-posting-markup" title="Link [url]<url>[/url] or [url=<url>]<text>[/url]" data-tag-open="[url=]" data-tag-close="[/url]">
|
||||
<i class="fas fa-link fa-fw"></i>
|
||||
</div>
|
||||
<div class="forum__post__action forum__post__action--tag forum__post__action--bb-image js-forum-posting-markup" title="Image [img]<url>[/img]" data-tag-open="[img]" data-tag-close="[/img]">
|
||||
<i class="fas fa-image fa-fw"></i>
|
||||
</div>
|
||||
<div class="forum__post__action forum__post__action--tag forum__post__action--bb-audio js-forum-posting-markup" title="Audio [audio]<url>[/url]" data-tag-open="[audio]" data-tag-close="[/audio]">
|
||||
<i class="fas fa-music fa-fw"></i>
|
||||
</div>
|
||||
<div class="forum__post__action forum__post__action--tag forum__post__action--bb-video js-forum-posting-markup" title="Video [video]<url>[/video]" data-tag-open="[video]" data-tag-close="[/video]">
|
||||
<i class="fas fa-video fa-fw"></i>
|
||||
</div>
|
||||
<div class="forum__post__action forum__post__action--tag forum__post__action--bb-code js-forum-posting-markup" title="Code [code]<code>[/code]" data-tag-open="[code]" data-tag-close="[/code]">
|
||||
<i class="fas fa-code fa-fw"></i>
|
||||
</div>
|
||||
<div class="forum__post__action forum__post__action--tag forum__post__action--bb-zalgo" title="Zalgo [zalgo]<text>[/zalgo]" data-tag-open="[zalgo]" data-tag-close="[/zalgo]">
|
||||
<i class="fas fa-frog fa-fw"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="forum__post__actions forum__post__actions--markdown" hidden>
|
||||
<div class="forum__post__action forum__post__action--tag forum__post__action--md-bold js-forum-posting-markup" title="Bold **<text>**" data-tag-open="**" data-tag-close="**">
|
||||
<i class="fas fa-bold fa-fw"></i>
|
||||
</div>
|
||||
<div class="forum__post__action forum__post__action--tag forum__post__action--md-italic js-forum-posting-markup" title="Italic *<text>* or _<text>_" data-tag-open="*" data-tag-close="*">
|
||||
<i class="fas fa-italic fa-fw"></i>
|
||||
</div>
|
||||
<div class="forum__post__action forum__post__action--tag forum__post__action--md-underline js-forum-posting-markup" title="Underline __<text>__" data-tag-open="__" data-tag-close="__">
|
||||
<i class="fas fa-underline fa-fw"></i>
|
||||
</div>
|
||||
<div class="forum__post__action forum__post__action--tag forum__post__action--md-strike js-forum-posting-markup" title="Strikethrough ~~<text>~~" data-tag-open="~~" data-tag-close="~~">
|
||||
<i class="fas fa-strikethrough fa-fw"></i>
|
||||
</div>
|
||||
<div class="forum__post__action forum__post__action--tag forum__post__action--md-link js-forum-posting-markup" title="Link [<text>](<url>)" data-tag-open="[](" data-tag-close=")">
|
||||
<i class="fas fa-link fa-fw"></i>
|
||||
</div>
|
||||
<div class="forum__post__action forum__post__action--tag forum__post__action--md-image js-forum-posting-markup" title="Image ![<alt text>](<url>)" data-tag-open="![](" data-tag-close=")">
|
||||
<i class="fas fa-image fa-fw"></i>
|
||||
</div>
|
||||
<div class="forum__post__action forum__post__action--tag forum__post__action--md-audio js-forum-posting-markup" title="Audio ![<alt text>](<url>)" data-tag-open="![](" data-tag-close=")">
|
||||
<i class="fas fa-music fa-fw"></i>
|
||||
</div>
|
||||
<div class="forum__post__action forum__post__action--tag forum__post__action--md-video js-forum-posting-markup" title="Video ![<alt text>](<url>)" data-tag-open="![](" data-tag-close=")">
|
||||
<i class="fas fa-video fa-fw"></i>
|
||||
</div>
|
||||
<div class="forum__post__action forum__post__action--tag forum__post__action--md-code js-forum-posting-markup" title="Code `<code>` or ```<code>```" data-tag-open="```" data-tag-close="```">
|
||||
<i class="fas fa-code fa-fw"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="forum__post__actions js-forum-posting-actions"></div>
|
||||
<div class="forum__post__options">
|
||||
<div class="forum__post__settings">
|
||||
{{ input_select(
|
||||
|
@ -167,7 +103,6 @@
|
|||
)
|
||||
) }}
|
||||
</div>
|
||||
|
||||
<div class="forum__post__buttons js-forum-posting-buttons">
|
||||
<button class="input__button" onclick="MszForumEditorAllowClose = true;">Submit</button>
|
||||
</div>
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
{% include '_layout/meta.twig' %}
|
||||
<meta name="csrfp-token" content="{{ csrf_token() }}">
|
||||
<link href="/vendor/fontawesome/css/all.min.css" type="text/css" rel="stylesheet">
|
||||
<link href="{{ asset('misuzu.css') }}" type="text/css" rel="stylesheet">
|
||||
{% if site_background is defined %}
|
||||
|
@ -43,7 +44,7 @@
|
|||
{% endif %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="margin: 2px 0; padding: 2px 5px;">
|
||||
<div class="container">
|
||||
This page is empty, populate it.
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
63
templates/messages/compose.twig
Normal file
63
templates/messages/compose.twig
Normal file
|
@ -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 %}
|
||||
<div class="messages-columns js-messages-compose">
|
||||
<div class="messages-columns-sidebar">
|
||||
<div class="messages-sidebar">
|
||||
<div class="messages-sidebar-button">
|
||||
<a class="input__button" href="{{ url('messages-index') }}">Return</a>
|
||||
</div>
|
||||
|
||||
<div class="container messages-sidebar-section">
|
||||
{{ container_title('<i class="fas fa-address-card fa-fw"></i> Recipient') }}
|
||||
|
||||
<div class="messages-recipient js-messages-recipient" data-msg-lookup="{{ url('messages-recipient') }}">
|
||||
<div class="messages-recipient-avatar js-messages-recipient-avatar">
|
||||
{{ avatar(0, 100) }}
|
||||
</div>
|
||||
<div class="messages-recipient-name">
|
||||
{{ input_text('name', 'messages-recipient-name-input js-messages-recipient-name', recipient, 'text', 'Recipient name') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="warning">
|
||||
<div class="warning__content">
|
||||
<p>UI is VERY not final. It will be not awful before 2025 I promise for real this time!!!</p>
|
||||
<p>I need to clean up a lot of code first because a lot of things are specifically written for the forum editor and it will become a big mess otherwise.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages-columns-content">
|
||||
<div class="container messages-reply messages-reply-compose js-messages-reply">
|
||||
{{ container_title('<i class="fas fa-pencil-alt"></i> Writing a message') }}
|
||||
|
||||
<form class="messages-reply-form js-messages-reply-form">
|
||||
{{ input_hidden('recipient', '') }}
|
||||
<div class="messages-reply-subject">
|
||||
{{ input_text('title', 'messages-reply-subject-input', '', 'text', 'Subject', true) }}
|
||||
</div>
|
||||
<div class="messages-reply-body">
|
||||
<textarea name="body" placeholder="Write your reply here... Press Ctrl+Enter to send your reply Press Ctrl+Shift+Enter to save a draft" class="input__textarea messages-reply-body-input js-messages-reply-body"></textarea>
|
||||
</div>
|
||||
<div class="messages-reply-actions js-messages-reply-actions" hidden></div>
|
||||
<div class="messages-reply-options">
|
||||
<div class="messages-reply-settings">
|
||||
{{ input_select('parser', constant('\\Misuzu\\Parsers\\Parser::NAMES'), '1', null, null, null, 'js-messages-reply-parser') }}
|
||||
</div>
|
||||
<div class="messages-reply-buttons">
|
||||
<button class="input__button js-messages-reply-save" name="draft" value="1">Save draft</button>
|
||||
<button class="input__button js-messages-reply-send" name="draft" value="0">Reply</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
89
templates/messages/index.twig
Normal file
89
templates/messages/index.twig
Normal file
|
@ -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 %}
|
||||
<div class="messages-columns">
|
||||
<div class="messages-columns-sidebar">
|
||||
<div class="messages-sidebar">
|
||||
{% if can_send_messages %}
|
||||
<div class="messages-sidebar-button">
|
||||
<a class="input__button" href="{{ url('messages-compose') }}">New Message</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="container messages-sidebar-section messages-actions">
|
||||
{{ container_title('<i class="fas fa-folder fa-fw"></i> Folders') }}
|
||||
|
||||
{% for name, meta in folder_meta %}
|
||||
<a class="{{ html_classes('messages-actions-item', {'messages-actions-item-current': folder_name == name}) }}" href="{{ url('messages-index', {folder: name == 'inbox' ? '' : name}) }}">
|
||||
<div class="messages-actions-item-icon"><i class="{{ meta.icon }}"></i></div>
|
||||
<div class="messages-actions-item-label">{{ meta.title }}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="container messages-sidebar-section messages-actions">
|
||||
{{ container_title('<i class="fas fa-comments fa-fw"></i> Actions') }}
|
||||
|
||||
<button class="messages-actions-item js-messages-actions-select-all" data-state="inactive" data-inactive-str="Select all" data-inactive-ico="far fa-check-square fa-fw" data-active-str="Unselect all" data-active-ico="far fa-square fa-fw">
|
||||
<div class="messages-actions-item-icon js-messages-button-icon"><i></i></div>
|
||||
<div class="messages-actions-item-label js-messages-button-label"></div>
|
||||
</button>
|
||||
{% if folder_name == 'inbox' %}
|
||||
<button class="messages-actions-item js-messages-actions-mark-read" data-state="inactive" data-inactive-str="Mark as read" data-inactive-ico="fas fa-envelope-open fa-fw" data-active-str="Mark as unread" data-active-ico="fas fa-envelope">
|
||||
<div class="messages-actions-item-icon js-messages-button-icon"><i></i></div>
|
||||
<div class="messages-actions-item-label js-messages-button-label"></div>
|
||||
</button>
|
||||
{% endif %}
|
||||
<button class="messages-actions-item js-messages-actions-move-trash" data-state="{{ folder_name == 'trash' ? 'active' : 'inactive' }}" data-inactive-str="Move to Trash" data-inactive-ico="fas fa-trash-alt fa-fw" data-active-str="Restore item" data-active-ico="fas fa-trash-restore-alt">
|
||||
<div class="messages-actions-item-icon js-messages-button-icon"><i></i></div>
|
||||
<div class="messages-actions-item-label js-messages-button-label"></div>
|
||||
</button>
|
||||
{% if folder_name == 'trash' %}
|
||||
<button class="messages-actions-item js-messages-actions-nuke">
|
||||
<div class="messages-actions-item-icon"><i class="fas fa-radiation-alt"></i></div>
|
||||
<div class="messages-actions-item-label">Permanently delete</div>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages-columns-content">
|
||||
<div class="container">
|
||||
{{ container_title('<i class="%s"></i> %s'|format(folder_meta[folder_name].icon, folder_meta[folder_name].title)) }}
|
||||
|
||||
<div class="messages-folder js-messages-list">
|
||||
<div class="messages-folder-notice js-messages-folder-empty"{% if folder_messages is not empty %} hidden{% endif %}>
|
||||
<div class="messages-folder-notice-icon"><i class="fas fa-tenge fa-fw fa-4x"></i></div>
|
||||
<div class="messages-folder-notice-text">There are no messages to display.</div>
|
||||
</div>
|
||||
{% 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) %}
|
||||
<div class="messages-folder-item messages-entry js-messages-entry" tabindex="0" data-msg-id="{{ message.info.id }}" data-msg-url="{{ url('messages-view', {message: message.info.id}) }}" data-msg-sent="{{ message.info.isSent ? 'sent' : 'draft' }}" data-msg-read="{{ message.info.isRead ? 'read' : 'unread' }}" style="--user-colour: {{ user_colour }};">
|
||||
<div class="messages-entry-header">
|
||||
<div class="messages-entry-check"><input type="checkbox" class="js-entry-checkbox"></div>
|
||||
<div class="messages-entry-unread js-messages-entry-unread"{% if not message.info.isSent or message.info.isRead %} hidden{% endif %}><div class="messages-entry-unread-orb"></div></div>
|
||||
<div class="messages-entry-author"><div class="messages-entry-overflow">{{ user_info.name|default('Deleted User') }}</div></div>
|
||||
<div class="messages-entry-spacing"></div>
|
||||
<div class="messages-entry-datetime"><time datetime="{{ message.info.displayTime|date('c') }}" title="{{ message.info.displayTime|date('r') }}">{{ message.info.displayTime|time_format }}</time></div>
|
||||
</div>
|
||||
<div class="messages-entry-subject">
|
||||
<div class="messages-entry-overflow">{{ message.info.title }}</div>
|
||||
</div>
|
||||
<div class="messages-entry-preview">
|
||||
<div class="messages-entry-overflow">{{ message.info.body }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{{ pagination(folder_pagination, 'messages-index', {folder: (folder_name == 'index' ? '' : folder_name)}) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
12
templates/messages/master.twig
Normal file
12
templates/messages/master.twig
Normal file
|
@ -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 %}
|
||||
<script type="text/javascript">
|
||||
const peepPath = '{{ globals.eeprom_path }}', peepApp = '{{ globals.eeprom_app_messages }}';
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
179
templates/messages/thread.twig
Normal file
179
templates/messages/thread.twig
Normal file
|
@ -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 %}
|
||||
<div class="messages-columns">
|
||||
<div class="messages-columns-sidebar">
|
||||
<div class="messages-sidebar">
|
||||
<div class="messages-sidebar-button">
|
||||
<a class="input__button" href="{{ url('messages-index') }}">Back</a>
|
||||
</div>
|
||||
|
||||
<div class="container messages-sidebar-section messages-actions">
|
||||
{{ container_title('<i class="fas fa-comments fa-fw"></i> Actions') }}
|
||||
|
||||
<button class="messages-actions-item js-messages-actions-mark-read"{% if not message.info.isSent %} hidden{% endif %} data-state="active" data-inactive-str="Mark as read" data-inactive-ico="fas fa-envelope-open fa-fw" data-active-str="Mark as unread" data-active-ico="fas fa-envelope">
|
||||
<div class="messages-actions-item-icon js-messages-button-icon"><i></i></div>
|
||||
<div class="messages-actions-item-label js-messages-button-label"></div>
|
||||
</button>
|
||||
<button class="messages-actions-item js-messages-actions-move-trash" data-state="{{ message.info.isDeleted ? 'active' : 'inactive' }}" data-inactive-str="Move to Trash" data-inactive-ico="fas fa-trash-alt fa-fw" data-active-str="Restore item" data-active-ico="fas fa-trash-restore-alt">
|
||||
<div class="messages-actions-item-icon js-messages-button-icon"><i></i></div>
|
||||
<div class="messages-actions-item-label js-messages-button-label"></div>
|
||||
</button>
|
||||
<button class="messages-actions-item js-messages-actions-nuke"{% if not message.info.isDeleted %} hidden{% endif %}>
|
||||
<div class="messages-actions-item-icon"><i class="fas fa-radiation-alt"></i></div>
|
||||
<div class="messages-actions-item-label">Permanently delete</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages-columns-content">
|
||||
<div class="messages-thread js-messages-thread">
|
||||
{% if reply_to is defined and reply_to is not empty %}
|
||||
<article class="{{ html_classes('container', 'messages-message', 'messages-message-snippet', 'js-messages-message', {'messages-message-deleted': reply_to.info.isDeleted, 'messages-message-draft': not reply_to.info.isSent}) }}" tabindex="0" data-msg-id="{{ reply_to.info.id }}" data-msg-url="{{ url('messages-view', {message: reply_to.info.id}) }}" data-msg-type="snip" data-msg-sent="{{ reply_to.info.isSent ? 'sent' : 'draft' }}" data-msg-read="{{ reply_to.info.isRead ? 'read' : 'unread' }}">
|
||||
<div class="messages-message-header">
|
||||
<div class="messages-message-sender-avatar">
|
||||
{{ avatar(reply_to.author_info.id|default(0), 80) }}
|
||||
</div>
|
||||
<div class="messages-message-details">
|
||||
<div class="messages-message-header-columns">
|
||||
<div class="messages-message-sender-name" style="--user-colour: {{ reply_to.author_colour }};">
|
||||
<a href="{{ url('user-profile', {user: reply_to.author_info.id|default(0)}) }}" class="messages-message-overflow">{{ reply_to.author_info.name|default('Deleted User') }}</a>
|
||||
</div>
|
||||
<div class="messages-message-details-spacing"></div>
|
||||
<div class="messages-message-datetime">
|
||||
<time datetime="{{ reply_to.info.displayTime|date('c') }}" title="{{ reply_to.info.displayTime|date('r') }}">{{ reply_to.info.displayTime|time_format }}</time>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages-message-addressee">
|
||||
<div class="messages-message-addressee-to">To: </div>
|
||||
<div class="messages-message-addressee-user" style="--user-colour: {{ reply_to.recipient_colour }};">
|
||||
<a href="{{ url('user-profile', {user: reply_to.recipient_info.id|default(0)}) }}" class="messages-message-overflow">{{ reply_to.recipient_info.name|default('Deleted User') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages-message-subject">
|
||||
<h1>{{ reply_to.info.title }}</h1>
|
||||
</div>
|
||||
<div class="messages-message-snippet-body">
|
||||
<p>{{ reply_to.info.body }}</p>
|
||||
</div>
|
||||
</article>
|
||||
{% endif %}
|
||||
|
||||
<article class="{{ html_classes('container', 'messages-message', 'js-messages-message', {'messages-message-deleted': message.info.isDeleted, 'messages-message-draft': not message.info.isSent}) }}" data-msg-id="{{ message.info.id }}" data-msg-url="{{ url('messages-view', {message: message.info.id}) }}" data-msg-type="full" data-msg-sent="{{ message.info.isSent ? 'sent' : 'draft' }}" data-msg-read="{{ message.info.isRead ? 'read' : 'unread' }}" data-msg-deleted="{{ message.info.isDeleted ? 'yes' : 'no' }}">
|
||||
<div class="messages-message-header">
|
||||
<div class="messages-message-sender-avatar">
|
||||
{{ avatar(message.author_info.id|default(0), 80) }}
|
||||
</div>
|
||||
<div class="messages-message-details">
|
||||
<div class="messages-message-header-columns">
|
||||
<div class="messages-message-sender-name" style="--user-colour: {{ message.author_colour }};">
|
||||
<a href="{{ url('user-profile', {user: message.author_info.id|default(0)}) }}" class="messages-message-overflow">{{ message.author_info.name|default('Deleted User') }}</a>
|
||||
</div>
|
||||
<div class="messages-message-details-spacing"></div>
|
||||
<div class="messages-message-datetime">
|
||||
<time datetime="{{ message.info.displayTime|date('c') }}" title="{{ message.info.displayTime|date('r') }}">{{ message.info.displayTime|time_format }}</time>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages-message-addressee">
|
||||
<div class="messages-message-addressee-to">To: </div>
|
||||
<div class="messages-message-addressee-user" style="--user-colour: {{ message.recipient_colour }};">
|
||||
<a href="{{ url('user-profile', {user: message.recipient_info.id|default(0)}) }}" class="messages-message-overflow">{{ message.recipient_info.name|default('Deleted User') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages-message-subject">
|
||||
<h1>{{ message.info.title }}</h1>
|
||||
</div>
|
||||
<div class="messages-message-body{% if message.info.isBodyMarkdown %} markdown{% endif %}">{{ message.info.body|escape|parse_text(message.info.parser)|raw }}</div>
|
||||
</article>
|
||||
|
||||
{% 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) %}
|
||||
<div class="container messages-reply js-messages-reply"{% if (reply_field_is_draft ? draft_info.isDeleted : message.info.isDeleted) %} hidden{% endif %}>
|
||||
{{ container_title(has_draft_info ? '<i class="fas fa-edit"></i> Edit' : '<i class="fas fa-reply"></i> Reply') }}
|
||||
|
||||
<form class="messages-reply-form js-messages-reply-form">
|
||||
{% 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 %}
|
||||
<div class="messages-reply-subject">
|
||||
{{ 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) }}
|
||||
</div>
|
||||
<div class="messages-reply-body">
|
||||
<textarea name="body" placeholder="Write your reply here... Press Ctrl+Enter to send your reply Press Ctrl+Shift+Enter to save a draft" class="input__textarea messages-reply-body-input js-messages-reply-body">{{ draft_info.body|default('') }}</textarea>
|
||||
</div>
|
||||
<div class="messages-reply-actions js-messages-reply-actions" hidden></div>
|
||||
<div class="messages-reply-options">
|
||||
<div class="messages-reply-settings">
|
||||
{{ input_select('parser', constant('\\Misuzu\\Parsers\\Parser::NAMES'), draft_info.parser|default('1'), null, null, null, 'js-messages-reply-parser') }}
|
||||
</div>
|
||||
<div class="messages-reply-buttons">
|
||||
<button class="input__button js-messages-reply-save" name="draft" value="1">Save draft</button>
|
||||
<button class="input__button js-messages-reply-send" name="draft" value="0">Reply</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if replies_for is defined and replies_for is iterable %}
|
||||
{% for reply_for in replies_for %}
|
||||
<article class="{{ html_classes('container', 'messages-message', 'messages-message-snippet', 'js-messages-message', {'messages-message-deleted': reply_for.info.isDeleted, 'messages-message-draft': not reply_for.info.isSent}) }}" tabindex="0" data-msg-id="{{ reply_for.info.id }}" data-msg-url="{{ url('messages-view', {message: reply_for.info.id}) }}" data-msg-type="snip" data-msg-sent="{{ reply_for.info.isSent ? 'sent' : 'draft' }}" data-msg-read="{{ reply_for.info.isRead ? 'read' : 'unread' }}">
|
||||
<div class="messages-message-header">
|
||||
<div class="messages-message-sender-avatar">
|
||||
{{ avatar(reply_for.author_info.id|default(0), 80) }}
|
||||
</div>
|
||||
<div class="messages-message-details">
|
||||
<div class="messages-message-header-columns">
|
||||
<div class="messages-message-sender-name" style="--user-colour: {{ reply_for.author_colour }};">
|
||||
<a href="{{ url('user-profile', {user: reply_for.author_info.id|default(0)}) }}" class="messages-message-overflow">{{ reply_for.author_info.name|default('Deleted User') }}</a>
|
||||
</div>
|
||||
<div class="messages-message-details-spacing"></div>
|
||||
<div class="messages-message-datetime">
|
||||
<time datetime="{{ reply_for.info.displayTime|date('c') }}" title="{{ reply_for.info.displayTime|date('r') }}">{{ reply_for.info.displayTime|time_format }}</time>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages-message-addressee">
|
||||
<div class="messages-message-addressee-to">To: </div>
|
||||
<div class="messages-message-addressee-user" style="--user-colour: {{ reply_for.recipient_colour }};">
|
||||
<a href="{{ url('user-profile', {user: reply_for.recipient_info.id|default(0) }) }}" class="messages-message-overflow">{{ reply_for.recipient_info.name|default('Deleted User') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages-message-subject">
|
||||
<h1>{{ reply_for.info.title }}</h1>
|
||||
</div>
|
||||
<div class="messages-message-snippet-body">
|
||||
<p>{{ reply_for.info.body }}</p>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -71,8 +71,13 @@
|
|||
<button class="input__button input__button--save profile__header__action">Save</button>
|
||||
<a href="{{ url('user-profile', {'user': profile_user.id}) }}" class="input__button input__button--destroy profile__header__action">Discard</a>
|
||||
<a href="{{ url('settings-index') }}" class="input__button profile__header__action">Settings</a>
|
||||
{% elseif profile_can_edit %}
|
||||
<a href="{{ url('user-profile-edit', {'user': profile_user.id}) }}" class="input__button profile__header__action">Edit Profile</a>
|
||||
{% else %}
|
||||
{% if profile_can_edit %}
|
||||
<a href="{{ url('user-profile-edit', {'user': profile_user.id}) }}" class="input__button profile__header__action">Edit Profile</a>
|
||||
{% endif %}
|
||||
{% if profile_can_send_messages %}
|
||||
<a href="{{ url('messages-compose', {'recipient': profile_user.name}) }}" class="input__button profile__header__action">Send Message</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<a href="{{ url('user-profile', {'user': profile_user.id}) }}" class="input__button profile__header__action">Return</a>
|
||||
|
|
Loading…
Reference in a new issue