diff --git a/assets/common.css/loading.css b/assets/common.css/loading.css new file mode 100644 index 00000000..443a8507 --- /dev/null +++ b/assets/common.css/loading.css @@ -0,0 +1,31 @@ +.msz-loading { + display: flex; + justify-content: center; + flex-direction: column; + min-width: var(--msz-loading-container-width, calc(var(--msz-loading-size, 1) * 100px)); + min-height: var(--msz-loading-container-height, calc(var(--msz-loading-size, 1) * 100px)); +} + +.msz-loading-frame { + display: flex; + justify-content: center; + flex: 0 0 auto; +} + +.msz-loading-icon { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(3, 1fr); + gap: var(--msz-loading-gap, calc(var(--msz-loading-size, 1) * 1px)); + margin: var(--msz-loading-margin, calc(var(--msz-loading-size, 1) * 10px)); +} + +.msz-loading-icon-block { + background: var(--msz-loading-colour, currentColor); + width: var(--msz-loading-width, calc(var(--msz-loading-size, 1) * 10px)); + height: var(--msz-loading-height, calc(var(--msz-loading-size, 1) * 10px)); +} + +.msz-loading-icon-block-hidden { + opacity: 0; +} diff --git a/assets/common.css/main.css b/assets/common.css/main.css index 1dcafd4a..d9bccaf6 100644 --- a/assets/common.css/main.css +++ b/assets/common.css/main.css @@ -20,3 +20,5 @@ html, body { --font-regular: Verdana, Geneva, 'Dejavu Sans', Arial, Helvetica, sans-serif; --font-monospace: Consolas, 'Liberation Mono', Menlo, Courier, monospace; } + +@include loading.css; diff --git a/assets/common.js/html.js b/assets/common.js/html.js index b1fb2caf..09aef2cf 100644 --- a/assets/common.js/html.js +++ b/assets/common.js/html.js @@ -66,8 +66,22 @@ const $create = function(info, attrs, child, created) { elem.setAttribute(key, attr.toString()); } } else { - for(const attrKey in attr) - elem[key][attrKey] = attr[attrKey]; + if(key === 'class' || key === 'className') + key = 'classList'; + + let setFunc = null; + if(elem[key] instanceof DOMTokenList) + setFunc = (ak, av) => { if(av) elem[key].add(ak); }; + else if(elem[key] instanceof CSS2Properties) + setFunc = (ak, av) => { elem[key].setProperty(ak, av); } + else + setFunc = (ak, av) => { elem[key][ak] = av; }; + + for(const attrKey in attr) { + const attrValue = attr[attrKey]; + if(attrValue) + setFunc(attrKey, attrValue); + } } break; @@ -93,11 +107,17 @@ const $create = function(info, attrs, child, created) { for(const child of children) { switch(typeof child) { + case 'undefined': + break; + case 'string': elem.appendChild(document.createTextNode(child)); break; case 'object': + if(child === null) + break; + if(child instanceof Element) { elem.appendChild(child); } else if('element' in child) { diff --git a/assets/oauth2.js/loading.jsx b/assets/common.js/loading.jsx similarity index 52% rename from assets/oauth2.js/loading.jsx rename to assets/common.js/loading.jsx index 96f0e11a..515cf231 100644 --- a/assets/oauth2.js/loading.jsx +++ b/assets/common.js/loading.jsx @@ -1,7 +1,7 @@ -const MszOAuth2LoadingIcon = function() { - const element = <div class="oauth2-loading-icon"/>; +const MszLoadingIcon = function() { + const element = <div class="msz-loading-icon"/>; for(let i = 0; i < 9; ++i) - element.appendChild(<div class="oauth2-loading-icon-block"/>); + element.appendChild(<div class="msz-loading-icon-block"/>); // this is moderately cursed but it'll do const blocks = [ @@ -26,7 +26,7 @@ const MszOAuth2LoadingIcon = function() { tsLastUpdate = tsCurrent; for(let i = 0; i < blocks.length; ++i) - blocks[(counter + i) % blocks.length].classList.toggle('oauth2-loading-icon-block-hidden', i < 3); + blocks[(counter + i) % blocks.length].classList.toggle('msz-loading-icon-block-hidden', i < 3); ++counter; } finally { @@ -56,20 +56,49 @@ const MszOAuth2LoadingIcon = function() { }; }; -const MszOAuth2Loading = function(element) { +const MszLoading = function(options=null) { + if(typeof options !== 'object') + throw 'options must be an object'; + + let { + element, size, colour, + width, height, + containerWidth, containerHeight, + gap, margin, hidden, + } = options ?? {}; + if(typeof element === 'string') element = document.querySelector(element); if(!(element instanceof HTMLElement)) - element = <div class="oauth2-loading"/>; + element = <div class="msz-loading"/>; - if(!element.classList.contains('oauth2-loading')) - element.classList.add('oauth2-loading'); + if(!element.classList.contains('msz-loading')) + element.classList.add('msz-loading'); + if(hidden) + element.classList.add('hidden'); + + if(typeof size === 'number' && size > 0) + element.style.setProperty('--msz-loading-size', size); + if(typeof containerWidth === 'string') + element.style.setProperty('--msz-loading-container-width', containerWidth); + if(typeof containerHeight === 'string') + element.style.setProperty('--msz-loading-container-height', containerHeight); + if(typeof gap === 'string') + element.style.setProperty('--msz-loading-gap', gap); + if(typeof margin === 'string') + element.style.setProperty('--msz-loading-margin', margin); + if(typeof width === 'string') + element.style.setProperty('--msz-loading-width', width); + if(typeof height === 'string') + element.style.setProperty('--msz-loading-height', height); + if(typeof colour === 'string') + element.style.setProperty('--msz-loading-colour', colour); let icon; if(element.childElementCount < 1) { - icon = new MszOAuth2LoadingIcon; + icon = new MszLoadingIcon; icon.play(); - element.appendChild(<div class="oauth2-loading-frame">{icon}</div>); + element.appendChild(<div class="msz-loading-frame">{icon}</div>); } return { diff --git a/assets/common.js/main.js b/assets/common.js/main.js index d8d77481..dd5c0896 100644 --- a/assets/common.js/main.js +++ b/assets/common.js/main.js @@ -3,3 +3,5 @@ #include html.js #include uniqstr.js #include xhr.js + +#include loading.jsx diff --git a/assets/common.js/xhr.js b/assets/common.js/xhr.js index 4940aff0..1fd5566a 100644 --- a/assets/common.js/xhr.js +++ b/assets/common.js/xhr.js @@ -80,7 +80,7 @@ const $xhr = (function() { return headers; })(xhr.getAllResponseHeaders()); - if(options.csrf && headers.has('x-csrf-token')) + if(headers.has('x-csrf-token')) $csrf.token = headers.get('x-csrf-token'); resolve({ diff --git a/assets/misuzu.css/comments/comment.css b/assets/misuzu.css/comments/comment.css deleted file mode 100644 index 671b5d34..00000000 --- a/assets/misuzu.css/comments/comment.css +++ /dev/null @@ -1,159 +0,0 @@ -.comment { - margin: 10px; -} -.comment__reply-toggle { - display: none; -} -.comment__reply-toggle:checked ~ .comment--reply { - display: block; -} - -.comment--reply { - display: none; -} - -.comment--deleted > .comment__container { - opacity: .5; - transition: opacity .2s; -} -.comment--deleted > .comment__container:hover { - opacity: .9; -} - -.comment__container { - display: flex; - margin-bottom: 3px; -} - -.comment__mention { - color: var(--user-colour); - text-decoration: none; - font-weight: 700; -} -.comment__mention:hover { - text-decoration: underline; -} - -.comment__actions { - list-style: none; - display: flex; - font-size: .9em; - align-items: center; -} -.comment__action { - color: inherit; - text-decoration: none; - vertical-align: middle; - cursor: pointer; -} - -.comment__action:not(:last-child) { - margin-right: 6px; -} - -.comment__action--link:hover { - text-decoration: underline; -} - -.comment__action--post { - margin-left: auto; -} - -.comment__action--button { - cursor: pointer; - font: 12px/20px var(--font-regular); - padding: 0 10px; -} - -.comment__action--hide { - opacity: 0; - transition: opacity .2s; -} - -.comment__action--voted { - font-weight: 700; -} - -.comment__action__checkbox { - vertical-align: text-top; - margin-right: 2px; -} - -.comment__replies .comment--indent-1, -.comment__replies .comment--indent-2, -.comment__replies .comment--indent-3, -.comment__replies .comment--indent-4, -.comment__replies .comment--indent-5 { - margin-left: 20px; -} - -.comment__avatar { - flex: 0 0 auto; - height: 50px; - width: 50px; - margin-right: 5px; -} -.comment__replies .comment__avatar { - width: 40px; - height: 40px; -} - -.comment__content { - flex: 1 1 auto; - display: flex; - flex-direction: column; - overflow: hidden; - word-wrap: break-word; - padding-left: 5px; -} -.comment__content:hover .comment__action--hide { - opacity: 1; -} - -.comment__info { - display: inline-flex; -} - -.comment__text { - margin-right: 2px; -} -.comment__text--input { - min-width: 100%; - max-width: 100%; - min-height: 50px; - font: 12px/20px var(--font-regular); - margin-right: 1px; -} - -.comment__user { - color: var(--user-colour); - text-decoration: none; -} -.comment__user--link:hover { - text-decoration: underline; -} - -.comment__date, -.comment__pin { - color: #666; - font-size: .9em; - margin-left: 8px; -} - -.comment__link { - color: #666; - display: inline-flex; - text-decoration: none; - - &:hover { - text-decoration: underline; - } -} - -.comment__pin { - margin-left: 4px; -} -.comment__pin:before { - content: "-"; - padding-right: 4px; -} diff --git a/assets/misuzu.css/comments/comments.css b/assets/misuzu.css/comments/comments.css deleted file mode 100644 index 8b381303..00000000 --- a/assets/misuzu.css/comments/comments.css +++ /dev/null @@ -1,36 +0,0 @@ -.comments { - --comments-max-height: 600px; - margin: 1px; - overflow: hidden; - word-wrap: break-word; -} -.comments__listing { - overflow-y: auto; -} -.comments__listing--limit { - max-height: var(--comments-max-height); -} - -/*.comments__input,*/ -.comments__javascript, -.comments__notice--staff { - border-bottom: 1px solid var(--accent-colour); - padding-bottom: 1px; - margin-bottom: 1px; -} - -.comments__none, -.comments__javascript, -.comments__notice { - padding: 10px; - font-size: 1.2em; - text-align: center; -} - -.comments__notice__link { - color: var(--accent-colour); - text-decoration: none; -} -.comments__notice__link:hover { - text-decoration: underline; -} diff --git a/assets/misuzu.css/comments/entry.css b/assets/misuzu.css/comments/entry.css new file mode 100644 index 00000000..79aeac92 --- /dev/null +++ b/assets/misuzu.css/comments/entry.css @@ -0,0 +1,130 @@ +.comments-entry { + /**/ +} + +.comments-entry-main { + display: flex; + gap: 2px; +} + +.comments-entry-root { + padding-bottom: 2px; + border-top: 1px solid var(--accent-colour); +} + +.comments-entry-deleted { + opacity: .5; + transition: opacity .1s; +} +.comments-entry-deleted:hover, +.comments-entry-deleted:focus, +.comments-entry-deleted:focus-within { + opacity: .8; +} + +.comments-entry-replies { + margin-left: 25px; +} + +.comments-entry-avatar { + flex: 0 0 auto; + margin: 4px; +} +.comments-entry-wrap { + flex: 0 1 auto; + display: flex; + flex-direction: column; + gap: 2px; +} + +.comments-entry-meta { + display: flex; + gap: 4px; + margin-top: 4px; +} + +.comments-entry-user { + display: flex; +} +.comments-entry-user-link { + text-decoration: none; +} +.comments-entry-user-link:hover, +.comments-entry-user-link:focus { + text-decoration: underline solid var(--user-colour, var(--text-colour, #fff)); +} +.comments-entry-user-dead { + text-decoration: line-through; +} + +.comments-entry-time { + display: flex; + gap: 6px; +} +.comments-entry-time-edited, +.comments-entry-time-pinned, +.comments-entry-time-deleted { + margin-left: 6px; +} +.comments-entry-time-pinned .comments-entry-time-icon { + rotate: 45deg; +} +.comments-entry-time-link { + color: inherit; + text-decoration: none; +} +.comments-entry-time-link:hover, +.comments-entry-time-link:focus { + text-decoration: underline; +} + +.comments-entry-body { +} + +.comments-entry-actions { + display: flex; + gap: 2px; + margin-top: 2px; +} +.comments-entry-actions-group { + display: flex; + border-radius: 3px; + padding: 1px; + gap: 1px; +} +.comments-entry-actions-group-votes, +.comments-entry-actions-group-replies { + border: 1px solid var(--accent-colour); +} + +.comments-entry-action { + background: transparent; + border-width: 0; + border-radius: 2px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 3px 6px; + cursor: pointer; + transition: background-color .2s; +} +.comments-entry-action:hover, +.comments-entry-action:focus { + background: var(--comments-entry-action-background-hover, #fff4); +} +.comments-entry-action-replies-open { + background: #fff2; +} +.comments-entry-action-vote-like.comments-entry-action-vote-cast { + background: #0808; +} +.comments-entry-action-vote-like { + --comments-entry-action-background-hover: #0804; +} +.comments-entry-action-vote-dislike.comments-entry-action-vote-cast { + background: #c008; +} +.comments-entry-action-vote-dislike { + --comments-entry-action-background-hover: #c004; +} diff --git a/assets/misuzu.css/comments/form.css b/assets/misuzu.css/comments/form.css new file mode 100644 index 00000000..f8f53dae --- /dev/null +++ b/assets/misuzu.css/comments/form.css @@ -0,0 +1,26 @@ +.comments-form { + border: 1px solid var(--accent-colour); + border-radius: 3px; + display: flex; + gap: 2px; +} + +.comments-form-avatar { + flex: 0 0 auto; + margin: 3px; +} +.comments-form-wrap { + flex: 0 1 auto; + display: flex; + flex-direction: column; + gap: 2px; +} + +.comments-form-input { + display: flex; +} +.comments-form-input textarea { + min-width: 100%; + max-width: 100%; + width: 100%; +} diff --git a/assets/misuzu.css/comments/listing.css b/assets/misuzu.css/comments/listing.css new file mode 100644 index 00000000..353ebc87 --- /dev/null +++ b/assets/misuzu.css/comments/listing.css @@ -0,0 +1,5 @@ +.comments-listing { + display: flex; + flex-direction: column; + gap: 2px; +} diff --git a/assets/misuzu.css/comments/main.css b/assets/misuzu.css/comments/main.css new file mode 100644 index 00000000..26dc9a1e --- /dev/null +++ b/assets/misuzu.css/comments/main.css @@ -0,0 +1,3 @@ +@include comments/form.css; +@include comments/entry.css; +@include comments/listing.css; diff --git a/assets/misuzu.css/main.css b/assets/misuzu.css/main.css index ed1016d1..b5010f80 100644 --- a/assets/misuzu.css/main.css +++ b/assets/misuzu.css/main.css @@ -121,8 +121,7 @@ html { @include changelog/log.css; @include changelog/pagination.css; -@include comments/comment.css; -@include comments/comments.css; +@include comments/main.css; @include forum/actions.css; @include forum/categories.css; diff --git a/assets/misuzu.js/comments/api.js b/assets/misuzu.js/comments/api.js new file mode 100644 index 00000000..6b9d933d --- /dev/null +++ b/assets/misuzu.js/comments/api.js @@ -0,0 +1,110 @@ +const MszCommentsApi = (() => { + return { + getCategory: async name => { + if(typeof name !== 'string') + throw 'name must be a string'; + if(name.trim() === '') + throw 'name may not be empty'; + + const { status, body } = await $xhr.get(`/comments/categories/${name}`, { type: 'json' }); + if(status === 404) + throw 'that category does not exist'; + if(status !== 200) + throw 'something went wrong'; + + return body; + }, + updateCategory: async (name, args) => { + if(typeof name !== 'string') + throw 'name must be a string'; + if(name.trim() === '') + throw 'name may not be empty'; + if(typeof args !== 'object' || args === null) + throw 'args must be a non-null object'; + + const { status } = await $xhr.patch(`/comments/categories/${name}`, { csrf: true }, args); + + return status; + }, + getPost: async post => { + if(typeof post !== 'string') + throw 'post id must be a string'; + if(post.trim() === '') + throw 'post id may not be empty'; + + const { status, body } = await $xhr.get(`/comments/posts/${post}`, { type: 'json' }); + if(status === 404) + throw 'that post does not exist'; + if(status !== 200) + throw 'something went wrong'; + + return body; + }, + getPostReplies: async post => { + if(typeof post !== 'string') + throw 'post id must be a string'; + if(post.trim() === '') + throw 'post id may not be empty'; + + const { status, body } = await $xhr.get(`/comments/posts/${post}/replies`, { type: 'json' }); + if(status === 404) + throw 'that post does not exist'; + if(status !== 200) + throw 'something went wrong'; + + return body; + }, + createPost: async (post, args) => { + if(typeof post !== 'string') + throw 'post id must be a string'; + if(post.trim() === '') + throw 'post id may not be empty'; + if(typeof args !== 'object' || args === null) + throw 'args must be a non-null object'; + + const { status, body } = await $xhr.post('/comments/posts', { csrf: true }, args); + + return status; + }, + updatePost: async (post, args) => { + // + }, + deletePost: async post => { + // + }, + createVote: async (post, vote) => { + if(typeof post !== 'string') + throw 'name must be a string'; + if(post.trim() === '') + throw 'name may not be empty'; + if(typeof vote === 'string') + vote = parseInt(vote); + if(typeof vote !== 'number' || isNaN(vote)) + throw 'vote must be a number'; + + const { status } = await $xhr.post(`/comments/posts/${post}/vote`, { csrf: true }, { vote }); + if(status === 400) + throw 'your vote is not acceptable'; + if(status === 403) + throw 'you are not allowed to like or dislike comments'; + if(status === 404) + throw 'that post does not exist'; + if(status !== 200) + throw 'something went wrong'; + }, + deleteVote: async post => { + if(typeof post !== 'string') + throw 'name must be a string'; + if(post.trim() === '') + throw 'name may not be empty'; + + const { status } = await $xhr.delete(`/comments/posts/${post}/vote`, { csrf: true }); + if(status === 403) + throw 'you are not allowed to like or dislike comments'; + if(status === 404) + throw 'that post does not exist'; + if(status !== 204) + throw 'something went wrong'; + }, + }; +})(); diff --git a/assets/misuzu.js/comments/form.jsx b/assets/misuzu.js/comments/form.jsx new file mode 100644 index 00000000..67d614bf --- /dev/null +++ b/assets/misuzu.js/comments/form.jsx @@ -0,0 +1,36 @@ +const MszCommentsFormNotice = function() { + const element = <div class="comments-notice"> + You must be logged in to post comments. + </div>; + + return { + get element() { + return element; + }, + }; +}; + +const MszCommentsForm = function(userInfo, root) { + const element = <form class="comments-form" style={`--user-colour: ${userInfo.colour}; display: flex;`}> + <div class="comments-form-avatar"> + <img src={userInfo.avatar} alt="" width="40" height="40" class="avatar" /> + </div> + <div class="comments-form-wrap"> + <div class="comments-form-input"> + <textarea class="input__textarea" placeholder="Share your extensive insights..." /> + </div> + <div style="display: flex;"> + <div>Press enter to submit, use shift+enter to start a new line.</div> + <div style="flex-grow: 1;" /> + {userInfo.can_pin ? <div><label><input type="checkbox"/> Pin</label></div> : null} + <div><button class="input__button">Post</button></div> + </div> + </div> + </form>; + + return { + get element() { + return element; + }, + }; +}; diff --git a/assets/misuzu.js/comments/init.js b/assets/misuzu.js/comments/init.js new file mode 100644 index 00000000..0abddf60 --- /dev/null +++ b/assets/misuzu.js/comments/init.js @@ -0,0 +1,11 @@ +#include comments/section.jsx + +const MszCommentsInit = () => { + const targets = Array.from($queryAll('.js-comments')); + for(const target of targets) { + const section = new MszCommentsSection({ + category: target.dataset.category, + }); + target.replaceWith(section.element); + } +}; diff --git a/assets/misuzu.js/comments/listing.jsx b/assets/misuzu.js/comments/listing.jsx new file mode 100644 index 00000000..5c363f30 --- /dev/null +++ b/assets/misuzu.js/comments/listing.jsx @@ -0,0 +1,160 @@ +#include comments/api.js +#include comments/form.jsx + +const MszCommentsEntry = function(userInfo, postInfo, root) { + userInfo ??= {}; + + const actions = <div class="comments-entry-actions" />; + actions.appendChild(<div class="comments-entry-actions-group comments-entry-actions-group-votes"> + <button class={{ 'comments-entry-action': true, 'comments-entry-action-vote-like': true, 'comments-entry-action-vote-cast': postInfo.vote > 0 }} disabled={!userInfo.can_vote}> + <i class="fas fa-chevron-up" /> + {postInfo.positive > 0 ? <span>{postInfo.positive.toLocaleString()}</span> : null} + </button> + <button class={{ 'comments-entry-action': true, 'comments-entry-action-vote-dislike': true, 'comments-entry-action-vote-cast': postInfo.vote < 0 }} disabled={!userInfo.can_vote}> + <i class="fas fa-chevron-down" /> + {postInfo.negative < 0 ? <span>{Math.abs(postInfo.negative).toLocaleString()}</span> : null} + </button> + </div>); + + const repliesIsArray = Array.isArray(postInfo.replies); + const form = userInfo?.can_create ? new MszCommentsForm(userInfo) : null; + const listing = new MszCommentsListing({ hidden: !repliesIsArray }); + if(repliesIsArray) + listing.addPosts(userInfo, postInfo.replies); + + const replyCount = repliesIsArray ? postInfo.replies.length : (postInfo.replies ?? 0); + if(replyCount > 0 || userInfo.can_create) { + const replyElem = <button class={{ 'comments-entry-action': true, 'comments-entry-action-replies-open': listing.visible }}> + <i class="fas fa-reply" /> + {replyCount > 0 ? <span>{replyCount.toLocaleString()}</span> : null} + </button>; + + let loaded = listing.loaded; + replyElem.onclick = async () => { + replyElem.classList.toggle('comments-entry-action-replies-open', listing.visible = !listing.visible); + if(!loaded) { + loaded = true; + try { + listing.addPosts(userInfo, await MszCommentsApi.getPostReplies(postInfo.id)); + } catch(ex) { + loaded = false; + console.error(ex); + + // THIS IS NOT FINAL DO NOT PUSH THIS TO PUBLIC THIS WOULD BE HORRIBLE + if(typeof ex === 'string') + MszShowMessageBox(ex); + } + } + }; + + actions.appendChild(<div class="comments-entry-actions-group comments-entry-actions-group-replies"> + {replyElem} + </div>); + } + + if(postInfo.can_delete || userInfo.can_pin) { + const misc = <div class="comments-entry-actions-group" />; + if(postInfo.can_delete) + misc.appendChild(<button class="comments-entry-action"> + <i class="fas fa-trash" /> + </button>); + if(userInfo.can_pin) + misc.appendChild(<button class="comments-entry-action" disabled={!userInfo.can_pin}> + <i class="fas fa-thumbtack" /> + </button>); + actions.appendChild(misc); + } + + const created = new Date(postInfo.created); + const edited = postInfo.edited ? new Date(postInfo.edited) : null; + const deleted = postInfo.deleted ? new Date(postInfo.deleted) : null; + const pinned = postInfo.pinned ? new Date(postInfo.pinned) : null; + + const element = <div id={`comment-${postInfo.id}`} data-comment={postInfo.id} class={{ 'comments-entry': true, 'comments-entry-root': root, 'comments-entry-deleted': postInfo.deleted }} style={{ '--user-colour': postInfo.user?.colour }}> + <div class="comments-entry-main"> + <div class="comments-entry-avatar"> + <img src={postInfo.user?.avatar ?? '/images/no-avatar.png'} alt="" width="40" height="40" class="avatar" /> + </div> + <div class="comments-entry-wrap"> + <div class="comments-entry-meta"> + <div class="comments-entry-user"> + {postInfo.user + ? <a class="comments-entry-user-link" href={postInfo.user.profile} style="color: var(--user-colour);">{postInfo.user.name}</a> + : <span class="comments-entry-user-dead">Deleted user</span>} + </div> + <div class="comments-entry-time"> + <div class="comments-entry-time-icon">—</div> + <a href={`#comment-${postInfo.id}`} class="comments-entry-time-link"> + <time class="comments-entry-time-text" datetime={created.toISOString()} title={created.toString()}>{MszSakuya.formatTimeAgo(created)}</time> + </a> + </div> + {edited !== null ? <div class="comments-entry-time comments-entry-time-edited"> + <div class="comments-entry-time-icon"><i class="fas fa-pencil-alt" /></div> + <time class="comments-entry-time-text" datetime={edited.toISOString()} title={edited.toString()}>{MszSakuya.formatTimeAgo(edited)}</time> + </div> : null} + {pinned !== null ? <div class="comments-entry-time comments-entry-time-pinned"> + <div class="comments-entry-time-icon"><i class="fas fa-thumbtack" /></div> + <time class="comments-entry-time-text" datetime={pinned.toISOString()} title={pinned.toString()}>{MszSakuya.formatTimeAgo(pinned)}</time> + </div> : null} + {deleted !== null ? <div class="comments-entry-time comments-entry-time-deleted"> + <div class="comments-entry-time-icon"><i class="fas fa-trash" /></div> + <time class="comments-entry-time-text" datetime={deleted.toISOString()} title={deleted.toString()}>{MszSakuya.formatTimeAgo(deleted)}</time> + </div> : null} + </div> + <div class="comments-entry-body">{postInfo.body}</div> + {actions.childElementCount > 0 ? actions : null} + </div> + </div> + <div class="comments-entry-replies"> + {form} + {listing} + </div> + </div>; + + MszSakuya.trackElements(element.querySelectorAll('time')); + + return { + get element() { + return element; + }, + }; +}; + +const MszCommentsListing = function(options) { + let { hidden, root } = options ?? {}; + + let loading = new MszLoading; + const entries = new Map; + const element = <div class={{ 'comments-listing': true, 'hidden': hidden }}> + {loading} + </div>; + + const addPost = function(userInfo, postInfo, parentId=null) { + const entry = new MszCommentsEntry(userInfo ?? {}, postInfo, root); + entries.set(postInfo.id, entry); + element.appendChild(entry.element); + }; + + return { + get element() { return element; }, + + get visible() { return !element.classList.contains('hidden'); }, + set visible(state) { element.classList.toggle('hidden', !state); }, + + get loaded() { return loading === null; }, + + addPost: addPost, + addPosts: function(userInfo, posts) { + try { + if(!Array.isArray(posts)) + throw 'posts must be an array'; + userInfo ??= {}; + for(const postInfo of posts) + addPost(userInfo, postInfo); + } finally { + loading.element.remove(); + loading = null; + } + }, + }; +}; diff --git a/assets/misuzu.js/comments/section.jsx b/assets/misuzu.js/comments/section.jsx new file mode 100644 index 00000000..30cf9a23 --- /dev/null +++ b/assets/misuzu.js/comments/section.jsx @@ -0,0 +1,44 @@ +#include msgbox.jsx +#include comments/api.js +#include comments/form.jsx +#include comments/listing.jsx + +const MszCommentsSection = function(options) { + let { category: catName } = options ?? {}; + + const listing = new MszCommentsListing({ root: true }); + const element = <div class="comments"> + {listing} + </div>; + + let form; + + MszCommentsApi.getCategory(catName) + .then(catInfo => { + console.log(catInfo); + + let formElement; + if(catInfo.user?.can_create) { + form = new MszCommentsForm(catInfo.user, true); + formElement = form.element; + } else + formElement = (new MszCommentsFormNotice).element; + + $insertBefore(listing.element, formElement); + + listing.addPosts(catInfo.user, catInfo.posts); + }) + .catch(message => { + console.error(message); + + // THIS IS NOT FINAL DO NOT PUSH THIS TO PUBLIC THIS WOULD BE HORRIBLE + if(typeof message === 'string') + MszShowMessageBox(message); + }); + + return { + get element() { + return element; + }, + }; +}; diff --git a/assets/misuzu.js/main.js b/assets/misuzu.js/main.js index 5f6c5dc9..1f3f7504 100644 --- a/assets/misuzu.js/main.js +++ b/assets/misuzu.js/main.js @@ -1,4 +1,5 @@ #include msgbox.jsx +#include comments/init.js #include embed/embed.js #include events/christmas2019.js #include events/events.js @@ -129,6 +130,7 @@ MszEmbed.init(`${location.protocol}//uiharu.${location.host}`); initXhrActions(); + MszCommentsInit(); // only used by the forum posting form initQuickSubmit(); diff --git a/assets/oauth2.css/loading.css b/assets/oauth2.css/loading.css deleted file mode 100644 index 925d9471..00000000 --- a/assets/oauth2.css/loading.css +++ /dev/null @@ -1,36 +0,0 @@ -.oauth2-loading { - display: flex; - justify-content: center; - flex-direction: column; - min-height: 200px; -} - -.oauth2-loading-frame { - display: flex; - justify-content: center; - flex: 0 0 auto; -} - -.oauth2-loading-icon { - display: grid; - grid-template-columns: repeat(3, 1fr); - grid-template-rows: repeat(3, 1fr); - gap: 2px; - margin: 20px; -} - -.oauth2-loading-icon-block { - background: #fff; - width: 20px; - height: 20px; -} - -.oauth2-loading-icon-block-hidden { - opacity: 0; -} - -.oauth2-loading-text { - text-align: center; - font-size: 1.2em; - line-height: 1.5em; -} diff --git a/assets/oauth2.css/main.css b/assets/oauth2.css/main.css index 8dbcda53..a97e680c 100644 --- a/assets/oauth2.css/main.css +++ b/assets/oauth2.css/main.css @@ -63,7 +63,6 @@ a:focus { margin: 10px; } -@include loading.css; @include banner.css; @include error.css; @include device.css; diff --git a/assets/oauth2.js/authorise.js b/assets/oauth2.js/authorise.js index f76f30c4..628e76f8 100644 --- a/assets/oauth2.js/authorise.js +++ b/assets/oauth2.js/authorise.js @@ -1,4 +1,3 @@ -#include loading.jsx #include app/info.jsx #include app/scope.jsx #include header/header.js @@ -30,7 +29,7 @@ const MszOAuth2AuthoriseErrors = Object.freeze({ const MszOAuth2Authorise = async () => { const queryParams = new URLSearchParams(window.location.search); - const loading = new MszOAuth2Loading('.js-loading'); + const loading = new MszLoading({ element: '.js-loading', size: 2 }); const header = new MszOAuth2Header; const fAuths = document.querySelector('.js-authorise-form'); diff --git a/assets/oauth2.js/verify.js b/assets/oauth2.js/verify.js index 9921ef84..a2bd54b7 100644 --- a/assets/oauth2.js/verify.js +++ b/assets/oauth2.js/verify.js @@ -1,4 +1,3 @@ -#include loading.jsx #include app/info.jsx #include app/scope.jsx #include header/header.js @@ -6,7 +5,7 @@ const MszOAuth2Verify = () => { const queryParams = new URLSearchParams(window.location.search); - const loading = new MszOAuth2Loading('.js-loading'); + const loading = new MszLoading({ element: '.js-loading', size: 2 }); const header = new MszOAuth2Header; const fAuths = document.querySelector('.js-verify-authorise'); diff --git a/database/2025_02_10_230238_dont_autoupdate_comment_post_edited_field.php b/database/2025_02_10_230238_dont_autoupdate_comment_post_edited_field.php new file mode 100644 index 00000000..badedf78 --- /dev/null +++ b/database/2025_02_10_230238_dont_autoupdate_comment_post_edited_field.php @@ -0,0 +1,39 @@ +<?php +use Index\Db\DbConnection; +use Index\Db\Migration\DbMigration; + +final class DontAutoupdateCommentPostEditedField_20250210_230238 implements DbMigration { + public function migrate(DbConnection $conn): void { + $conn->execute(<<<SQL + ALTER TABLE msz_comments_posts + CHANGE COLUMN comment_edited comment_edited TIMESTAMP NULL DEFAULT NULL AFTER comment_pinned; + SQL); + + $conn->execute(<<<SQL + ALTER TABLE msz_news_posts + DROP FOREIGN KEY news_posts_category_id_foreign, + DROP FOREIGN KEY news_posts_user_id_foreign; + SQL); + + $conn->execute(<<<SQL + ALTER TABLE msz_news_posts + DROP COLUMN comment_section_id, + DROP INDEX news_posts_comment_section, + DROP INDEX news_posts_category_id_foreign, + ADD INDEX news_posts_categories_foreign (category_id), + DROP INDEX news_posts_user_id_foreign, + ADD INDEX news_posts_users_foreign (user_id), + DROP FOREIGN KEY news_posts_comment_section, + ADD CONSTRAINT news_posts_categories_foreign + FOREIGN KEY (category_id) + REFERENCES msz_news_categories (category_id) + ON UPDATE CASCADE + ON DELETE CASCADE, + ADD CONSTRAINT news_posts_users_foreign + FOREIGN KEY (user_id) + REFERENCES msz_users (user_id) + ON UPDATE CASCADE + ON DELETE SET NULL; + SQL); + } +} diff --git a/public-legacy/comments.php b/public-legacy/comments.php deleted file mode 100644 index a5407619..00000000 --- a/public-legacy/comments.php +++ /dev/null @@ -1,213 +0,0 @@ -<?php -namespace Misuzu; - -use RuntimeException; -use Misuzu\Comments\{CommentsCategoryInfo,CommentsPostInfo}; - -if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext)) - die('Script must be called through the Misuzu route dispatcher.'); - -$redirect = filter_input(INPUT_GET, 'return') ?? $_SERVER['HTTP_REFERER'] ?? $msz->urls->format('index'); - -if(!Tools::isLocalURL($redirect)) - Template::displayInfo('Possible request forgery detected.', 403); - -if(!CSRF::validateRequest()) - Template::displayInfo("Couldn't verify this request, please refresh the page and try again.", 403); - -if(!$msz->authInfo->loggedIn) - Template::displayInfo('You must be logged in to manage comments.', 403); - -if($msz->usersCtx->hasActiveBan($msz->authInfo->userInfo)) - Template::displayInfo('You have been banned, check your profile for more information.', 403); - -$perms = $msz->authInfo->getPerms('global'); - -$commentId = (string)filter_input(INPUT_GET, 'c', FILTER_SANITIZE_NUMBER_INT); -$commentMode = (string)filter_input(INPUT_GET, 'm'); -$commentVote = (int)filter_input(INPUT_GET, 'v', FILTER_SANITIZE_NUMBER_INT); - -if(!empty($commentId)) { - try { - $commentInfo = $msz->comments->getPost($commentId); - } catch(RuntimeException $ex) { - Template::displayInfo('Post not found.', 404); - } - - $categoryInfo = $msz->comments->getCategory(postInfo: $commentInfo); -} - -if($commentMode !== 'create' && empty($commentInfo)) - Template::throwError(400); - -switch($commentMode) { - case 'pin': - case 'unpin': - if(!isset($categoryInfo) || !($categoryInfo instanceof CommentsCategoryInfo)) - Template::displayInfo('Comment category not found.', 404); - if(!$perms->check(Perm::G_COMMENTS_PIN) && !$categoryInfo->isOwner($msz->authInfo->userInfo)) - Template::displayInfo("You're not allowed to pin comments.", 403); - if(!isset($commentInfo) || !($commentInfo instanceof CommentsPostInfo) || $commentInfo->deleted) - Template::displayInfo("This comment doesn't exist!", 400); - if($commentInfo->isReply) - Template::displayInfo("You can't pin replies!", 400); - - $isPinning = $commentMode === 'pin'; - - if($isPinning) { - if($commentInfo->pinned) - Template::displayInfo('This comment is already pinned.', 400); - - $msz->comments->pinPost($commentInfo); - } else { - if(!$commentInfo->pinned) - Template::displayInfo("This comment isn't pinned yet.", 400); - - $msz->comments->unpinPost($commentInfo); - } - - Tools::redirect($redirect . '#comment-' . $commentInfo->id); - break; - - case 'vote': - if(!isset($categoryInfo) || !($categoryInfo instanceof CommentsCategoryInfo)) - Template::displayInfo('Comment category not found.', 404); - if(!$perms->check(Perm::G_COMMENTS_VOTE) && !$categoryInfo->isOwner($msz->authInfo->userInfo)) - Template::displayInfo("You're not allowed to vote on comments.", 403); - if(!isset($commentInfo) || !($commentInfo instanceof CommentsPostInfo) || $commentInfo->deleted) - Template::displayInfo("This comment doesn't exist!", 400); - - if($commentVote > 0) - $msz->comments->addPostPositiveVote($commentInfo, $msz->authInfo->userInfo); - elseif($commentVote < 0) - $msz->comments->addPostNegativeVote($commentInfo, $msz->authInfo->userInfo); - else - $msz->comments->removePostVote($commentInfo, $msz->authInfo->userInfo); - - Tools::redirect($redirect . '#comment-' . $commentInfo->id); - break; - - case 'delete': - if(!isset($categoryInfo) || !($categoryInfo instanceof CommentsCategoryInfo)) - Template::displayInfo('Comment category not found.', 404); - - $canDelete = $perms->check(Perm::G_COMMENTS_DELETE_OWN | Perm::G_COMMENTS_DELETE_ANY); - if(!$canDelete && !$categoryInfo->isOwner($msz->authInfo->userInfo)) - Template::displayInfo("You're not allowed to delete comments.", 403); - - $canDeleteAny = $perms->check(Perm::G_COMMENTS_DELETE_ANY); - if(!isset($commentInfo) || !($commentInfo instanceof CommentsPostInfo) || $commentInfo->deleted) - Template::displayInfo( - $canDeleteAny ? 'This comment is already marked for deletion.' : "This comment doesn't exist.", - 400 - ); - - $isOwnComment = $commentInfo->userId === $msz->authInfo->userInfo->id; - $isModAction = $canDeleteAny && !$isOwnComment; - - if(!$isModAction && !$isOwnComment) - Template::displayInfo("You're not allowed to delete comments made by others.", 403); - - $msz->comments->deletePost($commentInfo); - - if($isModAction) { - $msz->createAuditLog('COMMENT_ENTRY_DELETE_MOD', [ - $commentInfo->id, - $commentUserId = $commentInfo->userId, - '<username>', - ]); - } else { - $msz->createAuditLog('COMMENT_ENTRY_DELETE', [$commentInfo->id]); - } - - Tools::redirect($redirect); - break; - - case 'restore': - if(!$perms->check(Perm::G_COMMENTS_DELETE_ANY)) - Template::displayInfo("You're not allowed to restore deleted comments.", 403); - - if(!isset($commentInfo) || !($commentInfo instanceof CommentsPostInfo)) - Template::displayInfo("This comment is probably nuked already.", 404); - if(!$commentInfo->deleted) - Template::displayInfo("This comment isn't in a deleted state.", 400); - - $msz->comments->restorePost($commentInfo); - - $msz->createAuditLog('COMMENT_ENTRY_RESTORE', [ - $commentInfo->id, - $commentUserId = $commentInfo->userId, - '<username>', - ]); - - Tools::redirect($redirect . '#comment-' . $commentInfo->id); - break; - - case 'create': - if(!isset($categoryInfo) || !($categoryInfo instanceof CommentsCategoryInfo)) - Template::displayInfo('Comment category not found.', 404); - if(!$perms->check(Perm::G_COMMENTS_CREATE) && !$categoryInfo->isOwner($msz->authInfo->userInfo)) - Template::displayInfo("You're not allowed to post comments.", 403); - if(empty($_POST['comment']) || !is_array($_POST['comment'])) - Template::displayInfo('Missing data.', 400); - - try { - $categoryId = isset($_POST['comment']['category']) && is_string($_POST['comment']['category']) - ? (int)$_POST['comment']['category'] - : 0; - $categoryInfo = $msz->comments->getCategory(categoryId: (string)$categoryId); - } catch(RuntimeException $ex) { - Template::displayInfo("This comment category doesn't exist.", 404); - } - - $canLock = $perms->check(Perm::G_COMMENTS_LOCK); - if($categoryInfo->locked && !$canLock) - Template::displayInfo('This comment category has been locked.', 403); - - $commentText = !empty($_POST['comment']['text']) && is_string($_POST['comment']['text']) ? $_POST['comment']['text'] : ''; - $commentReply = (string)(!empty($_POST['comment']['reply']) && is_string($_POST['comment']['reply']) ? (int)$_POST['comment']['reply'] : 0); - $commentLock = !empty($_POST['comment']['lock']) && $canLock; - $commentPin = !empty($_POST['comment']['pin']) && $perms->check(Perm::G_COMMENTS_PIN); - - if($commentLock) { - if($categoryInfo->locked) - $msz->comments->unlockCategory($categoryInfo); - else - $msz->comments->lockCategory($categoryInfo); - } - - if(strlen($commentText) > 0) { - $commentText = preg_replace("/[\r\n]{2,}/", "\n", $commentText); - } else { - if($canLock) - Template::displayInfo('The action has been processed.', 400); - else - Template::displayInfo('Your comment is too short.', 400); - } - - if(mb_strlen($commentText) > 5000) - Template::displayInfo('Your comment is too long.', 400); - - if($commentReply > 0) { - try { - $parentInfo = $msz->comments->getPost($commentReply); - } catch(RuntimeException $ex) {} - - if(!isset($parentInfo) || !($parentInfo instanceof CommentsPostInfo) || $parentInfo->deleted) - Template::displayInfo('The comment you tried to reply to does not exist.', 404); - } - - $commentInfo = $msz->comments->createPost( - $categoryInfo, - $parentInfo ?? null, - $msz->authInfo->userInfo, - $commentText, - $commentPin - ); - - Tools::redirect($redirect . '#comment-' . $commentInfo->id); - break; - - default: - Template::displayInfo('Not found.', 404); -} diff --git a/public-legacy/profile.php b/public-legacy/profile.php index 729ae48e..60d478f2 100644 --- a/public-legacy/profile.php +++ b/public-legacy/profile.php @@ -318,7 +318,7 @@ if($isEditing) { $profileStats = new stdClass; $profileStats->forum_topic_count = $msz->forumCtx->countTotalUserTopics($userInfo); $profileStats->forum_post_count = $msz->forumCtx->countTotalUserPosts($userInfo); -$profileStats->comments_count = $msz->comments->countPosts(userInfo: $userInfo, deleted: false); +$profileStats->comments_count = $msz->commentsCtx->posts->countPosts(userInfo: $userInfo, deleted: false); if(!$viewingAsGuest) { Template::set('profile_warnings', iterator_to_array($msz->usersCtx->warnings->getWarningsWithDefaultBacklog($userInfo))); diff --git a/public-legacy/settings/data.php b/public-legacy/settings/data.php index f338ed23..90031b79 100644 --- a/public-legacy/settings/data.php +++ b/public-legacy/settings/data.php @@ -140,7 +140,7 @@ if(isset($_POST['action']) && is_string($_POST['action'])) { $tmpFiles[] = db_to_zip($archive, $userInfo, 'forum_topics_track', ['user_id:s', 'topic_id:s', 'forum_id:s', 'track_last_read:t']); $tmpFiles[] = db_to_zip($archive, $userInfo, 'login_attempts', ['attempt_id:s', 'user_id:s:n', 'attempt_success:b', 'attempt_remote_addr:a', 'attempt_country:s', 'attempt_created:t', 'attempt_user_agent:s']); $tmpFiles[] = db_to_zip($archive, $userInfo, 'messages', ['msg_id:s', 'msg_owner_id:s', 'msg_author_id:s:n', 'msg_recipient_id:s:n', 'msg_reply_to:s:n', 'msg_title:s', 'msg_body:s', 'msg_body_format:s', 'msg_created:t', 'msg_sent:t:n', 'msg_read:t:n', 'msg_deleted:t:n'], 'msg_owner_id'); - $tmpFiles[] = db_to_zip($archive, $userInfo, 'news_posts', ['post_id:s', 'category_id:s', 'user_id:s:n', 'comment_section_id:s:n', 'post_featured:b', 'post_title:s', 'post_text:s', 'post_scheduled:t', 'post_created:t', 'post_updated:t', 'post_deleted:t:n']); + $tmpFiles[] = db_to_zip($archive, $userInfo, 'news_posts', ['post_id:s', 'category_id:s', 'user_id:s:n', 'post_featured:b', 'post_title:s', 'post_text:s', 'post_scheduled:t', 'post_created:t', 'post_updated:t', 'post_deleted:t:n']); $tmpFiles[] = db_to_zip($archive, $userInfo, 'oauth2_access', ['acc_id:s', 'app_id:s', 'user_id:s:n', 'acc_token:n', 'acc_scope:s', 'acc_created:t', 'acc_expires:t']); $tmpFiles[] = db_to_zip($archive, $userInfo, 'oauth2_authorise', ['auth_id:s', 'app_id:s', 'user_id:s', 'uri_id:s', 'auth_challenge_code:n', 'auth_challenge_method:s', 'auth_scope:s', 'auth_code:n', 'auth_created:t', 'auth_expires:t']); $tmpFiles[] = db_to_zip($archive, $userInfo, 'oauth2_device', ['dev_id:s', 'app_id:s', 'user_id:s:n', 'dev_code:n', 'dev_user_code:n', 'dev_interval:i', 'dev_polled:t', 'dev_scope:s', 'dev_approval:s', 'dev_created:t', 'dev_expires:t']); diff --git a/src/Changelog/ChangelogRoutes.php b/src/Changelog/ChangelogRoutes.php index cae82737..a34a4870 100644 --- a/src/Changelog/ChangelogRoutes.php +++ b/src/Changelog/ChangelogRoutes.php @@ -9,7 +9,7 @@ use Index\Syndication\FeedBuilder; use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon}; use Misuzu\{Pagination,SiteInfo,Template}; use Misuzu\Auth\AuthInfo; -use Misuzu\Comments\{CommentsData,CommentsEx}; +use Misuzu\Comments\CommentsContext; use Misuzu\Users\UsersContext; final class ChangelogRoutes implements RouteHandler, UrlSource { @@ -20,15 +20,10 @@ final class ChangelogRoutes implements RouteHandler, UrlSource { private UrlRegistry $urls, private ChangelogData $changelog, private UsersContext $usersCtx, + private CommentsContext $commentsCtx, private AuthInfo $authInfo, - private CommentsData $comments ) {} - private function getCommentsInfo(string $categoryName): object { - $comments = new CommentsEx($this->authInfo, $this->comments, $this->usersCtx); - return $comments->getCommentsForLayout($categoryName); - } - #[HttpGet('/changelog')] #[UrlFormat('changelog-index', '/changelog', ['date' => '<date>', 'user' => '<user>', 'tags' => '<tags>', 'p' => '<page>'])] public function getIndex(HttpResponseBuilder $response, HttpRequest $request): int|string { @@ -88,13 +83,18 @@ final class ChangelogRoutes implements RouteHandler, UrlSource { if(empty($changes)) return 404; + if(empty($filterDate)) + $commentsCategoryName = null; + elseif($commentsCategoryName) // should this be run here? + $this->commentsCtx->categories->ensureCategoryExists($commentsCategoryName); + return Template::renderRaw('changelog.index', [ 'changelog_infos' => $changes, 'changelog_date' => $filterDate, 'changelog_user' => $filterUser, 'changelog_tags' => $filterTags, 'changelog_pagination' => $pagination, - 'comments_info' => empty($filterDate) && $commentsCategoryName !== null ? null : $this->getCommentsInfo($commentsCategoryName), + 'comments_category_name' => $commentsCategoryName, ]); } @@ -111,12 +111,14 @@ final class ChangelogRoutes implements RouteHandler, UrlSource { $tagInfos = $this->changelog->getTags(changeInfo: $changeInfo); $userInfo = $changeInfo->userId !== null ? $this->usersCtx->getUserInfo($changeInfo->userId) : null; + // should this be run here? + $this->commentsCtx->categories->ensureCategoryExists($changeInfo->commentsCategoryName); + return Template::renderRaw('changelog.change', [ 'change_info' => $changeInfo, 'change_tags' => $tagInfos, 'change_user_info' => $userInfo, 'change_user_colour' => $this->usersCtx->getUserColour($userInfo), - 'comments_info' => $this->getCommentsInfo($changeInfo->commentsCategoryName), ]); } diff --git a/src/Comments/CommentsCategoriesData.php b/src/Comments/CommentsCategoriesData.php new file mode 100644 index 00000000..b935ad70 --- /dev/null +++ b/src/Comments/CommentsCategoriesData.php @@ -0,0 +1,247 @@ +<?php +namespace Misuzu\Comments; + +use InvalidArgumentException; +use RuntimeException; +use Index\Db\{DbConnection,DbStatement,DbStatementCache}; +use Misuzu\Pagination; +use Misuzu\Users\UserInfo; + +class CommentsCategoriesData { + private DbStatementCache $cache; + + public function __construct(DbConnection $dbConn) { + $this->cache = new DbStatementCache($dbConn); + } + + public function countCategories( + UserInfo|string|null $owner = null + ): int { + if($owner instanceof UserInfo) + $owner = $owner->id; + + $hasOwner = $owner !== null; + + $query = 'SELECT COUNT(*) FROM msz_comments_categories'; + if($hasOwner) + $query .= ' WHERE user_id = ?'; + + $stmt = $this->cache->get($query); + $stmt->nextParameter($owner); + $stmt->execute(); + + $count = 0; + $result = $stmt->getResult(); + + if($result->next()) + $count = $result->getInteger(0); + + return $count; + } + + /** @return \Iterator<int, CommentsCategoryInfo> */ + public function getCategories( + UserInfo|string|null $owner = null, + ?Pagination $pagination = null + ): iterable { + if($owner instanceof UserInfo) + $owner = $owner->id; + + $hasOwner = $owner !== null; + $hasPagination = $pagination !== null; + + $query = <<<SQL + SELECT category_id, category_name, user_id, + UNIX_TIMESTAMP(category_created), + UNIX_TIMESTAMP(category_locked) + FROM msz_comments_categories + SQL; + if($hasOwner) + $query .= ' WHERE user_id = ?'; + $query .= ' ORDER BY category_id ASC'; // should order by date but no index on + if($hasPagination) + $query .= ' LIMIT ? RANGE ?'; + + $stmt = $this->cache->get($query); + + if($hasOwner) + $stmt->nextParameter($owner); + if($hasPagination) + $pagination->addToStatement($stmt); + + $stmt->execute(); + + return $stmt->getResultIterator(CommentsCategoryInfo::fromResult(...)); + } + + public function getCategory( + ?string $categoryId = null, + ?string $name = null, + CommentsPostInfo|string|null $postInfo = null + ): CommentsCategoryInfo { + $hasCategoryId = $categoryId !== null; + $hasName = $name !== null; + $hasPostInfo = $postInfo !== null; + + if(!$hasCategoryId && !$hasName && !$hasPostInfo) + throw new InvalidArgumentException('At least one of the arguments must be set.'); + // there has got to be a better way to do this + if(($hasCategoryId && ($hasName || $hasPostInfo)) || ($hasName && ($hasCategoryId || $hasPostInfo)) || ($hasPostInfo && ($hasCategoryId || $hasName))) + throw new InvalidArgumentException('Only one of the arguments may be specified.'); + + $query = <<<SQL + SELECT category_id, category_name, user_id, + UNIX_TIMESTAMP(category_created), + UNIX_TIMESTAMP(category_locked) + FROM msz_comments_categories + SQL; + $value = null; + if($hasCategoryId) { + $query .= ' WHERE category_id = ?'; + $value = $categoryId; + } + if($hasName) { + $query .= ' WHERE category_name = ?'; + $value = $name; + } + if($hasPostInfo) { + if($postInfo instanceof CommentsPostInfo) { + $query .= ' WHERE category_id = ?'; + $value = $postInfo->categoryId; + } else { + $query .= ' WHERE category_id = (SELECT category_id FROM msz_comments_posts WHERE comment_id = ?)'; + $value = $postInfo; + } + } + + $stmt = $this->cache->get($query); + $stmt->nextParameter($value); + $stmt->execute(); + $result = $stmt->getResult(); + + if(!$result->next()) + throw new RuntimeException('Comments category not found.'); + + return CommentsCategoryInfo::fromResult($result); + } + + private function createCategoryInternal( + string $name, + UserInfo|string|null $owner = null, + ): DbStatement { + if($owner instanceof UserInfo) + $owner = $owner->id; + + $name = trim($name); + if(empty($name)) + throw new InvalidArgumentException('$name may not be empty.'); + + $stmt = $this->cache->get(<<<SQL + INSERT INTO msz_comments_categories ( + category_name, user_id + ) VALUES (?, ?) + SQL); + $stmt->nextParameter($name); + $stmt->nextParameter($owner); + $stmt->execute(); + + return $stmt; + } + + public function createCategory( + string $name, + UserInfo|string|null $owner = null + ): CommentsCategoryInfo { + return $this->getCategory( + categoryId: (string)$this->createCategoryInternal($name, $owner)->lastInsertId, + ); + } + + public function ensureCategoryExists( + string $name, + UserInfo|string|null $owner = null + ): void { + $stmt = $this->cache->get(<<<SQL + SELECT COUNT(*) + FROM msz_comments_categories + WHERE category_name = ? + SQL); + $stmt->nextParameter($name); + $stmt->execute(); + + $result = $stmt->getResult(); + if(!$result->next()) + throw new RuntimeException('failed to query for the existence of comments category'); + + if(!$result->getBoolean(0)) + $this->createCategoryInternal($name, $owner); + } + + public function deleteCategory(CommentsCategoryInfo|string $category): void { + if($category instanceof CommentsCategoryInfo) + $category = $category->id; + + $stmt = $this->cache->get(<<<SQL + DELETE FROM msz_comments_categories + WHERE category_id = ? + SQL); + $stmt->nextParameter($category); + $stmt->execute(); + } + + public function updateCategory( + CommentsCategoryInfo|string $category, + ?string $name = null, + bool $updateOwner = false, + UserInfo|string|null $owner = null + ): void { + if($category instanceof CommentsCategoryInfo) + $category = $category->id; + if($owner instanceof UserInfo) + $owner = $owner->id; + + if($name !== null) { + $name = trim($name); + if(empty($name)) + throw new InvalidArgumentException('$name may not be empty.'); + } + + $stmt = $this->cache->get(<<<SQL + UPDATE msz_comments_categories + SET category_name = COALESCE(?, category_name), + user_id = IF(?, ?, user_id) + WHERE category_id = ? + SQL); + $stmt->nextParameter($name); + $stmt->nextParameter($updateOwner ? 1 : 0); + $stmt->nextParameter($owner ? 1 : 0); + $stmt->nextParameter($category); + $stmt->execute(); + } + + public function lockCategory(CommentsCategoryInfo|string $category): void { + if($category instanceof CommentsCategoryInfo) + $category = $category->id; + + $stmt = $this->cache->get(<<<SQL + UPDATE msz_comments_categories + SET category_locked = COALESCE(category_locked, NOW()) + WHERE category_id = ? + SQL); + $stmt->nextParameter($category); + $stmt->execute(); + } + + public function unlockCategory(CommentsCategoryInfo|string $category): void { + if($category instanceof CommentsCategoryInfo) + $category = $category->id; + + $stmt = $this->cache->get(<<<SQL + UPDATE msz_comments_categories + SET category_locked = NULL + WHERE category_id = ? + SQL); + $stmt->nextParameter($category); + $stmt->execute(); + } +} diff --git a/src/Comments/CommentsCategoryInfo.php b/src/Comments/CommentsCategoryInfo.php index 74fa2df4..406c9eef 100644 --- a/src/Comments/CommentsCategoryInfo.php +++ b/src/Comments/CommentsCategoryInfo.php @@ -12,7 +12,6 @@ class CommentsCategoryInfo { public private(set) ?string $ownerId, public private(set) int $createdTime, public private(set) ?int $lockedTime, - public private(set) int $commentsCount, // virtual!! ) {} public static function fromResult(DbResult $result): CommentsCategoryInfo { @@ -22,7 +21,6 @@ class CommentsCategoryInfo { ownerId: $result->getStringOrNull(2), createdTime: $result->getInteger(3), lockedTime: $result->getIntegerOrNull(4), - commentsCount: $result->getInteger(5), ); } diff --git a/src/Comments/CommentsContext.php b/src/Comments/CommentsContext.php new file mode 100644 index 00000000..6922cce5 --- /dev/null +++ b/src/Comments/CommentsContext.php @@ -0,0 +1,16 @@ +<?php +namespace Misuzu\Comments; + +use Index\Db\DbConnection; + +class CommentsContext { + public private(set) CommentsCategoriesData $categories; + public private(set) CommentsPostsData $posts; + public private(set) CommentsVotesData $votes; + + public function __construct(DbConnection $dbConn) { + $this->categories = new CommentsCategoriesData($dbConn); + $this->posts = new CommentsPostsData($dbConn); + $this->votes = new CommentsVotesData($dbConn); + } +} diff --git a/src/Comments/CommentsData.php b/src/Comments/CommentsData.php deleted file mode 100644 index 68a6087f..00000000 --- a/src/Comments/CommentsData.php +++ /dev/null @@ -1,504 +0,0 @@ -<?php -namespace Misuzu\Comments; - -use InvalidArgumentException; -use RuntimeException; -use Index\Db\{DbConnection,DbStatementCache}; -use Misuzu\Pagination; -use Misuzu\Users\UserInfo; - -class CommentsData { - private DbStatementCache $cache; - - public function __construct(DbConnection $dbConn) { - $this->cache = new DbStatementCache($dbConn); - } - - public function countCategories( - UserInfo|string|null $owner = null - ): int { - if($owner instanceof UserInfo) - $owner = $owner->id; - - $hasOwner = $owner !== null; - - $query = 'SELECT COUNT(*) FROM msz_comments_categories'; - if($hasOwner) - $query .= ' WHERE user_id = ?'; - - $stmt = $this->cache->get($query); - $stmt->nextParameter($owner); - $stmt->execute(); - - $count = 0; - $result = $stmt->getResult(); - - if($result->next()) - $count = $result->getInteger(0); - - return $count; - } - - /** @return \Iterator<int, CommentsCategoryInfo> */ - public function getCategories( - UserInfo|string|null $owner = null, - ?Pagination $pagination = null - ): iterable { - if($owner instanceof UserInfo) - $owner = $owner->id; - - $hasOwner = $owner !== null; - $hasPagination = $pagination !== null; - - $query = 'SELECT category_id, category_name, user_id, UNIX_TIMESTAMP(category_created), UNIX_TIMESTAMP(category_locked), (SELECT COUNT(*) FROM msz_comments_posts AS cp WHERE cp.category_id = cc.category_id AND comment_deleted IS NULL) AS `category_comments` FROM msz_comments_categories AS cc'; - if($hasOwner) - $query .= ' WHERE user_id = ?'; - $query .= ' ORDER BY category_id ASC'; // should order by date but no index on - if($hasPagination) - $query .= ' LIMIT ? RANGE ?'; - - $stmt = $this->cache->get($query); - - if($hasOwner) - $stmt->nextParameter($owner); - if($hasPagination) - $pagination->addToStatement($stmt); - - $stmt->execute(); - - return $stmt->getResultIterator(CommentsCategoryInfo::fromResult(...)); - } - - public function getCategory( - ?string $categoryId = null, - ?string $name = null, - CommentsPostInfo|string|null $postInfo = null - ): CommentsCategoryInfo { - $hasCategoryId = $categoryId !== null; - $hasName = $name !== null; - $hasPostInfo = $postInfo !== null; - - if(!$hasCategoryId && !$hasName && !$hasPostInfo) - throw new InvalidArgumentException('At least one of the arguments must be set.'); - // there has got to be a better way to do this - if(($hasCategoryId && ($hasName || $hasPostInfo)) || ($hasName && ($hasCategoryId || $hasPostInfo)) || ($hasPostInfo && ($hasCategoryId || $hasName))) - throw new InvalidArgumentException('Only one of the arguments may be specified.'); - - $query = 'SELECT category_id, category_name, user_id, UNIX_TIMESTAMP(category_created), UNIX_TIMESTAMP(category_locked), (SELECT COUNT(*) FROM msz_comments_posts AS cp WHERE cp.category_id = cc.category_id AND comment_deleted IS NULL) AS category_comments FROM msz_comments_categories AS cc'; - $value = null; - if($hasCategoryId) { - $query .= ' WHERE category_id = ?'; - $value = $categoryId; - } - if($hasName) { - $query .= ' WHERE category_name = ?'; - $value = $name; - } - if($hasPostInfo) { - if($postInfo instanceof CommentsPostInfo) { - $query .= ' WHERE category_id = ?'; - $value = $postInfo->categoryId; - } else { - $query .= ' WHERE category_id = (SELECT category_id FROM msz_comments_posts WHERE comment_id = ?)'; - $value = $postInfo; - } - } - - $stmt = $this->cache->get($query); - $stmt->nextParameter($value); - $stmt->execute(); - $result = $stmt->getResult(); - - if(!$result->next()) - throw new RuntimeException('Comments category not found.'); - - return CommentsCategoryInfo::fromResult($result); - } - - public function checkCategoryNameExists(string $name): bool { - $stmt = $this->cache->get('SELECT COUNT(*) FROM msz_comments_categories WHERE category_name = ?'); - $stmt->nextParameter($name); - $stmt->execute(); - - $count = 0; - $result = $stmt->getResult(); - - if($result->next()) - $count = $result->getInteger(0); - - return $count > 0; - } - - public function ensureCategory(string $name, UserInfo|string|null $owner = null): CommentsCategoryInfo { - if($this->checkCategoryNameExists($name)) - return $this->getCategory(name: $name); - return $this->createCategory($name, $owner); - } - - public function createCategory(string $name, UserInfo|string|null $owner = null): CommentsCategoryInfo { - if($owner instanceof UserInfo) - $owner = $owner->id; - - $name = trim($name); - if(empty($name)) - throw new InvalidArgumentException('$name may not be empty.'); - - $stmt = $this->cache->get('INSERT INTO msz_comments_categories (category_name, user_id) VALUES (?, ?)'); - $stmt->nextParameter($name); - $stmt->nextParameter($owner); - $stmt->execute(); - - return $this->getCategory(categoryId: (string)$stmt->lastInsertId); - } - - public function deleteCategory(CommentsCategoryInfo|string $category): void { - if($category instanceof CommentsCategoryInfo) - $category = $category->id; - - $stmt = $this->cache->get('DELETE FROM msz_comments_categories WHERE category_id = ?'); - $stmt->nextParameter($category); - $stmt->execute(); - } - - public function updateCategory( - CommentsCategoryInfo|string $category, - ?string $name = null, - bool $updateOwner = false, - UserInfo|string|null $owner = null - ): void { - if($category instanceof CommentsCategoryInfo) - $category = $category->id; - if($owner instanceof UserInfo) - $owner = $owner->id; - - if($name !== null) { - $name = trim($name); - if(empty($name)) - throw new InvalidArgumentException('$name may not be empty.'); - } - - $stmt = $this->cache->get('UPDATE msz_comments_categories SET category_name = COALESCE(?, category_name), user_id = IF(?, ?, user_id) WHERE category_id = ?'); - $stmt->nextParameter($name); - $stmt->nextParameter($updateOwner ? 1 : 0); - $stmt->nextParameter($owner ? 1 : 0); - $stmt->nextParameter($category); - $stmt->execute(); - } - - public function lockCategory(CommentsCategoryInfo|string $category): void { - if($category instanceof CommentsCategoryInfo) - $category = $category->id; - - $stmt = $this->cache->get('UPDATE msz_comments_categories SET category_locked = COALESCE(category_locked, NOW()) WHERE category_id = ?'); - $stmt->nextParameter($category); - $stmt->execute(); - } - - public function unlockCategory(CommentsCategoryInfo|string $category): void { - if($category instanceof CommentsCategoryInfo) - $category = $category->id; - - $stmt = $this->cache->get('UPDATE msz_comments_categories SET category_locked = NULL WHERE category_id = ?'); - $stmt->nextParameter($category); - $stmt->execute(); - } - - public function countPosts( - CommentsCategoryInfo|string|null $categoryInfo = null, - CommentsPostInfo|string|null $parentInfo = null, - UserInfo|string|null $userInfo = null, - ?bool $replies = null, - ?bool $deleted = null - ): int { - if($categoryInfo instanceof CommentsCategoryInfo) - $categoryInfo = $categoryInfo->id; - if($parentInfo instanceof CommentsPostInfo) - $parentInfo = $parentInfo->id; - - $hasCategoryInfo = $categoryInfo !== null; - $hasParentInfo = $parentInfo !== null; - $hasUserInfo = $userInfo !== null; - $hasReplies = $replies !== null; - $hasDeleted = $deleted !== null; - - $args = 0; - $query = 'SELECT COUNT(*) FROM msz_comments_posts'; - - if($hasParentInfo) { - ++$args; - $query .= ' WHERE comment_reply_to = ?'; - } - if($hasCategoryInfo) - $query .= sprintf(' %s category_id = ?', ++$args > 1 ? 'AND' : 'WHERE'); - if($hasReplies) - $query .= sprintf(' %s comment_reply_to %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $replies ? 'IS NOT' : 'IS'); - if($hasDeleted) - $query .= sprintf(' %s comment_deleted %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $deleted ? 'IS NOT' : 'IS'); - if($hasUserInfo) - $query .= sprintf(' %s user_id = ?', ++$args > 1 ? 'AND' : 'WHERE'); - - $stmt = $this->cache->get($query); - if($hasParentInfo) - $stmt->nextParameter($parentInfo); - elseif($hasCategoryInfo) - $stmt->nextParameter($categoryInfo); - if($hasUserInfo) - $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo); - $stmt->execute(); - - $result = $stmt->getResult(); - $count = 0; - - if($result->next()) - $count = $result->getInteger(0); - - return $count; - } - - /** @return \Iterator<int, CommentsPostInfo> */ - public function getPosts( - CommentsCategoryInfo|string|null $categoryInfo = null, - CommentsPostInfo|string|null $parentInfo = null, - UserInfo|string|null $userInfo = null, - ?bool $replies = null, - ?bool $deleted = null, - bool $includeRepliesCount = false, - bool $includeVotesCount = false - ): iterable { - if($categoryInfo instanceof CommentsCategoryInfo) - $categoryInfo = $categoryInfo->id; - if($parentInfo instanceof CommentsPostInfo) - $parentInfo = $parentInfo->id; - - $hasCategoryInfo = $categoryInfo !== null; - $hasParentInfo = $parentInfo !== null; - $hasUserInfo = $userInfo !== null; - $hasReplies = $replies !== null; - $hasDeleted = $deleted !== null; - - $args = 0; - $query = 'SELECT comment_id, category_id, user_id, comment_reply_to, comment_text, UNIX_TIMESTAMP(comment_created), UNIX_TIMESTAMP(comment_pinned), UNIX_TIMESTAMP(comment_edited), UNIX_TIMESTAMP(comment_deleted)'; - if($includeRepliesCount) - $query .= ', (SELECT COUNT(*) FROM msz_comments_posts AS ccr WHERE ccr.comment_reply_to = cpp.comment_id AND comment_deleted IS NULL) AS `comment_replies`'; - if($includeVotesCount) { - $query .= ', (SELECT COUNT(*) FROM msz_comments_votes AS cvc WHERE cvc.comment_id = cpp.comment_id) AS `comment_votes_total`'; - $query .= ', (SELECT COUNT(*) FROM msz_comments_votes AS cvc WHERE cvc.comment_id = cpp.comment_id AND comment_vote > 0) AS `comment_votes_positive`'; - $query .= ', (SELECT COUNT(*) FROM msz_comments_votes AS cvc WHERE cvc.comment_id = cpp.comment_id AND comment_vote < 0) AS `comment_votes_negative`'; - } - $query .= ' FROM msz_comments_posts AS cpp'; - - if($hasParentInfo) { - ++$args; - $query .= ' WHERE comment_reply_to = ?'; - } - if($hasCategoryInfo) - $query .= sprintf(' %s category_id = ?', ++$args > 1 ? 'AND' : 'WHERE'); - if($hasReplies) - $query .= sprintf(' %s comment_reply_to %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $replies ? 'IS NOT' : 'IS'); - if($hasDeleted) - $query .= sprintf(' %s comment_deleted %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $deleted ? 'IS NOT' : 'IS'); - if($hasUserInfo) - $query .= sprintf(' %s user_id = ?', ++$args > 1 ? 'AND' : 'WHERE'); - - // this should really not be implicit like this - if($hasParentInfo) - $query .= ' ORDER BY comment_deleted ASC, comment_pinned DESC, comment_created ASC'; - elseif($hasCategoryInfo) - $query .= ' ORDER BY comment_deleted ASC, comment_pinned DESC, comment_created DESC'; - else - $query .= ' ORDER BY comment_created DESC'; - - $stmt = $this->cache->get($query); - if($hasParentInfo) - $stmt->nextParameter($parentInfo); - elseif($hasCategoryInfo) - $stmt->nextParameter($categoryInfo); - if($hasUserInfo) - $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo); - $stmt->execute(); - - return $stmt->getResultIterator(fn($result) => CommentsPostInfo::fromResult($result, $includeRepliesCount, $includeVotesCount)); - } - - public function getPost( - string $postId, - bool $includeRepliesCount = false, - bool $includeVotesCount = false - ): CommentsPostInfo { - $query = 'SELECT comment_id, category_id, user_id, comment_reply_to, comment_text, UNIX_TIMESTAMP(comment_created), UNIX_TIMESTAMP(comment_pinned), UNIX_TIMESTAMP(comment_edited), UNIX_TIMESTAMP(comment_deleted)'; - if($includeRepliesCount) - $query .= ', (SELECT COUNT(*) FROM msz_comments_posts AS ccr WHERE ccr.comment_reply_to = cpp.comment_id AND comment_deleted IS NULL) AS `comment_replies`'; - if($includeVotesCount) { - $query .= ', (SELECT COUNT(*) FROM msz_comments_votes AS cvc WHERE cvc.comment_id = cpp.comment_id) AS `comment_votes_total`'; - $query .= ', (SELECT COUNT(*) FROM msz_comments_votes AS cvc WHERE cvc.comment_id = cpp.comment_id AND comment_vote > 0) AS `comment_votes_positive`'; - $query .= ', (SELECT COUNT(*) FROM msz_comments_votes AS cvc WHERE cvc.comment_id = cpp.comment_id AND comment_vote < 0) AS `comment_votes_negative`'; - } - $query .= ' FROM msz_comments_posts AS cpp WHERE comment_id = ?'; - - $stmt = $this->cache->get($query); - $stmt->nextParameter($postId); - $stmt->execute(); - - $result = $stmt->getResult(); - if(!$result->next()) - throw new RuntimeException('No comment with that ID exists.'); - - return CommentsPostInfo::fromResult($result, $includeRepliesCount, $includeVotesCount); - } - - public function createPost( - CommentsCategoryInfo|string|null $category, - CommentsPostInfo|string|null $parent, - UserInfo|string|null $user, - string $body, - bool $pin = false - ): CommentsPostInfo { - if($category instanceof CommentsCategoryInfo) - $category = $category->id; - if($parent instanceof CommentsPostInfo) { - if($category === null) - $category = $parent->categoryId; - elseif($category !== $parent->categoryId) - throw new InvalidArgumentException('$parent belongs to a different category than where this post is attempted to be created.'); - $parent = $parent->id; - } - if($category === null) - throw new InvalidArgumentException('$category is null; at least a $category or $parent must be specified.'); - if($user instanceof UserInfo) - $user = $user->id; - if(empty(trim($body))) - throw new InvalidArgumentException('$body may not be empty.'); - - $stmt = $this->cache->get('INSERT INTO msz_comments_posts (category_id, user_id, comment_reply_to, comment_text, comment_pinned) VALUES (?, ?, ?, ?, IF(?, NOW(), NULL))'); - $stmt->nextParameter($category); - $stmt->nextParameter($user); - $stmt->nextParameter($parent); - $stmt->nextParameter($body); - $stmt->nextParameter($pin ? 1 : 0); - $stmt->execute(); - - return $this->getPost((string)$stmt->lastInsertId); - } - - public function deletePost(CommentsPostInfo|string $infoOrId): void { - if($infoOrId instanceof CommentsPostInfo) - $infoOrId = $infoOrId->id; - - $stmt = $this->cache->get('UPDATE msz_comments_posts SET comment_deleted = COALESCE(comment_deleted, NOW()) WHERE comment_id = ?'); - $stmt->nextParameter($infoOrId); - $stmt->execute(); - } - - public function nukePost(CommentsPostInfo|string $infoOrId): void { - if($infoOrId instanceof CommentsPostInfo) - $infoOrId = $infoOrId->id; - - $stmt = $this->cache->get('DELETE FROM msz_comments_posts WHERE comment_id = ?'); - $stmt->nextParameter($infoOrId); - $stmt->execute(); - } - - public function restorePost(CommentsPostInfo|string $infoOrId): void { - if($infoOrId instanceof CommentsPostInfo) - $infoOrId = $infoOrId->id; - - $stmt = $this->cache->get('UPDATE msz_comments_posts SET comment_deleted = NULL WHERE comment_id = ?'); - $stmt->nextParameter($infoOrId); - $stmt->execute(); - } - - public function editPost(CommentsPostInfo|string $infoOrId, string $body): void { - if($infoOrId instanceof CommentsPostInfo) - $infoOrId = $infoOrId->id; - - if(empty(trim($body))) - throw new InvalidArgumentException('$body may not be empty.'); - - $stmt = $this->cache->get('UPDATE msz_comments_posts SET comment_text = ?, comment_edited = NOW() WHERE comment_id = ?'); - $stmt->nextParameter($body); - $stmt->nextParameter($infoOrId); - $stmt->execute(); - } - - public function pinPost(CommentsPostInfo|string $infoOrId): void { - if($infoOrId instanceof CommentsPostInfo) - $infoOrId = $infoOrId->id; - - $stmt = $this->cache->get('UPDATE msz_comments_posts SET comment_pinned = COALESCE(comment_pinned, NOW()) WHERE comment_id = ?'); - $stmt->nextParameter($infoOrId); - $stmt->execute(); - } - - public function unpinPost(CommentsPostInfo|string $infoOrId): void { - if($infoOrId instanceof CommentsPostInfo) - $infoOrId = $infoOrId->id; - - $stmt = $this->cache->get('UPDATE msz_comments_posts SET comment_pinned = NULL WHERE comment_id = ?'); - $stmt->nextParameter($infoOrId); - $stmt->execute(); - } - - public function getPostVote( - CommentsPostInfo|string $post, - UserInfo|string|null $user - ): CommentsPostVoteInfo { - if($post instanceof CommentsPostInfo) - $post = $post->id; - if($user instanceof UserInfo) - $user = $user->id; - - // SUM() here makes it so a result row is always returned, albeit with just NULLs - $stmt = $this->cache->get('SELECT comment_id, user_id, SUM(comment_vote) FROM msz_comments_votes WHERE comment_id = ? AND user_id = ?'); - $stmt->nextParameter($post); - $stmt->nextParameter($user); - $stmt->execute(); - - $result = $stmt->getResult(); - if(!$result->next()) - throw new RuntimeException('Failed to fetch vote info.'); - - return CommentsPostVoteInfo::fromResult($result); - } - - public function addPostVote( - CommentsPostInfo|string $post, - UserInfo|string $user, - int $weight - ): void { - if($weight === 0) - return; - if($post instanceof CommentsPostInfo) - $post = $post->id; - if($user instanceof UserInfo) - $user = $user->id; - - $stmt = $this->cache->get('REPLACE INTO msz_comments_votes (comment_id, user_id, comment_vote) VALUES (?, ?, ?)'); - $stmt->nextParameter($post); - $stmt->nextParameter($user); - $stmt->nextParameter($weight); - $stmt->execute(); - } - - public function addPostPositiveVote(CommentsPostInfo|string $post, UserInfo|string $user): void { - $this->addPostVote($post, $user, 1); - } - - public function addPostNegativeVote(CommentsPostInfo|string $post, UserInfo|string $user): void { - $this->addPostVote($post, $user, -1); - } - - public function removePostVote( - CommentsPostInfo|string $post, - UserInfo|string $user - ): void { - if($post instanceof CommentsPostInfo) - $post = $post->id; - if($user instanceof UserInfo) - $user = $user->id; - - $stmt = $this->cache->get('DELETE FROM msz_comments_votes WHERE comment_id = ? AND user_id = ?'); - $stmt->nextParameter($post); - $stmt->nextParameter($user); - $stmt->execute(); - } -} diff --git a/src/Comments/CommentsEx.php b/src/Comments/CommentsEx.php deleted file mode 100644 index 4701744a..00000000 --- a/src/Comments/CommentsEx.php +++ /dev/null @@ -1,60 +0,0 @@ -<?php -namespace Misuzu\Comments; - -use stdClass; -use RuntimeException; -use Misuzu\MisuzuContext; -use Misuzu\Perm; -use Misuzu\Auth\AuthInfo; -use Misuzu\Users\UsersContext; - -class CommentsEx { - public function __construct( - private AuthInfo $authInfo, - private CommentsData $comments, - private UsersContext $usersCtx - ) {} - - public function getCommentsForLayout(CommentsCategoryInfo|string $category): object { - $info = new stdClass; - if(is_string($category)) - $category = $this->comments->ensureCategory($category); - - $hasUser = $this->authInfo->loggedIn; - $info->user = $hasUser ? $this->authInfo->userInfo : null; - $info->colour = $this->usersCtx->getUserColour($info->user); - $info->perms = $this->authInfo->getPerms('global')->checkMany([ - 'can_post' => Perm::G_COMMENTS_CREATE, - 'can_delete' => Perm::G_COMMENTS_DELETE_OWN | Perm::G_COMMENTS_DELETE_ANY, - 'can_delete_any' => Perm::G_COMMENTS_DELETE_ANY, - 'can_pin' => Perm::G_COMMENTS_PIN, - 'can_lock' => Perm::G_COMMENTS_LOCK, - 'can_vote' => Perm::G_COMMENTS_VOTE, - ]); - $info->category = $category; - $info->posts = []; - - $root = $this->comments->getPosts($category, includeRepliesCount: true, includeVotesCount: true, replies: false); - foreach($root as $postInfo) - $info->posts[] = $this->decorateComment($postInfo); - - return $info; - } - - public function decorateComment(CommentsPostInfo $postInfo): object { - $userInfo = $postInfo->userId !== null ? $this->usersCtx->getUserInfo($postInfo->userId) : null; - - $info = new stdClass; - $info->post = $postInfo; - $info->user = $userInfo; - $info->colour = $this->usersCtx->getUserColour($userInfo); - $info->vote = $this->comments->getPostVote($postInfo, $userInfo); - $info->replies = []; - - $root = $this->comments->getPosts(parentInfo: $postInfo, includeRepliesCount: true, includeVotesCount: true); - foreach($root as $childInfo) - $info->replies[] = $this->decorateComment($childInfo); - - return $info; - } -} diff --git a/src/Comments/CommentsPostInfo.php b/src/Comments/CommentsPostInfo.php index 55f17463..3f811ec9 100644 --- a/src/Comments/CommentsPostInfo.php +++ b/src/Comments/CommentsPostInfo.php @@ -13,45 +13,22 @@ class CommentsPostInfo { public private(set) string $body, public private(set) int $createdTime, public private(set) ?int $pinnedTime, - public private(set) ?int $updatedTime, + public private(set) ?int $editedTime, public private(set) ?int $deletedTime, - public private(set) int $repliesCount, - public private(set) int $votesCount, - public private(set) int $votesPositive, - public private(set) int $votesNegative, ) {} - public static function fromResult( - DbResult $result, - bool $includeRepliesCount = false, - bool $includeVotesCount = false - ): CommentsPostInfo { - $args = []; - $count = 0; - - $args[] = $result->getString($count); // id - $args[] = $result->getString(++$count); // categoryId - $args[] = $result->getStringOrNull(++$count); // userId - $args[] = $result->getStringOrNull(++$count); // replyingTo - $args[] = $result->getString(++$count); // body - $args[] = $result->getInteger(++$count); // createdTime - $args[] = $result->getIntegerOrNull(++$count); // pinnedTime - $args[] = $result->getIntegerOrNull(++$count); // updatedTime - $args[] = $result->getIntegerOrNull(++$count); // deletedTime - - $args[] = $includeRepliesCount ? $result->getInteger(++$count) : 0; - - if($includeVotesCount) { - $args[] = $result->getInteger(++$count); // votesCount - $args[] = $result->getInteger(++$count); // votesPositive - $args[] = $result->getInteger(++$count); // votesNegative - } else { - $args[] = 0; - $args[] = 0; - $args[] = 0; - } - - return new CommentsPostInfo(...$args); + public static function fromResult(DbResult $result): CommentsPostInfo { + return new CommentsPostInfo( + id: $result->getString(0), + categoryId: $result->getString(1), + userId: $result->getStringOrNull(2), + replyingTo: $result->getStringOrNull(3), + body: $result->getString(4), + createdTime: $result->getInteger(5), + pinnedTime: $result->getIntegerOrNull(6), + editedTime: $result->getIntegerOrNull(7), + deletedTime: $result->getIntegerOrNull(8), + ); } public bool $isReply { @@ -70,12 +47,12 @@ class CommentsPostInfo { get => $this->pinnedTime !== null; } - public ?CarbonImmutable $updatedAt { - get => $this->updatedTime === null ? null : CarbonImmutable::createFromTimestampUTC($this->updatedTime); + public ?CarbonImmutable $editedAt { + get => $this->editedTime === null ? null : CarbonImmutable::createFromTimestampUTC($this->editedTime); } public bool $edited { - get => $this->updatedTime !== null; + get => $this->editedTime !== null; } public ?CarbonImmutable $deletedAt { diff --git a/src/Comments/CommentsPostsData.php b/src/Comments/CommentsPostsData.php new file mode 100644 index 00000000..2c4e60e2 --- /dev/null +++ b/src/Comments/CommentsPostsData.php @@ -0,0 +1,281 @@ +<?php +namespace Misuzu\Comments; + +use InvalidArgumentException; +use RuntimeException; +use Index\Db\{DbConnection,DbStatementCache}; +use Misuzu\Users\UserInfo; + +class CommentsPostsData { + private DbStatementCache $cache; + + public function __construct(DbConnection $dbConn) { + $this->cache = new DbStatementCache($dbConn); + } + + public function countPosts( + CommentsCategoryInfo|string|null $categoryInfo = null, + ?string $categoryName = null, + CommentsPostInfo|string|null $parentInfo = null, + UserInfo|string|null $userInfo = null, + ?bool $replies = null, + ?bool $deleted = null + ): int { + if($categoryInfo instanceof CommentsCategoryInfo) + $categoryInfo = $categoryInfo->id; + if($parentInfo instanceof CommentsPostInfo) + $parentInfo = $parentInfo->id; + + $hasCategoryInfo = $categoryInfo !== null; + $hasCategoryName = $categoryName !== null; + $hasParentInfo = $parentInfo !== null; + $hasUserInfo = $userInfo !== null; + $hasReplies = $replies !== null; + $hasDeleted = $deleted !== null; + + $args = 0; + $query = 'SELECT COUNT(*) FROM msz_comments_posts'; + + if($hasParentInfo) { + ++$args; + $query .= ' WHERE comment_reply_to = ?'; + } + if($hasCategoryInfo) + $query .= sprintf(' %s category_id = ?', ++$args > 1 ? 'AND' : 'WHERE'); + if($hasCategoryName) + $query .= sprintf(' %s category_id = (SELECT category_id FROM msz_comments_categories WHERE category_name = ?)', ++$args > 1 ? 'AND' : 'WHERE'); + if($hasReplies) + $query .= sprintf(' %s comment_reply_to %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $replies ? 'IS NOT' : 'IS'); + if($hasDeleted) + $query .= sprintf(' %s comment_deleted %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $deleted ? 'IS NOT' : 'IS'); + if($hasUserInfo) + $query .= sprintf(' %s user_id = ?', ++$args > 1 ? 'AND' : 'WHERE'); + + $stmt = $this->cache->get($query); + if($hasParentInfo) + $stmt->nextParameter($parentInfo); + elseif($hasCategoryInfo) + $stmt->nextParameter($categoryInfo); + if($hasCategoryName) + $stmt->nextParameter($categoryName); + if($hasUserInfo) + $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo); + $stmt->execute(); + + $result = $stmt->getResult(); + $count = 0; + + if($result->next()) + $count = $result->getInteger(0); + + return $count; + } + + /** @return \Iterator<int, CommentsPostInfo> */ + public function getPosts( + CommentsCategoryInfo|string|null $categoryInfo = null, + ?string $categoryName = null, + CommentsPostInfo|string|null $parentInfo = null, + UserInfo|string|null $userInfo = null, + ?bool $replies = null, + ?bool $deleted = null + ): iterable { + if($categoryInfo instanceof CommentsCategoryInfo) + $categoryInfo = $categoryInfo->id; + if($parentInfo instanceof CommentsPostInfo) + $parentInfo = $parentInfo->id; + + $hasCategoryInfo = $categoryInfo !== null; + $hasCategoryName = $categoryName !== null; + $hasParentInfo = $parentInfo !== null; + $hasUserInfo = $userInfo !== null; + $hasReplies = $replies !== null; + $hasDeleted = $deleted !== null; + + $args = 0; + $query = <<<SQL + SELECT comment_id, category_id, user_id, comment_reply_to, comment_text, + UNIX_TIMESTAMP(comment_created), + UNIX_TIMESTAMP(comment_pinned), + UNIX_TIMESTAMP(comment_edited), + UNIX_TIMESTAMP(comment_deleted) + FROM msz_comments_posts + SQL; + + if($hasParentInfo) { + ++$args; + $query .= ' WHERE comment_reply_to = ?'; + } + if($hasCategoryInfo) + $query .= sprintf(' %s category_id = ?', ++$args > 1 ? 'AND' : 'WHERE'); + if($hasCategoryName) + $query .= sprintf(' %s category_id = (SELECT category_id FROM msz_comments_categories WHERE category_name = ?)', ++$args > 1 ? 'AND' : 'WHERE'); + if($hasReplies) + $query .= sprintf(' %s comment_reply_to %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $replies ? 'IS NOT' : 'IS'); + if($hasDeleted) + $query .= sprintf(' %s comment_deleted %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $deleted ? 'IS NOT' : 'IS'); + if($hasUserInfo) + $query .= sprintf(' %s user_id = ?', ++$args > 1 ? 'AND' : 'WHERE'); + + // this should really not be implicit like this + if($hasParentInfo) + $query .= ' ORDER BY comment_deleted ASC, comment_pinned DESC, comment_created ASC'; + elseif($hasCategoryInfo) + $query .= ' ORDER BY comment_deleted ASC, comment_pinned DESC, comment_created DESC'; + else + $query .= ' ORDER BY comment_created DESC'; + + $stmt = $this->cache->get($query); + if($hasParentInfo) + $stmt->nextParameter($parentInfo); + elseif($hasCategoryInfo) + $stmt->nextParameter($categoryInfo); + if($hasCategoryName) + $stmt->nextParameter($categoryName); + if($hasUserInfo) + $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo); + $stmt->execute(); + + return $stmt->getResultIterator(CommentsPostInfo::fromResult(...)); + } + + public function getPost(string $postId): CommentsPostInfo { + $stmt = $this->cache->get(<<<SQL + SELECT comment_id, category_id, user_id, comment_reply_to, comment_text, + UNIX_TIMESTAMP(comment_created), + UNIX_TIMESTAMP(comment_pinned), + UNIX_TIMESTAMP(comment_edited), + UNIX_TIMESTAMP(comment_deleted) + FROM msz_comments_posts + WHERE comment_id = ? + SQL); + $stmt->nextParameter($postId); + $stmt->execute(); + + $result = $stmt->getResult(); + if(!$result->next()) + throw new RuntimeException('No comment with that ID exists.'); + + return CommentsPostInfo::fromResult($result); + } + + public function createPost( + CommentsCategoryInfo|string|null $category, + CommentsPostInfo|string|null $parent, + UserInfo|string|null $user, + string $body, + bool $pin = false + ): CommentsPostInfo { + if($category instanceof CommentsCategoryInfo) + $category = $category->id; + if($parent instanceof CommentsPostInfo) { + if($category === null) + $category = $parent->categoryId; + elseif($category !== $parent->categoryId) + throw new InvalidArgumentException('$parent belongs to a different category than where this post is attempted to be created.'); + $parent = $parent->id; + } + if($category === null) + throw new InvalidArgumentException('$category is null; at least a $category or $parent must be specified.'); + if($user instanceof UserInfo) + $user = $user->id; + if(empty(trim($body))) + throw new InvalidArgumentException('$body may not be empty.'); + + $stmt = $this->cache->get(<<<SQL + INSERT INTO msz_comments_posts ( + category_id, user_id, comment_reply_to, comment_text, comment_pinned + ) VALUES (?, ?, ?, ?, IF(?, NOW(), NULL)) + SQL); + $stmt->nextParameter($category); + $stmt->nextParameter($user); + $stmt->nextParameter($parent); + $stmt->nextParameter($body); + $stmt->nextParameter($pin ? 1 : 0); + $stmt->execute(); + + return $this->getPost((string)$stmt->lastInsertId); + } + + public function deletePost(CommentsPostInfo|string $infoOrId): void { + if($infoOrId instanceof CommentsPostInfo) + $infoOrId = $infoOrId->id; + + $stmt = $this->cache->get(<<<SQL + UPDATE msz_comments_posts + SET comment_deleted = COALESCE(comment_deleted, NOW()) + WHERE comment_id = ? + SQL); + $stmt->nextParameter($infoOrId); + $stmt->execute(); + } + + public function nukePost(CommentsPostInfo|string $infoOrId): void { + if($infoOrId instanceof CommentsPostInfo) + $infoOrId = $infoOrId->id; + + $stmt = $this->cache->get(<<<SQL + DELETE FROM msz_comments_posts + WHERE comment_id = ? + SQL); + $stmt->nextParameter($infoOrId); + $stmt->execute(); + } + + public function restorePost(CommentsPostInfo|string $infoOrId): void { + if($infoOrId instanceof CommentsPostInfo) + $infoOrId = $infoOrId->id; + + $stmt = $this->cache->get(<<<SQL + UPDATE msz_comments_posts + SET comment_deleted = NULL + WHERE comment_id = ? + SQL); + $stmt->nextParameter($infoOrId); + $stmt->execute(); + } + + public function editPost(CommentsPostInfo|string $infoOrId, string $body): void { + if($infoOrId instanceof CommentsPostInfo) + $infoOrId = $infoOrId->id; + + if(empty(trim($body))) + throw new InvalidArgumentException('$body may not be empty.'); + + $stmt = $this->cache->get(<<<SQL + UPDATE msz_comments_posts + SET comment_text = ?, + comment_edited = NOW() + WHERE comment_id = ? + SQL); + $stmt->nextParameter($body); + $stmt->nextParameter($infoOrId); + $stmt->execute(); + } + + public function pinPost(CommentsPostInfo|string $infoOrId): void { + if($infoOrId instanceof CommentsPostInfo) + $infoOrId = $infoOrId->id; + + $stmt = $this->cache->get(<<<SQL + UPDATE msz_comments_posts + SET comment_pinned = COALESCE(comment_pinned, NOW()) + WHERE comment_id = ? + SQL); + $stmt->nextParameter($infoOrId); + $stmt->execute(); + } + + public function unpinPost(CommentsPostInfo|string $infoOrId): void { + if($infoOrId instanceof CommentsPostInfo) + $infoOrId = $infoOrId->id; + + $stmt = $this->cache->get(<<<SQL + UPDATE msz_comments_posts + SET comment_pinned = NULL + WHERE comment_id = ? + SQL); + $stmt->nextParameter($infoOrId); + $stmt->execute(); + } +} diff --git a/src/Comments/CommentsRoutes.php b/src/Comments/CommentsRoutes.php new file mode 100644 index 00000000..8e0b5150 --- /dev/null +++ b/src/Comments/CommentsRoutes.php @@ -0,0 +1,299 @@ +<?php +namespace Misuzu\Comments; + +use RuntimeException; +use Index\XArray; +use Index\Http\{FormHttpContent,HttpRequest,HttpResponseBuilder}; +use Index\Http\Routing\{HttpDelete,HttpGet,HttpMiddleware,HttpPatch,HttpPost,RouteHandler,RouteHandlerCommon}; +use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon}; +use Misuzu\{CSRF,Perm}; +use Misuzu\Auth\AuthInfo; +use Misuzu\Perms\{PermissionResult,IPermissionResult}; +use Misuzu\Users\{UserInfo,UsersContext,UsersData}; + +class CommentsRoutes implements RouteHandler, UrlSource { + use RouteHandlerCommon, UrlSourceCommon; + + public function __construct( + private CommentsContext $commentsCtx, + private UsersContext $usersCtx, + private UrlRegistry $urls, + private AuthInfo $authInfo, + ) {} + + private function getGlobalPerms(): IPermissionResult { + return $this->authInfo->loggedIn && !$this->usersCtx->hasActiveBan($this->authInfo->userInfo) + ? $this->authInfo->getPerms('global') + : new PermissionResult(0); + } + + private function convertUser(UserInfo $userInfo, int $avatarRes = 80): array { + $user = [ + 'id' => $userInfo->id, + 'name' => $userInfo->name, + 'profile' => $this->urls->format('user-profile', ['user' => $userInfo->id]), + 'avatar' => $this->urls->format('user-avatar', ['user' => $userInfo->id, 'res' => $avatarRes]), + ]; + + $userColour = $this->usersCtx->getUserColour($userInfo); + if(!$userColour->inherits) + $user['colour'] = (string)$userColour; + + return $user; + } + + private function convertPost( + IPermissionResult $perms, + CommentsPostInfo $postInfo, + ?iterable $replyInfos = null + ): array { + $post = [ + 'id' => $postInfo->id, + 'body' => $postInfo->body, + 'created' => $postInfo->createdAt->toIso8601ZuluString(), + ]; + if($postInfo->pinned) + $post['pinned'] = $postInfo->pinnedAt->toIso8601ZuluString(); + if($postInfo->edited) + $post['edited'] = $postInfo->editedAt->toIso8601ZuluString(); + if($postInfo->deleted) + $post['deleted'] = $postInfo->deletedAt->toIso8601ZuluString(); + + if($postInfo->userId !== null) + try { + $post['user'] = $this->convertUser( + $this->usersCtx->getUserInfo($postInfo->userId) + ); + } catch(RuntimeException $ex) {} + + $votes = $this->commentsCtx->votes->getVotesAggregate($postInfo); + $post['positive'] = $votes->positive; + $post['negative'] = $votes->negative; + + if($this->authInfo->loggedIn) { + $voteInfo = $this->commentsCtx->votes->getVote($postInfo, $this->authInfo->userInfo); + if($voteInfo->weight !== 0) + $post['vote'] = $voteInfo->weight; + + $isAuthor = $this->authInfo->userId === $postInfo->userId; + if($perms->check(Perm::G_COMMENTS_EDIT_ANY) || ($isAuthor && $perms->check(Perm::G_COMMENTS_EDIT_OWN))) + $post['can_edit'] = true; + if($perms->check(Perm::G_COMMENTS_DELETE_ANY) || ($isAuthor && $perms->check(Perm::G_COMMENTS_DELETE_OWN))) + $post['can_delete'] = true; + } + + if($replyInfos === null) { + $replies = $this->commentsCtx->posts->countPosts( + parentInfo: $postInfo, + deleted: $perms->check(Perm::G_COMMENTS_DELETE_ANY) ? null : false, + ); + if($replies > 0) + $post['replies'] = $replies; + } else { + $replies = []; + foreach($replyInfos as $replyInfo) + $replies[] = $this->convertPost($perms, $replyInfo); + if(!empty($replies)) + $post['replies'] = $replies; + } + + return $post; + } + + /** @return void|int|array{error: array{name: string, text: string}} */ + #[HttpMiddleware('/comments')] + public function checkCsrf(HttpResponseBuilder $response, HttpRequest $request) { + if(in_array($request->method, ['DELETE', 'PATCH', 'POST'])) { + if($request->method !== 'DELETE' && !($request->content instanceof FormHttpContent)) + return 400; + + if(!$this->authInfo->loggedIn) + return 401; + + if(!CSRF::validate($request->getHeaderLine('x-csrf-token'))) + return 403; + } + + $response->setHeader('X-CSRF-Token', CSRF::token()); + } + + #[HttpGet('/comments/categories/([A-Za-z0-9-]+)')] + public function getCategory(HttpResponseBuilder $response, HttpRequest $request, string $categoryName): int|array { + try { + $categoryInfo = $this->commentsCtx->categories->getCategory(name: $categoryName); + } catch(RuntimeException $ex) { + return 404; + } + + $perms = $this->getGlobalPerms(); + $result = []; + $category = [ + 'name' => $categoryInfo->name, + 'created' => $categoryInfo->createdAt->toIso8601ZuluString(), + ]; + + if($categoryInfo->locked) + $category['locked'] = $categoryInfo->lockedAt->toIso8601ZuluString(); + + if($categoryInfo->ownerId !== null) + try { + $category['owner'] = $this->convertUser( + $this->usersCtx->getUserInfo($categoryInfo->ownerId) + ); + } catch(RuntimeException $ex) {} + + $result['category'] = $category; + + if($this->authInfo->loggedIn) { + $user = $this->convertUser($this->authInfo->userInfo, 100); + + if($perms->check(Perm::G_COMMENTS_CREATE)) + $user['can_create'] = true; + if($perms->check(Perm::G_COMMENTS_PIN)) + $user['can_pin'] = true; + if($perms->check(Perm::G_COMMENTS_VOTE)) + $user['can_vote'] = true; + if($perms->check(Perm::G_COMMENTS_LOCK)) + $user['can_lock'] = true; + + $result['user'] = $user; + } + + $posts = []; + try { + $postInfos = $this->commentsCtx->posts->getPosts( + categoryInfo: $categoryInfo, + replies: false, + deleted: $perms->check(Perm::G_COMMENTS_DELETE_ANY) ? null : false, + ); + foreach($postInfos as $postInfo) { + $replyInfos = $this->commentsCtx->posts->getPosts( + parentInfo: $postInfo, + deleted: $perms->check(Perm::G_COMMENTS_DELETE_ANY) ? null : false, + ); + + $posts[] = $this->convertPost($perms, $postInfo, $replyInfos); + } + } catch(RuntimeException $ex) {} + + $result['posts'] = $posts; + + return $result; + } + + #[HttpPatch('/comments/categories/([A-Za-z0-9-]+)')] + public function patchCategory(HttpResponseBuilder $response, HttpRequest $request, string $categoryName): int|array { + if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_LOCK)) + return 403; + + return 501; + } + + #[HttpPost('/comments/posts')] + public function postPost(HttpResponseBuilder $response, HttpRequest $request): int|array { + if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_CREATE)) + return 403; + + return 501; + } + + #[HttpGet('/comments/posts/([0-9]+)')] + public function getPost(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int|array { + try { + $postInfo = $this->commentsCtx->posts->getPost($commentId); + } catch(RuntimeException $ex) { + return 404; + } + + $perms = $this->getGlobalPerms(); + $replyInfos = $this->commentsCtx->posts->getPosts( + parentInfo: $postInfo, + deleted: $perms->check(Perm::G_COMMENTS_DELETE_ANY) ? null : false, + ); + + return $this->convertPost($perms, $postInfo, $replyInfos); + } + + #[HttpGet('/comments/posts/([0-9]+)/replies')] + public function getPostReplies(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int|array { + try { + $postInfo = $this->commentsCtx->posts->getPost($commentId); + } catch(RuntimeException $ex) { + return 404; + } + + $perms = $this->getGlobalPerms(); + $replyInfos = $this->commentsCtx->posts->getPosts( + parentInfo: $postInfo, + deleted: $perms->check(Perm::G_COMMENTS_DELETE_ANY) ? null : false, + ); + + $replies = []; + foreach($replyInfos as $replyInfo) + $replies[] = $this->convertPost($perms, $replyInfo); + + return $replies; + } + + #[HttpPatch('/comments/posts/([0-9]+)')] + public function patchPost(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int|array { + $perms = $this->getGlobalPerms(); + $canEditAny = $perms->check(Perm::G_COMMENTS_EDIT_ANY); + $canEditOwn = $perms->check(Perm::G_COMMENTS_EDIT_OWN); + $canPin = $perms->check(Perm::G_COMMENTS_PIN); + if(!$canEditAny && !$canEditOwn && !$canPin) + return 403; + + return 501; + } + + #[HttpDelete('/comments/posts/([0-9]+)')] + public function deletePost(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int|array { + $perms = $this->getGlobalPerms(); + $canDeleteAny = $perms->check(Perm::G_COMMENTS_DELETE_ANY); + if(!$canDeleteAny && !$perms->check(Perm::G_COMMENTS_DELETE_OWN)) + return 403; + + return 501; + } + + #[HttpPost('/comments/posts/([0-9]+)/vote')] + public function postPostVote(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int { + $vote = (int)$request->content->getParam('vote', FILTER_SANITIZE_NUMBER_INT); + if($vote === 0) + return 400; + + if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_VOTE)) + return 403; + + try { + $postInfo = $this->commentsCtx->posts->getPost($commentId); + } catch(RuntimeException $ex) { + return 404; + } + + $this->commentsCtx->votes->addVote( + $postInfo, + $this->authInfo->userInfo, + max(-1, min(1, $vote)) + ); + + return 200; + } + + #[HttpDelete('/comments/posts/([0-9]+)/vote')] + public function deletePostVote(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int { + if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_VOTE)) + return 403; + + try { + $postInfo = $this->commentsCtx->posts->getPost($commentId); + } catch(RuntimeException $ex) { + return 404; + } + + $this->commentsCtx->votes->removeVote($postInfo, $this->authInfo->userInfo); + + return 204; + } +} diff --git a/src/Comments/CommentsPostVoteInfo.php b/src/Comments/CommentsVoteInfo.php similarity index 83% rename from src/Comments/CommentsPostVoteInfo.php rename to src/Comments/CommentsVoteInfo.php index 246e563b..7001e6b6 100644 --- a/src/Comments/CommentsPostVoteInfo.php +++ b/src/Comments/CommentsVoteInfo.php @@ -3,15 +3,15 @@ namespace Misuzu\Comments; use Index\Db\DbResult; -class CommentsPostVoteInfo { +class CommentsVoteInfo { public function __construct( public private(set) string $commentId, public private(set) string $userId, public private(set) int $weight ) {} - public static function fromResult(DbResult $result): CommentsPostVoteInfo { - return new CommentsPostVoteInfo( + public static function fromResult(DbResult $result): CommentsVoteInfo { + return new CommentsVoteInfo( commentId: $result->getString(0), userId: $result->getString(1), weight: $result->getInteger(2), diff --git a/src/Comments/CommentsVotesAggregate.php b/src/Comments/CommentsVotesAggregate.php new file mode 100644 index 00000000..72edbf19 --- /dev/null +++ b/src/Comments/CommentsVotesAggregate.php @@ -0,0 +1,20 @@ +<?php +namespace Misuzu\Comments; + +use Index\Db\DbResult; + +class CommentsVotesAggregate { + public function __construct( + public private(set) string $commentId, + public private(set) int $positive, + public private(set) int $negative, + ) {} + + public static function fromResult(DbResult $result): CommentsVotesAggregate { + return new CommentsVotesAggregate( + commentId: $result->getString(0), + positive: $result->getInteger(1), + negative: $result->getInteger(2), + ); + } +} diff --git a/src/Comments/CommentsVotesData.php b/src/Comments/CommentsVotesData.php new file mode 100644 index 00000000..41e79af6 --- /dev/null +++ b/src/Comments/CommentsVotesData.php @@ -0,0 +1,87 @@ +<?php +namespace Misuzu\Comments; + +use RuntimeException; +use Index\Db\{DbConnection,DbStatementCache}; +use Misuzu\Users\UserInfo; + +class CommentsVotesData { + private DbStatementCache $cache; + + public function __construct(DbConnection $dbConn) { + $this->cache = new DbStatementCache($dbConn); + } + + public function getVotesAggregate( + CommentsPostInfo|string $postInfo + ): CommentsVotesAggregate { + $stmt = $this->cache->get(<<<SQL + SELECT ? AS id, + (SELECT SUM(comment_vote) FROM msz_comments_votes WHERE comment_id = id AND comment_vote > 0), + (SELECT SUM(comment_vote) FROM msz_comments_votes WHERE comment_id = id AND comment_vote < 0) + SQL); + $stmt->nextParameter($postInfo instanceof CommentsPostInfo ? $postInfo->id : $postInfo); + $stmt->execute(); + + $result = $stmt->getResult(); + if(!$result->next()) + throw new RuntimeException('failed to aggregate comment votes'); + + return CommentsVotesAggregate::fromResult($result); + } + + public function getVote( + CommentsPostInfo|string $postInfo, + UserInfo|string $userInfo + ): CommentsVoteInfo { + // SUM() here makes it so a result row is always returned, albeit with just NULLs + $stmt = $this->cache->get(<<<SQL + SELECT comment_id, user_id, SUM(comment_vote) + FROM msz_comments_votes + WHERE comment_id = ? + AND user_id = ? + SQL); + $stmt->nextParameter($postInfo instanceof CommentsPostInfo ? $postInfo->id : $postInfo); + $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo); + $stmt->execute(); + + $result = $stmt->getResult(); + if(!$result->next()) + throw new RuntimeException('Failed to fetch vote info.'); + + return CommentsVoteInfo::fromResult($result); + } + + public function addVote( + CommentsPostInfo|string $postInfo, + UserInfo|string $userInfo, + int $weight + ): void { + if($weight === 0) + return; + + $stmt = $this->cache->get(<<<SQL + REPLACE INTO msz_comments_votes ( + comment_id, user_id, comment_vote + ) VALUES (?, ?, ?) + SQL); + $stmt->nextParameter($postInfo instanceof CommentsPostInfo ? $postInfo->id : $postInfo); + $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo); + $stmt->nextParameter($weight); + $stmt->execute(); + } + + public function removeVote( + CommentsPostInfo|string $postInfo, + UserInfo|string $userInfo + ): void { + $stmt = $this->cache->get(<<<SQL + DELETE FROM msz_comments_votes + WHERE comment_id = ? + AND user_id = ? + SQL); + $stmt->nextParameter($postInfo instanceof CommentsPostInfo ? $postInfo->id : $postInfo); + $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo); + $stmt->execute(); + } +} diff --git a/src/Home/HomeRoutes.php b/src/Home/HomeRoutes.php index 8ba5d677..9b9cdbf8 100644 --- a/src/Home/HomeRoutes.php +++ b/src/Home/HomeRoutes.php @@ -12,7 +12,7 @@ use Index\Urls\{UrlFormat,UrlSource,UrlSourceCommon}; use Misuzu\{Pagination,SiteInfo,Template}; use Misuzu\Auth\AuthInfo; use Misuzu\Changelog\ChangelogData; -use Misuzu\Comments\CommentsData; +use Misuzu\Comments\CommentsContext; use Misuzu\Counters\CountersData; use Misuzu\News\{NewsData,NewsCategoryInfo,NewsPostInfo}; use Misuzu\Users\{UsersContext,UserInfo}; @@ -23,13 +23,13 @@ class HomeRoutes implements RouteHandler, UrlSource { public function __construct( private Config $config, private DbConnection $dbConn, - private SiteInfo $siteInfo, - private AuthInfo $authInfo, + private UsersContext $usersCtx, + private CommentsContext $commentsCtx, private ChangelogData $changelog, - private CommentsData $comments, private CountersData $counters, private NewsData $news, - private UsersContext $usersCtx + private SiteInfo $siteInfo, + private AuthInfo $authInfo, ) {} /** @@ -94,8 +94,10 @@ class HomeRoutes implements RouteHandler, UrlSource { else $this->newsCategoryInfos[$categoryId] = $categoryInfo = $this->news->getCategory(postInfo: $postInfo); - $commentsCount = $postInfo->commentsSectionId - ? $this->comments->countPosts(categoryInfo: $postInfo->commentsSectionId, deleted: false) : 0; + $commentsCount = $this->commentsCtx->posts->countPosts( + categoryName: $postInfo->commentsCategoryName, + deleted: false, + ); $posts[] = [ 'post' => $postInfo, diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php index ed8203c9..270a8d5c 100644 --- a/src/MisuzuContext.php +++ b/src/MisuzuContext.php @@ -27,11 +27,11 @@ class MisuzuContext { public private(set) Emoticons\EmotesData $emotes; public private(set) Changelog\ChangelogData $changelog; public private(set) News\NewsData $news; - public private(set) Comments\CommentsData $comments; public private(set) DatabaseContext $dbCtx; public private(set) Apps\AppsContext $appsCtx; public private(set) Auth\AuthContext $authCtx; + public private(set) Comments\CommentsContext $commentsCtx; public private(set) Forum\ForumContext $forumCtx; public private(set) Messages\MessagesContext $messagesCtx; public private(set) OAuth2\OAuth2Context $oauth2Ctx; @@ -68,6 +68,7 @@ class MisuzuContext { $this->deps->register($this->appsCtx = $this->deps->constructLazy(Apps\AppsContext::class)); $this->deps->register($this->authCtx = $this->deps->constructLazy(Auth\AuthContext::class, config: $this->config->scopeTo('auth'))); + $this->deps->register($this->commentsCtx = $this->deps->constructLazy(Comments\CommentsContext::class)); $this->deps->register($this->forumCtx = $this->deps->constructLazy(Forum\ForumContext::class)); $this->deps->register($this->messagesCtx = $this->deps->constructLazy(Messages\MessagesContext::class)); $this->deps->register($this->oauth2Ctx = $this->deps->constructLazy(OAuth2\OAuth2Context::class, config: $this->config->scopeTo('oauth2'))); @@ -77,7 +78,6 @@ class MisuzuContext { $this->deps->register($this->auditLog = $this->deps->constructLazy(AuditLog\AuditLogData::class)); $this->deps->register($this->changelog = $this->deps->constructLazy(Changelog\ChangelogData::class)); - $this->deps->register($this->comments = $this->deps->constructLazy(Comments\CommentsData::class)); $this->deps->register($this->counters = $this->deps->constructLazy(Counters\CountersData::class)); $this->deps->register($this->emotes = $this->deps->constructLazy(Emoticons\EmotesData::class)); $this->deps->register($this->news = $this->deps->constructLazy(News\NewsData::class)); @@ -173,6 +173,8 @@ class MisuzuContext { $routingCtx->register($this->deps->constructLazy(Users\Assets\AssetsRoutes::class)); $routingCtx->register($this->deps->constructLazy(Info\InfoRoutes::class)); $routingCtx->register($this->deps->constructLazy(News\NewsRoutes::class)); + + $routingCtx->register($this->deps->constructLazy(Comments\CommentsRoutes::class)); $routingCtx->register($this->deps->constructLazy( Messages\MessagesRoutes::class, config: $this->config->scopeTo('messages') diff --git a/src/News/NewsData.php b/src/News/NewsData.php index 4294c7ab..331ead24 100644 --- a/src/News/NewsData.php +++ b/src/News/NewsData.php @@ -220,7 +220,12 @@ class NewsData { $hasPagination = $pagination !== null; $args = 0; - $query = 'SELECT post_id, category_id, user_id, comment_section_id, post_featured, post_title, post_text, UNIX_TIMESTAMP(post_scheduled), UNIX_TIMESTAMP(post_created), UNIX_TIMESTAMP(post_updated), UNIX_TIMESTAMP(post_deleted) FROM msz_news_posts'; + $query = <<<SQL + SELECT post_id, category_id, user_id, post_featured, post_title, post_text, + UNIX_TIMESTAMP(post_scheduled), UNIX_TIMESTAMP(post_created), + UNIX_TIMESTAMP(post_updated), UNIX_TIMESTAMP(post_deleted) + FROM msz_news_posts + SQL; if($hasCategoryInfo) { ++$args; $query .= ' WHERE category_id = ?'; @@ -259,7 +264,13 @@ class NewsData { } public function getPost(string $postId): NewsPostInfo { - $stmt = $this->cache->get('SELECT post_id, category_id, user_id, comment_section_id, post_featured, post_title, post_text, UNIX_TIMESTAMP(post_scheduled), UNIX_TIMESTAMP(post_created), UNIX_TIMESTAMP(post_updated), UNIX_TIMESTAMP(post_deleted) FROM msz_news_posts WHERE post_id = ?'); + $stmt = $this->cache->get(<<<SQL + SELECT post_id, category_id, user_id, post_featured, post_title, post_text, + UNIX_TIMESTAMP(post_scheduled), UNIX_TIMESTAMP(post_created), + UNIX_TIMESTAMP(post_updated), UNIX_TIMESTAMP(post_deleted) + FROM msz_news_posts + WHERE post_id = ? + SQL); $stmt->nextParameter($postId); $stmt->execute(); @@ -378,19 +389,4 @@ class NewsData { $stmt->nextParameter($postInfo); $stmt->execute(); } - - public function updatePostCommentCategory( - NewsPostInfo|string $postInfo, - CommentsCategoryInfo|string $commentsCategory - ): void { - if($postInfo instanceof NewsPostInfo) - $postInfo = $postInfo->id; - if($commentsCategory instanceof CommentsCategoryInfo) - $commentsCategory = $commentsCategory->id; - - $stmt = $this->cache->get('UPDATE msz_news_posts SET comment_section_id = ? WHERE post_id = ?'); - $stmt->nextParameter($commentsCategory); - $stmt->nextParameter($postInfo); - $stmt->execute(); - } } diff --git a/src/News/NewsPostInfo.php b/src/News/NewsPostInfo.php index e01a8bcb..3534fe8c 100644 --- a/src/News/NewsPostInfo.php +++ b/src/News/NewsPostInfo.php @@ -9,7 +9,6 @@ class NewsPostInfo { public private(set) string $id, public private(set) string $categoryId, public private(set) ?string $userId, - public private(set) ?string $commentsSectionId, public private(set) bool $featured, public private(set) string $title, public private(set) string $body, @@ -24,14 +23,13 @@ class NewsPostInfo { id: $result->getString(0), categoryId: $result->getString(1), userId: $result->getStringOrNull(2), - commentsSectionId: $result->getStringOrNull(3), - featured: $result->getBoolean(4), - title: $result->getString(5), - body: $result->getString(6), - scheduledTime: $result->getInteger(7), - createdTime: $result->getInteger(8), - updatedTime: $result->getInteger(9), - deletedTime: $result->getIntegerOrNull(10), + featured: $result->getBoolean(3), + title: $result->getString(4), + body: $result->getString(5), + scheduledTime: $result->getInteger(6), + createdTime: $result->getInteger(7), + updatedTime: $result->getInteger(8), + deletedTime: $result->getIntegerOrNull(9), ); } diff --git a/src/News/NewsRoutes.php b/src/News/NewsRoutes.php index 5a32c9a9..601439ef 100644 --- a/src/News/NewsRoutes.php +++ b/src/News/NewsRoutes.php @@ -9,7 +9,7 @@ use Index\Syndication\FeedBuilder; use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon}; use Misuzu\{Pagination,SiteInfo,Template}; use Misuzu\Auth\AuthInfo; -use Misuzu\Comments\{CommentsData,CommentsCategory,CommentsEx}; +use Misuzu\Comments\CommentsContext; use Misuzu\Parsers\{Parsers,TextFormat}; use Misuzu\Users\{UsersContext,UserInfo}; @@ -20,9 +20,9 @@ class NewsRoutes implements RouteHandler, UrlSource { private SiteInfo $siteInfo, private AuthInfo $authInfo, private UrlRegistry $urls, - private NewsData $news, private UsersContext $usersCtx, - private CommentsData $comments + private CommentsContext $commentsCtx, + private NewsData $news, ) {} /** @var array<string, NewsCategoryInfo> */ @@ -54,9 +54,10 @@ class NewsRoutes implements RouteHandler, UrlSource { else $this->categoryInfos[$categoryId] = $categoryInfo = $this->news->getCategory(postInfo: $postInfo); - $commentsCount = $postInfo->commentsSectionId - ? $this->comments->countPosts(categoryInfo: $postInfo->commentsSectionId, deleted: false) - : 0; + $commentsCount = $this->commentsCtx->posts->countPosts( + categoryName: $postInfo->commentsCategoryName, + deleted: false, + ); $posts[] = [ 'post' => $postInfo, @@ -156,26 +157,16 @@ class NewsRoutes implements RouteHandler, UrlSource { return 404; $categoryInfo = $this->news->getCategory(postInfo: $postInfo); - - if($postInfo->commentsSectionId !== null) - try { - $commentsCategory = $this->comments->getCategory(categoryId: $postInfo->commentsSectionId); - } catch(RuntimeException $ex) {} - - if(!isset($commentsCategory)) { - $commentsCategory = $this->comments->ensureCategory($postInfo->commentsCategoryName); - $this->news->updatePostCommentCategory($postInfo, $commentsCategory); - } - $userInfo = $postInfo->userId !== null ? $this->usersCtx->getUserInfo($postInfo->userId) : null; - $comments = new CommentsEx($this->authInfo, $this->comments, $this->usersCtx); + + // should this be run here? + $this->commentsCtx->categories->ensureCategoryExists($postInfo->commentsCategoryName); return Template::renderRaw('news.post', [ 'post_info' => $postInfo, 'post_category_info' => $categoryInfo, 'post_user_info' => $userInfo, 'post_user_colour' => $this->usersCtx->getUserColour($userInfo), - 'comments_info' => $comments->getCommentsForLayout($commentsCategory), ]); } diff --git a/src/Perm.php b/src/Perm.php index 574eb03c..cc5e65d8 100644 --- a/src/Perm.php +++ b/src/Perm.php @@ -39,8 +39,8 @@ final class Perm { 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 + public const G_COMMENTS_EDIT_OWN = 0b00000_00000000_00000010_00000000_00000000_00000000_00000000; + public const G_COMMENTS_EDIT_ANY = 0b00000_00000000_00000100_00000000_00000000_00000000_00000000; public const G_COMMENTS_DELETE_OWN = 0b00000_00000000_00001000_00000000_00000000_00000000_00000000; public const G_COMMENTS_DELETE_ANY = 0b00000_00000000_00010000_00000000_00000000_00000000_00000000; public const G_COMMENTS_PIN = 0b00000_00000000_00100000_00000000_00000000_00000000_00000000; diff --git a/src/Routing/RoutingErrorHandler.php b/src/Routing/RoutingErrorHandler.php index 87fff7a7..167d5c22 100644 --- a/src/Routing/RoutingErrorHandler.php +++ b/src/Routing/RoutingErrorHandler.php @@ -12,10 +12,10 @@ class RoutingErrorHandler extends HtmlHttpErrorHandler { return; } - $path = sprintf('/error-%03d.html', $code); - if(is_file(Misuzu::PATH_PUBLIC . $path)) { + $path = sprintf('%s/error-%03d.html', Misuzu::PATH_PUBLIC, $code); + if(is_file($path)) { $response->setTypeHTML(); - $response->accelRedirect($path); + $response->content = file_get_contents($path); return; } diff --git a/templates/_layout/comments.twig b/templates/_layout/comments.twig deleted file mode 100644 index b817423a..00000000 --- a/templates/_layout/comments.twig +++ /dev/null @@ -1,215 +0,0 @@ -{% macro comments_input(category, user, perms, reply_to, return_url) %} - {% set reply_mode = reply_to is not null %} - - {% from 'macros.twig' import avatar %} - {% from '_layout/input.twig' import input_hidden, input_csrf, input_checkbox %} - - <form class="comment comment--input{% if reply_mode %} comment--reply{% endif %}" - method="post" action="{{ url('comment-create', {'return': return_url}) }}" - id="comment-{{ reply_mode ? 'reply-' ~ reply_to.id : 'create-' ~ category.id }}"> - {{ input_hidden('comment[category]', category.id) }} - {{ input_csrf() }} - - {% if reply_mode %} - {{ input_hidden('comment[reply]', reply_to.id) }} - {% endif %} - - <div class="comment__container"> - <div class="avatar comment__avatar"> - {{ avatar(user.id, reply_mode ? 40 : 50, user.name) }} - </div> - <div class="comment__content"> - <textarea - class="comment__text input__textarea comment__text--input" - name="comment[text]" placeholder="Share your extensive insights..."></textarea> - <div class="comment__actions"> - {% if not reply_mode %} - {% if perms.can_pin|default(false) %} - {{ input_checkbox('comment[pin]', 'Pin this comment', false, 'comment__action') }} - {% endif %} - {% if perms.can_lock|default(false) %} - {{ input_checkbox('comment[lock]', 'Toggle locked status', false, 'comment__action') }} - {% endif %} - {% endif %} - <button class="input__button comment__action comment__action--button comment__action--post"> - {{ reply_mode ? 'Reply' : 'Post' }} - </button> - </div> - </div> - </div> - </form> -{% endmacro %} - -{% macro comments_entry(comment, indent, category, user, colour, perms, return_url) %} - {% from 'macros.twig' import avatar %} - {% from '_layout/input.twig' import input_checkbox_raw %} - - {% set replies = comment.replies %} - {% set poster = comment.user|default(null) %} - {% if comment.post is defined %} - {% set userVote = comment.vote.weight %} - {% set commenterColour = comment.colour %} - {% set comment = comment.post %} - {% set body = comment.body %} - {% set likes = comment.votesPositive %} - {% set dislikes = comment.votesNegative %} - {% set isReply = comment.isReply %} - {% else %} - {% set body = comment.text %} - {% set commenterColour = null %} - {% set userVote = comment.userVote %} - {% set likes = comment.likes %} - {% set dislikes = comment.dislikes %} - {% set isReply = comment.hasParent %} - {% endif %} - - {% set hide_details = poster is null or comment.deleted and not perms.can_delete_any|default(false) %} - - {% if perms.can_delete_any|default(false) or (not comment.deleted or replies|length > 0) %} - <div class="comment{% if comment.deleted %} comment--deleted{% endif %}" id="comment-{{ comment.id }}"> - <div class="comment__container"> - {% if hide_details %} - <div class="comment__avatar"> - {{ avatar(0, indent > 1 ? 40 : 50) }} - </div> - {% else %} - <a class="comment__avatar" href="{{ url('user-profile', {'user': poster.id}) }}"> - {{ avatar(poster.id, indent > 1 ? 40 : 50, poster.name) }} - </a> - {% endif %} - <div class="comment__content"> - <div class="comment__info"> - {% if not hide_details %} - <a class="comment__user comment__user--link" - href="{{ url('user-profile', {'user': poster.id}) }}" - style="--user-colour: {{ commenterColour }}">{{ poster.name }}</a> - {% endif %} - <a class="comment__link" href="#comment-{{ comment.id }}"> - <time class="comment__date" - title="{{ comment.createdTime|date('r') }}" - datetime="{{ comment.createdTime|date('c') }}"> - {{ comment.createdTime|time_format }} - </time> - </a> - {% if comment.pinned %} - <span class="comment__pin">{% apply spaceless %} - Pinned - {% if comment.pinnedTime != comment.createdTime %} - <time title="{{ comment.pinnedTime|date('r') }}" - datetime="{{ comment.pinnedTime|date('c') }}"> - {{ comment.pinnedTime|time_format }} - </time> - {% endif %} - {% endapply %}</span> - {% endif %} - </div> - <div class="comment__text"> - {{ hide_details ? '(deleted)' : body }} - </div> - <div class="comment__actions"> - {% if not comment.deleted and user is not null %} - {% if perms.can_vote|default(false) %} - {% set like_vote_state = (userVote > 0 ? 0 : 1) %} - {% set dislike_vote_state = (userVote < 0 ? 0 : -1) %} - - <a class="comment__action comment__action--link comment__action--vote comment__action--like{% if userVote > 0 %} comment__action--voted{% endif %}" data-comment-id="{{ comment.id }}" data-comment-vote="{{ like_vote_state }}" - href="{{ url('comment-vote', { comment: comment.id, vote: like_vote_state, return: return_url, csrf: csrf_token() }) }}"> - Like - {% if likes > 0 %} - ({{ likes|number_format }}) - {% endif %} - </a> - <a class="comment__action comment__action--link comment__action--vote comment__action--dislike{% if userVote < 0 %} comment__action--voted{% endif %}" data-comment-id="{{ comment.id }}" data-comment-vote="{{ dislike_vote_state }}" - href="{{ url('comment-vote', { comment: comment.id, vote: dislike_vote_state, return: return_url, csrf: csrf_token() }) }}"> - Dislike - {% if dislikes > 0 %} - ({{ dislikes|number_format }}) - {% endif %} - </a> - {% endif %} - {% if perms.can_post|default(false) %} - <label class="comment__action comment__action--link" for="comment-reply-toggle-{{ comment.id }}">Reply</label> - {% endif %} - {% if perms.can_delete_any|default(false) or (poster.id|default(0) == user.id and perms.can_delete|default(false)) %} - <a class="comment__action comment__action--link comment__action--hide comment__action--delete" data-comment-id="{{ comment.id }}" href="{{ url('comment-delete', { comment: comment.id, return: return_url, csrf: csrf_token() }) }}">Delete</a> - {% endif %} - {# if user is not null %} - <a class="comment__action comment__action--link comment__action--hide" href="#">Report</a> - {% endif #} - {% if not isReply and perms.can_pin|default(false) %} - <a class="comment__action comment__action--link comment__action--hide comment__action--pin" data-comment-id="{{ comment.id }}" data-comment-pinned="{{ comment.pinned ? '1' : '0' }}" href="{{ url((comment.pinned ? 'comment-unpin' : 'comment-pin'), { comment: comment.id, return: return_url, csrf: csrf_token() }) }}">{{ comment.pinned ? 'Unpin' : 'Pin' }}</a> - {% endif %} - {% elseif perms.can_delete_any|default(false) %} - <a class="comment__action comment__action--link comment__action--restore" data-comment-id="{{ comment.id }}" href="{{ url('comment-restore', { comment: comment.id, return: return_url, csrf: csrf_token() }) }}">Restore</a> - {% endif %} - </div> - </div> - </div> - - <div class="comment__replies comment__replies--indent-{{ indent }}" id="comment-{{ comment.id }}-replies"> - {% from _self import comments_entry, comments_input %} - {% if user|default(null) is not null and category|default(null) is not null and perms.can_post|default(false) %} - {{ input_checkbox_raw('', false, 'comment__reply-toggle', '', false, {'id':'comment-reply-toggle-' ~ comment.id}) }} - {{ comments_input(category, user, perms, comment, return_url) }} - {% endif %} - {% if replies|length > 0 %} - {% for reply in replies %} - {{ comments_entry(reply, indent + 1, category, user, colour, perms, return_url) }} - {% endfor %} - {% endif %} - </div> - </div> - {% endif %} -{% endmacro %} - -{% macro comments_section(category, return_url) %} - {% set user = category.user %} - {% set colour = category.colour %} - {% set posts = category.posts %} - {% set perms = category.perms %} - {% set category = category.category %} - - <div class="comments" id="comments"> - <div class="comments__input"> - {% if user|default(null) is null %} - <div class="comments__notice"> - Please <a href="{{ url('auth-login') }}" class="comments__notice__link">login</a> to comment. - </div> - {% elseif category|default(null) is null %} - <div class="comments__notice"> - Posting new comments here is disabled. - </div> - {% elseif not perms.can_lock|default(false) and category.locked %} - <div class="comments__notice"> - This comment section was locked, <time datetime="{{ category.lockedTime|date('c') }}" title="{{ category.lockedTime|date('r') }}">{{ category.lockedTime|time_format }}</time>. - </div> - {% elseif not perms.can_post|default(false) %} - <div class="comments__notice"> - You are not allowed to post comments. - </div> - {% else %} - {% from _self import comments_input %} - {{ comments_input(category, user, perms, null, return_url) }} - {% endif %} - </div> - - {% if perms.can_lock|default(false) and category.locked %} - <div class="comments__notice comments__notice--staff"> - This comment section was locked, <time datetime="{{ category.lockedTime|date('c') }}" title="{{ category.lockedTime|date('r') }}">{{ category.lockedTime|time_format }}</time>. - </div> - {% endif %} - - <div class="comments__listing"> - {% if posts|length > 0 %} - {% from _self import comments_entry %} - {% for comment in posts %} - {{ comments_entry(comment, 1, category, user, colour, perms, return_url) }} - {% endfor %} - {% else %} - <div class="comments__none" id="_no_comments_notice_{{ category.id }}"> - There are no comments yet. - </div> - {% endif %} - </div> - </div> -{% endmacro %} diff --git a/templates/changelog/change.twig b/templates/changelog/change.twig index cecb46e3..889243a6 100644 --- a/templates/changelog/change.twig +++ b/templates/changelog/change.twig @@ -1,6 +1,5 @@ {% extends 'changelog/master.twig' %} {% from 'macros.twig' import container_title, avatar %} -{% from '_layout/comments.twig' import comments_section %} {% set title = 'Changelog ยป Change #' ~ change_info.id %} {% set canonical_url = url('changelog-change', {'change': change_info.id}) %} @@ -69,6 +68,6 @@ <div class="container"> {{ container_title('<i class="fas fa-comments fa-fw"></i> Comments for ' ~ change_info.date) }} - {{ comments_section(comments_info, canonical_url) }} + <div class="js-comments" data-category="{{ change_info.commentsCategoryName }}"></div> </div> {% endblock %} diff --git a/templates/changelog/index.twig b/templates/changelog/index.twig index f1b327d8..48ea3762 100644 --- a/templates/changelog/index.twig +++ b/templates/changelog/index.twig @@ -1,7 +1,6 @@ {% extends 'changelog/master.twig' %} {% from 'macros.twig' import pagination, container_title %} {% from 'changelog/macros.twig' import changelog_listing %} -{% from '_layout/comments.twig' import comments_section %} {% set is_date = changelog_date > 0 %} {% set is_user = changelog_user is not null %} @@ -50,10 +49,10 @@ {% endif %} </div> - {% if is_date %} + {% if comments_category_name is defined and comments_category_name is not null %} <div class="container"> {{ container_title('<i class="fas fa-comments fa-fw"></i> Comments') }} - {{ comments_section(comments_info, canonical_url) }} + <div class="js-comments" data-category="{{ comments_category_name }}"></div> </div> {% endif %} {% endblock %} diff --git a/templates/manage/news/posts.twig b/templates/manage/news/posts.twig index acf8ed4e..0080f95e 100644 --- a/templates/manage/news/posts.twig +++ b/templates/manage/news/posts.twig @@ -14,7 +14,6 @@ {{ post.title }} | {{ post.featured ? 'Featured' : 'Normal' }} | User #{{ post.userId }} | - {% if post.commentsSectionId is not null %}Comments category #{{ post.commentsSectionId }}{% else %}No comments category{% endif %} | Created {{ post.createdAt }} | {{ post.published ? 'published' : 'Published ' ~ post.scheduledAt }} | {{ post.edited ? 'Edited ' ~ post.updatedAt : 'not edited' }} | diff --git a/templates/news/post.twig b/templates/news/post.twig index 48734b30..30ef9249 100644 --- a/templates/news/post.twig +++ b/templates/news/post.twig @@ -1,6 +1,5 @@ {% extends 'news/master.twig' %} {% from 'macros.twig' import container_title %} -{% from '_layout/comments.twig' import comments_section %} {% from 'news/macros.twig' import news_post %} {% set title = post_info.title ~ ' :: News' %} @@ -10,10 +9,8 @@ {% block content %} {{ news_post(post_info, post_category_info, post_user_info, post_user_colour) }} - {% if comments_info is defined %} - <div class="container"> - {{ container_title('<i class="fas fa-comments fa-fw"></i> Comments') }} - {{ comments_section(comments_info, canonical_url) }} - </div> - {% endif %} + <div class="container"> + {{ container_title('<i class="fas fa-comments fa-fw"></i> Comments') }} + <div class="js-comments" data-category="{{ post_info.commentsCategoryName }}"></div> + </div> {% endblock %}