Post queue write api (#13473)

* move post queue from socket.io to rest api

* move harmony post-queue to core

add canEdit, allow users to edit their queued posts

* fix: openapi spec

* lint: whitespace
This commit is contained in:
Barış Uşaklı
2025-06-05 07:15:45 -04:00
committed by GitHub
parent 6d40a2118c
commit 4fbcfae8b1
13 changed files with 521 additions and 259 deletions

View File

@@ -1,7 +1,7 @@
get:
tags:
- admin
summary: Get flag data
summary: Get post queue
responses:
"200":
description: ""
@@ -42,6 +42,8 @@ get:
description: A user identifier
type:
type: string
canEdit:
type: boolean
data:
type: object
properties:

View File

@@ -202,6 +202,10 @@ paths:
$ref: 'write/posts/pid/diffs/timestamp.yaml'
/posts/{pid}/replies:
$ref: 'write/posts/pid/replies.yaml'
/posts/queue/{id}:
$ref: 'write/posts/queue/id.yaml'
/posts/queue/{id}/notify:
$ref: 'write/posts/queue/notify.yaml'
/chats/:
$ref: 'write/chats.yaml'
/chats/unread:

View File

@@ -0,0 +1,92 @@
post:
summary: Accept a queued post
tags:
- QueuedPosts
parameters:
- in: path
name: id
schema:
type: string
required: true
description: a valid queued post id
example: 2
responses:
'200':
description: post successfully accepted
content:
application/json:
schema:
type: object
properties:
status:
$ref: ../../../components/schemas/Status.yaml#/Status
response:
type: object
properties:
type:
type: string
pid:
type: number
tid:
type: number
'400':
description: Bad request, invalid post id
delete:
summary: Remove a queued post
tags:
- QueuedPosts
parameters:
- name: id
in: path
required: true
schema:
type: string
example: 'topic-12345'
responses:
'200':
description: Post removed successfully
content:
application/json:
schema:
type: object
properties:
status:
$ref: ../../../components/schemas/Status.yaml#/Status
response:
type: object
'400':
description: Bad request, invalid post id
put:
summary: Edit a queued post
tags:
- QueuedPosts
parameters:
- name: id
in: path
required: true
schema:
type: string
example: 'topic-12345'
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
content:
type: string
example: This is a test reply
cid:
type: number
description: Category ID to which the post belongs
title:
type: string
description: Updated Post Title
responses:
'200':
description: Post edited successfully
'400':
description: Bad request, invalid post id

View File

@@ -0,0 +1,36 @@
post:
summary: Notify the owner of a queued post
tags:
- QueuedPosts
parameters:
- in: path
name: id
schema:
type: string
required: true
description: a valid queued post id
example: 2
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
message:
type: string
example: body of the notification message
responses:
'200':
description: post successfully accepted
content:
application/json:
schema:
type: object
properties:
status:
$ref: ../../../components/schemas/Status.yaml#/Status
response:
type: object
'400':
description: Bad request, invalid post id

View File

@@ -69,14 +69,10 @@ define('forum/post-queue', [
const id = textarea.parents('[data-id]').attr('data-id');
const titleEdit = triggerClass === '[data-action="editTitle"]';
socket.emit('posts.editQueuedContent', {
id: id,
api.put(`/posts/queue/${id}`, {
title: titleEdit ? textarea.val() : undefined,
content: titleEdit ? undefined : textarea.val(),
}, function (err, data) {
if (err) {
return alerts.error(err);
}
}).then((data) => {
if (titleEdit) {
preview.find('.title-text').text(data.postData.title);
} else {
@@ -85,7 +81,7 @@ define('forum/post-queue', [
textarea.parent().addClass('hidden');
preview.removeClass('hidden');
});
}).catch(alerts.error);
});
}
@@ -96,8 +92,7 @@ define('forum/post-queue', [
onSubmit: function (selectedCategory) {
Promise.all([
api.get(`/categories/${selectedCategory.cid}`, {}),
socket.emit('posts.editQueuedContent', {
id: id,
api.put(`/posts/queue/${id}`, {
cid: selectedCategory.cid,
}),
]).then(function (result) {
@@ -174,6 +169,35 @@ define('forum/post-queue', [
async function handleQueueActions() {
// accept, reject, notify
const parent = $(this).parents('[data-id]');
const action = $(this).attr('data-action');
const id = parent.attr('data-id');
const listContainer = parent.get(0).parentNode;
if ((!['accept', 'reject', 'notify'].includes(action)) ||
(action === 'reject' && !await confirmReject(ajaxify.data.canAccept ? '[[post-queue:confirm-reject]]' : '[[post-queue:confirm-remove]]'))) {
return;
}
doAction(action, id).then(function () {
if (action === 'accept' || action === 'reject') {
parent.remove();
}
if (listContainer.childElementCount === 0) {
if (ajaxify.data.singlePost) {
ajaxify.go('/post-queue' + window.location.search);
} else {
ajaxify.refresh();
}
}
}).catch(alerts.error);
return false;
}
async function doAction(action, id) {
function getMessage() {
return new Promise((resolve) => {
const modal = bootbox.dialog({
@@ -194,36 +218,16 @@ define('forum/post-queue', [
});
}
const parent = $(this).parents('[data-id]');
const action = $(this).attr('data-action');
const id = parent.attr('data-id');
const listContainer = parent.get(0).parentNode;
if ((!['accept', 'reject', 'notify'].includes(action)) ||
(action === 'reject' && !await confirmReject(ajaxify.data.canAccept ? '[[post-queue:confirm-reject]]' : '[[post-queue:confirm-remove]]'))) {
return;
const actionsMap = {
accept: () => api.post(`/posts/queue/${id}`, {}),
reject: () => api.del(`/posts/queue/${id}`, {}),
notify: async () => api.post(`/posts/queue/${id}/notify`, { message: await getMessage() }),
};
if (actionsMap[action]) {
const result = actionsMap[action]();
return (result instanceof Promise ? result : Promise.resolve(result));
}
socket.emit('posts.' + action, {
id: id,
message: action === 'notify' ? await getMessage() : undefined,
}, function (err) {
if (err) {
return alerts.error(err);
}
if (action === 'accept' || action === 'reject') {
parent.remove();
}
if (listContainer.childElementCount === 0) {
if (ajaxify.data.singlePost) {
ajaxify.go('/post-queue' + window.location.search);
} else {
ajaxify.refresh();
}
}
});
return false;
throw new Error(`Unknown action: ${action}`);
}
function handleBulkActions() {
@@ -244,7 +248,7 @@ define('forum/post-queue', [
return;
}
const action = bulkAction.split('-')[0];
const promises = ids.map(id => socket.emit('posts.' + action, { id: id }));
const promises = ids.map(id => doAction(action, id));
Promise.allSettled(promises).then(function (results) {
const fulfilled = results.filter(res => res.status === 'fulfilled').length;

View File

@@ -17,6 +17,8 @@ const activitypub = require('../activitypub');
const apiHelpers = require('./helpers');
const websockets = require('../socket.io');
const socketHelpers = require('../socket.io/helpers');
const translator = require('../translator');
const notifications = require('../notifications');
const postsAPI = module.exports;
@@ -574,3 +576,91 @@ postsAPI.getReplies = async (caller, { pid }) => {
return postData;
};
postsAPI.acceptQueuedPost = async (caller, data) => {
await canEditQueue(caller.uid, data, 'accept');
const result = await posts.submitFromQueue(data.id);
if (result && caller.uid !== parseInt(result.uid, 10)) {
await sendQueueNotification('post-queue-accepted', result.uid, `/post/${result.pid}`);
}
await logQueueEvent(caller, result, 'accept');
return { type: result.type, pid: result.pid, tid: result.tid };
};
postsAPI.removeQueuedPost = async (caller, data) => {
await canEditQueue(caller.uid, data, 'reject');
const result = await posts.removeFromQueue(data.id);
if (result && caller.uid !== parseInt(result.uid, 10)) {
await sendQueueNotification('post-queue-rejected', result.uid, '/');
}
await logQueueEvent(caller, result, 'reject');
};
postsAPI.editQueuedPost = async (caller, data) => {
if (!data || !data.id || (!data.content && !data.title && !data.cid)) {
throw new Error('[[error:invalid-data]]');
}
await posts.editQueuedContent(caller.uid, data);
if (data.content) {
return await plugins.hooks.fire('filter:parse.post', { postData: data });
}
return { postData: data };
};
postsAPI.notifyQueuedPostOwner = async (caller, data) => {
await canEditQueue(caller.uid, data, 'notify');
const result = await posts.getFromQueue(data.id);
if (result) {
await sendQueueNotification('post-queue-notify', result.uid, `/post-queue/${data.id}`, validator.escape(String(data.message)));
}
};
async function canEditQueue(uid, data, action) {
const [canEditQueue, queuedPost] = await Promise.all([
posts.canEditQueue(uid, data, action),
posts.getFromQueue(data.id),
]);
if (!queuedPost) {
throw new Error('[[error:no-post]]');
}
if (!canEditQueue) {
throw new Error('[[error:no-privileges]]');
}
}
async function logQueueEvent(caller, result, type) {
const eventData = {
type: `post-queue-${result.type}-${type}`,
uid: caller.uid,
ip: caller.ip,
content: result.data.content,
targetUid: result.uid,
};
if (result.type === 'topic') {
eventData.cid = result.data.cid;
eventData.title = result.data.title;
} else {
eventData.tid = result.data.tid;
}
if (result.pid) {
eventData.pid = result.pid;
}
await events.log(eventData);
}
async function sendQueueNotification(type, targetUid, path, notificationText) {
const bodyShort = notificationText ?
translator.compile(`notifications:${type}`, notificationText) :
translator.compile(`notifications:${type}`);
const notifData = {
type: type,
nid: `${type}-${targetUid}-${path}`,
bodyShort: bodyShort,
path: path,
};
if (parseInt(meta.config.postQueueNotificationUid, 10) > 0) {
notifData.from = meta.config.postQueueNotificationUid;
}
const notifObj = await notifications.create(notifData);
await notifications.push(notifObj, [targetUid]);
}

View File

@@ -237,6 +237,7 @@ modsController.postQueue = async function (req, res, next) {
.map((post) => {
const isSelf = post.user.uid === req.uid;
post.canAccept = !isSelf && (isAdmin || isGlobalMod || !!moderatedCids.length);
post.canEdit = isSelf || isAdmin || isGlobalMod;
return post;
});

View File

@@ -189,3 +189,24 @@ Posts.getReplies = async (req, res) => {
helpers.formatApiResponse(200, res, { replies });
};
Posts.acceptQueuedPost = async (req, res) => {
const post = await api.posts.acceptQueuedPost(req, { id: req.params.id });
helpers.formatApiResponse(200, res, { post });
};
Posts.removeQueuedPost = async (req, res) => {
await api.posts.removeQueuedPost(req, { id: req.params.id });
helpers.formatApiResponse(200, res);
};
Posts.editQueuedPost = async (req, res) => {
const result = await api.posts.editQueuedPost(req, { id: req.params.id, ...req.body });
helpers.formatApiResponse(200, res, result);
};
Posts.notifyQueuedPostOwner = async (req, res) => {
const { id } = req.params;
await api.posts.notifyQueuedPostOwner(req, { id, message: req.body.message });
helpers.formatApiResponse(200, res);
};

View File

@@ -307,9 +307,11 @@ module.exports = function (Posts) {
if (data.type === 'topic') {
const result = await createTopic(data.data);
data.pid = result.postData.pid;
data.tid = result.topicData.tid;
} else if (data.type === 'reply') {
const result = await createReply(data.data);
data.pid = result.pid;
data.tid = result.tid;
}
await removeFromQueue(id);
plugins.hooks.fire('action:post-queue:submitFromQueue', { data: data });

View File

@@ -41,6 +41,12 @@ module.exports = function () {
setupApiRoute(router, 'get', '/:pid/replies', [middleware.assert.post], controllers.write.posts.getReplies);
setupApiRoute(router, 'post', '/queue/:id', controllers.write.posts.acceptQueuedPost);
setupApiRoute(router, 'delete', '/queue/:id', controllers.write.posts.removeQueuedPost);
setupApiRoute(router, 'put', '/queue/:id', controllers.write.posts.editQueuedPost);
setupApiRoute(router, 'post', '/queue/:id/notify', [middleware.checkRequired.bind(null, ['message'])], controllers.write.posts.notifyQueuedPostOwner);
// Shorthand route to access post routes by topic index
router.all('/+byIndex/:index*?', [middleware.checkRequired.bind(null, ['tid'])], controllers.write.posts.redirectByIndex);

View File

@@ -1,18 +1,10 @@
'use strict';
const validator = require('validator');
const db = require('../database');
const posts = require('../posts');
const privileges = require('../privileges');
const plugins = require('../plugins');
const meta = require('../meta');
const topics = require('../topics');
const notifications = require('../notifications');
const utils = require('../utils');
const events = require('../events');
const translator = require('../translator');
const api = require('../api');
const sockets = require('.');
@@ -99,90 +91,23 @@ SocketPosts.getReplies = async function (socket, pid) {
};
SocketPosts.accept = async function (socket, data) {
await canEditQueue(socket, data, 'accept');
const result = await posts.submitFromQueue(data.id);
if (result && socket.uid !== parseInt(result.uid, 10)) {
await sendQueueNotification('post-queue-accepted', result.uid, `/post/${result.pid}`);
}
await logQueueEvent(socket, result, 'accept');
sockets.warnDeprecated(socket, 'POST /api/v3/posts/queue/:id');
await api.posts.acceptQueuedPost(socket, data);
};
SocketPosts.reject = async function (socket, data) {
await canEditQueue(socket, data, 'reject');
const result = await posts.removeFromQueue(data.id);
if (result && socket.uid !== parseInt(result.uid, 10)) {
await sendQueueNotification('post-queue-rejected', result.uid, '/');
}
await logQueueEvent(socket, result, 'reject');
sockets.warnDeprecated(socket, 'DELETE /api/v3/posts/queue/:id');
await api.posts.removeQueuedPost(socket, data);
};
async function logQueueEvent(socket, result, type) {
const eventData = {
type: `post-queue-${result.type}-${type}`,
uid: socket.uid,
ip: socket.ip,
content: result.data.content,
targetUid: result.uid,
};
if (result.type === 'topic') {
eventData.cid = result.data.cid;
eventData.title = result.data.title;
} else {
eventData.tid = result.data.tid;
}
if (result.pid) {
eventData.pid = result.pid;
}
await events.log(eventData);
}
SocketPosts.notify = async function (socket, data) {
await canEditQueue(socket, data, 'notify');
const result = await posts.getFromQueue(data.id);
if (result) {
await sendQueueNotification('post-queue-notify', result.uid, `/post-queue/${data.id}`, validator.escape(String(data.message)));
}
sockets.warnDeprecated(socket, 'POST /api/v3/posts/queue/:id/notify');
await api.posts.notifyQueuedPostOwner(socket, data);
};
async function canEditQueue(socket, data, action) {
const [canEditQueue, queuedPost] = await Promise.all([
posts.canEditQueue(socket.uid, data, action),
posts.getFromQueue(data.id),
]);
if (!queuedPost) {
throw new Error('[[error:no-post]]');
}
if (!canEditQueue) {
throw new Error('[[error:no-privileges]]');
}
}
async function sendQueueNotification(type, targetUid, path, notificationText) {
const bodyShort = notificationText ?
translator.compile(`notifications:${type}`, notificationText) :
translator.compile(`notifications:${type}`);
const notifData = {
type: type,
nid: `${type}-${targetUid}-${path}`,
bodyShort: bodyShort,
path: path,
};
if (parseInt(meta.config.postQueueNotificationUid, 10) > 0) {
notifData.from = meta.config.postQueueNotificationUid;
}
const notifObj = await notifications.create(notifData);
await notifications.push(notifObj, [targetUid]);
}
SocketPosts.editQueuedContent = async function (socket, data) {
if (!data || !data.id || (!data.content && !data.title && !data.cid)) {
throw new Error('[[error:invalid-data]]');
}
await posts.editQueuedContent(socket.uid, data);
if (data.content) {
return await plugins.hooks.fire('filter:parse.post', { postData: data });
}
return { postData: data };
sockets.warnDeprecated(socket, 'PUT /api/v3/posts/queue/:id');
return await api.posts.editQueuedPost(socket, data);
};
require('../promisify')(SocketPosts);

View File

@@ -1,92 +1,158 @@
{{{ if isAdmin }}}
{{{ if !enabled }}}
<div class="alert alert-info">
[[post-queue:enabling-help, {config.relative_path}/admin/settings/post#post-queue]]
</div>
{{{ end }}}
{{{ else }}}
<div>
<p class="lead">[[post-queue:public-intro]]</p>
<p>[[post-queue:public-description]]</p>
<hr />
</div>
{{{ end }}}
{{{ if (!singlePost && posts.length) }}}
<div class="btn-toolbar justify-content-end">
<div class="me-2">
<!-- IMPORT partials/category/filter-dropdown-right.tpl -->
<div class="flex-fill">
{{{ if isAdmin }}}
{{{ if !enabled }}}
<div class="alert alert-info">
[[post-queue:enabling-help, {config.relative_path}/admin/settings/post#post-queue]]
</div>
<div class="btn-group bottom-sheet" component="post-queue/bulk-actions">
<button type="button" class="btn btn-ghost btn-sm dropdown-toggle d-flex gap-2 align-items-center" data-bs-toggle="dropdown" autocomplete="off" aria-haspopup="true" aria-expanded="false">
<i class="fa fa-clone text-primary"></i>
<span class="fw-semibold">[[post-queue:bulk-actions]]</span>
</button>
<ul class="dropdown-menu dropdown-menu-end p-1" role="menu">
{{{ if canAccept }}}
<li><a class="dropdown-item rounded-1" href="#" data-action="accept-all" role="menuitem">[[post-queue:accept-all]]</a></li>
<li><a class="dropdown-item rounded-1" href="#" data-action="accept-selected" role="menuitem">[[post-queue:accept-selected]]</a></li>
<li class="dropdown-divider"></li>
<li><a class="dropdown-item rounded-1" href="#" data-action="reject-all" role="menuitem">[[post-queue:reject-all]]</a></li>
<li><a class="dropdown-item rounded-1" href="#" data-action="reject-selected" role="menuitem">[[post-queue:reject-selected]]</a></li>
{{{ else }}}
<li><a class="dropdown-item rounded-1" href="#" data-action="reject-all">[[post-queue:remove-all]]</a></li>
<li><a class="dropdown-item rounded-1" href="#" data-action="reject-selected" role="menuitem">[[post-queue:remove-selected]]</a></li>
{{{ end }}}
</ul>
{{{ end }}}
{{{ else }}}
<div>
<p class="lead">[[post-queue:public-intro]]</p>
<p>[[post-queue:public-description]]</p>
<hr />
</div>
</div>
{{{ end }}}
<hr/>
{{{ end }}}
{{{ if (!singlePost && posts.length) }}}
<div class="btn-toolbar justify-content-end mb-3">
<div class="me-2">
<!-- IMPORT partials/category/filter-dropdown-right.tpl -->
</div>
<div class="row">
<div class="col-12">
<div class="post-queue preventSlideout posts-list">
{{{ if !posts.length }}}
{{{ if !singlePost }}}
<div class="mx-auto">
<div class="d-flex flex-column gap-3 justify-content-center text-center">
<div class="mx-auto p-4 bg-light border rounded">
<i class="text-secondary fa fa-fw fa-4x fa-seedling"></i>
</div>
[[post-queue:no-queued-posts]]
</div>
</div>
<div class="btn-group bottom-sheet" component="post-queue/bulk-actions">
<button type="button" class="btn btn-ghost btn-sm ff-secondary dropdown-toggle d-flex align-items-center gap-2" data-bs-toggle="dropdown" autocomplete="off" aria-haspopup="true" aria-expanded="false">
<i class="fa fa-clone text-primary"></i><span class="fw-semibold"> [[post-queue:bulk-actions]]</span>
</button>
<ul class="dropdown-menu p-1 text-sm dropdown-menu-end" role="menu">
{{{ if canAccept }}}
<li><a class="dropdown-item rounded-1" href="#" data-action="accept-all" role="menuitem">[[post-queue:accept-all]]</a></li>
<li><a class="dropdown-item rounded-1" href="#" data-action="accept-selected" role="menuitem">[[post-queue:accept-selected]]</a></li>
<li class="dropdown-divider"></li>
<li><a class="dropdown-item rounded-1" href="#" data-action="reject-all" role="menuitem">[[post-queue:reject-all]]</a></li>
<li><a class="dropdown-item rounded-1" href="#" data-action="reject-selected" role="menuitem">[[post-queue:reject-selected]]</a></li>
{{{ else }}}
<div class="alert alert-info d-flex align-items-md-center d-flex flex-column flex-md-row">
<p class="mb-md-0">[[post-queue:no-single-post]]</p>
<div class="d-grid ms-md-auto">
<a class="btn btn-sm btn-primary flex-shrink text-nowrap" href=".">[[post-queue:back-to-list]]</a>
</div>
</div>
<li><a class="dropdown-item rounded-1" href="#" data-action="reject-all" role="menuitem">[[post-queue:remove-all]]</a></li>
<li><a class="dropdown-item rounded-1" href="#" data-action="reject-selected" role="menuitem">[[post-queue:remove-selected]]</a></li>
{{{ end }}}
{{{ end }}}
</ul>
</div>
</div>
{{{ end }}}
{{{ each posts }}}
<div class="card mb-3" data-id="{./id}"data-uid="{./user.uid}">
<div class="card-header">
{{{ if !singlePost }}}
<input type="checkbox" class="form-check-input" autocomplete="off" />
{{{ end }}}
<strong>{{{ if posts.data.tid }}}[[post-queue:reply]]{{{ else }}}[[post-queue:topic]]{{{ end }}}</strong>
<span class="timeago float-end" title={posts.data.timestampISO}></span>
<div class="post-queue posts-list">
{{{ if !posts.length }}}
{{{ if !singlePost }}}
<div class="mx-auto">
<div class="d-flex flex-column gap-3 justify-content-center text-center">
<div class="mx-auto p-4 bg-light border rounded">
<i class="text-secondary fa fa-fw fa-4x fa-seedling"></i>
</div>
[[post-queue:no-queued-posts]]
</div>
<div class="card-body">
<div class="row">
<div class="col-lg-2 col-12">
<strong>[[post-queue:user]]</strong>
<div>
</div>
{{{ else }}}
<div class="alert alert-info d-flex align-items-md-center d-flex flex-column flex-md-row">
<p class="mb-md-0">[[post-queue:no-single-post]]</p>
<div class="d-grid ms-md-auto">
<a class="btn btn-sm btn-primary flex-shrink text-nowrap" href=".">[[post-queue:back-to-list]]</a>
</div>
</div>
{{{ end }}}
{{{ end }}}
{{{ each posts }}}
<div class="card mb-4" data-id="{./id}" data-uid="{./user.uid}">
<div class="row g-0">
<div class="col-lg-3 bg-card-cap rounded-start">
<ul class="list-unstyled ps-0 mb-0 border-end h-100">
<li class="card-body border-bottom position-relative">
{{{ if !singlePost }}}
<input id="{./id}" type="checkbox" class="form-check-input" autocomplete="off" />
{{{ end }}}
<label for="{./id}" class="small stretched-link">
{{{ if posts.data.tid }}}[[post-queue:reply]]{{{ else }}}[[post-queue:topic]]{{{ end }}}
</label>
</li>
<li class="card-body d-flex flex-column gap-1 border-bottom">
<div class="d-flex text-xs fw-semibold align-items-center">
[[post-queue:user]]
{{{ if ((privileges.ban || privileges.mute) || privileges.admin:users) }}}
<div class="ms-auto btn-group bottom-sheet">
<button href="#" class="btn btn-ghost btn-sm ff-secondary border text-xs dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">[[global:actions]]</button>
<ul class="dropdown-menu p-1 text-sm" role="menu">
{{{ if privileges.view:users:info }}}
<li><a class="dropdown-item rounded-1" href="{config.relative_path}/user/{./user.userslug}/info" role="menuitem">[[user:account-info]]</a></li>
{{{ end }}}
{{{ if privileges.ban }}}
<li class="{{{ if target.user.banned }}}hidden{{{ end }}}"><a class="dropdown-item rounded-1" href="#" data-action="ban" role="menuitem">[[user:ban-account]]</a></li>
<li class="{{{ if !target.user.banned }}}hidden{{{ end }}}"><a class="dropdown-item rounded-1" href="#" data-action="unban" role="menuitem">[[user:unban-account]]</a></li>
{{{ end }}}
{{{ if privileges.mute}}}
<li class="{{{ if target.user.muted }}}hidden{{{ end }}}"><a class="dropdown-item rounded-1" href="#" data-action="mute" role="menuitem">[[user:mute-account]]</a></li>
<li class="{{{ if !target.user.muted }}}hidden{{{ end }}}"><a class="dropdown-item rounded-1" href="#" data-action="unmute" role="menuitem">[[user:unmute-account]]</a></li>
{{{ end }}}
{{{ if privileges.admin:users }}}
<li><a class="dropdown-item rounded-1" href="#" data-action="delete-account" role="menuitem">[[user:delete-account-as-admin]]</a></li>
<li><a class="dropdown-item rounded-1" href="#" data-action="delete-content" role="menuitem">[[user:delete-content]]</a></li>
<li><a class="dropdown-item rounded-1" href="#" data-action="delete-all" role="menuitem">[[user:delete-all]]</a></li>
{{{ end }}}
</ul>
</div>
{{{ end }}}
</div>
<div class="small">
{{{ if posts.user.userslug}}}
<a href="{config.relative_path}/uid/{posts.user.uid}">{buildAvatar(posts.user, "24px", true, "not-responsive")} {posts.user.username}</a>
<a class="text-decoration-none" href="{config.relative_path}/uid/{posts.user.uid}">{buildAvatar(posts.user, "24px", true, "not-responsive")} {posts.user.username}</a>
{{{ else }}}
{posts.user.username}
{{{ end }}}
</div>
</div>
<div class="col-lg-3 col-12">
<strong>[[post-queue:category]]{{{ if posts.data.cid}}} <i class="fa fa-fw fa-edit" data-bs-toggle="tooltip" title="[[post-queue:category-editable]]"></i>{{{ end }}}</strong>
<div class="topic-category" {{{if posts.data.cid}}}data-editable="editable"{{{end}}}">
<div>
<span class="badge text-body border border-gray-300 stats text-xs">
<span title="{posts.user.postcount}" class="fw-bold">{humanReadableNumber(posts.user.postcount)}</span>
<span class="text-lowercase fw-normal">[[global:posts]]</span>
</span>
<span class="badge text-body border border-gray-300 stats text-xs">
<span title="{posts.user.reputation}" class="fw-bold">{humanReadableNumber(posts.user.reputation)}</span>
<span class="text-lowercase fw-normal">[[global:reputation]]</span>
</span>
<span class="badge text-body border border-gray-300 stats text-xs">
<span class="text-lowercase fw-normal">[[user:joined]]</span>
<span title="{posts.user.joindateISO}" class="timeago fw-bold"></span>
</span>
</div>
</li>
<li class="card-body border-bottom">
<div class="text-xs fw-semibold mb-1">[[post-queue:when]]</div>
<span class="small timeago" title={posts.data.timestampISO}></span>
</li>
<li class="card-body border-bottom">
<div class="text-xs fw-semibold mb-1">
{{{ if posts.data.tid }}}[[post-queue:topic]]{{{ else }}}[[post-queue:title]]{{{ end }}}
</div>
<span class="small topic-title text-break">
{{{ if posts.data.tid }}}
<div class="d-flex flex-column align-items-start gap-1">
<a href="{config.relative_path}/topic/{posts.data.tid}">{posts.topic.title}</a>
<span class="badge text-body border border-gray-300 stats text-xs">
<span class="text-lowercase fw-normal">[[global:lastpost]]</span>
<span title="{posts.topic.lastposttimeISO}" class="timeago fw-bold"></span>
</span>
</div>
{{{ end }}}
<span class="title-text">{posts.data.title}</span>
</span>
{{{if !posts.data.tid}}}
<div class="topic-title-editable hidden">
<input class="form-control" type="text" value="{posts.data.title}"/>
</div>
{{{end}}}
</li>
<li class="card-body border-bottom">
<div class="text-xs fw-semibold mb-1">
[[post-queue:category]]
</div>
<div class="topic-category small">
<a href="{config.relative_path}/category/{posts.category.slug}">
<div class="category-item d-inline-block">
{buildCategoryIcon(./category, "24px", "rounded-circle")}
@@ -94,46 +160,59 @@
</div>
</a>
</div>
</div>
<div class="col-lg-7 col-12">
<strong>{{{ if posts.data.tid }}}[[post-queue:topic]]{{{ else }}}[[post-queue:title]] <i class="fa fa-fw fa-edit" data-bs-toggle="tooltip" title="[[post-queue:title-editable]]"></i>{{{ end }}}</strong>
<div class="topic-title text-break">
{{{ if posts.data.tid }}}
<a href="{config.relative_path}/topic/{posts.data.tid}">{posts.topic.title}</a>
</li>
<li class="card-body">
<div class="row row-cols-2 g-1">
{{{ if ./canAccept }}}
<div class="col d-grid">
<button class="btn btn-success btn-sm" data-action="accept"><i class="fa fa-fw fa-check"></i> [[post-queue:accept]] </button>
</div>
<div class="col d-grid">
<button class="btn btn-danger btn-sm" data-action="reject"><i class="fa fa-fw fa-times"></i> [[post-queue:reject]]</button>
</div>
{{{ end }}}
{{{ if ./canEdit}}}
{{{ if !posts.data.tid }}}
<div class="col d-grid">
<button class="btn btn-light btn-sm" data-action="editTitle"><i class="fa fa-fw fa-edit"></i> [[post-queue:title]]</button>
</div>
{{{ end }}}
<div class="col d-grid">
<button class="btn btn-light btn-sm" data-action="editContent"><i class="fa fa-fw fa-edit"></i> [[post-queue:content]]</button>
</div>
{{{if posts.data.cid}}}
<div class="col d-grid">
<button class="btn btn-light btn-sm" data-action="editCategory"><i class="fa fa-fw fa-edit"></i> [[post-queue:category]]</button>
</div>
{{{ end }}}
{{{ end }}}
{{{ if ./canAccept }}}
<div class="col d-grid">
<button class="btn btn-light btn-sm" data-action="notify"><i class="fa fa-fw fa-bell-o"></i> [[post-queue:notify]]</button>
</div>
{{{ else }}}
<div class="col d-grid">
<button class="btn btn-danger btn-sm" data-action="reject"><i class="fa fa-fw fa-times"></i> [[post-queue:remove]]</button>
</div>
{{{ end }}}
<span data-action="editTitle" class="title-text">{posts.data.title}</span>
</div>
{{{if !posts.data.tid}}}
<div class="topic-title-editable hidden">
<input class="form-control" type="text" value="{posts.data.title}"/>
</div>
{{{end}}}
</div>
</div>
<hr/>
<div>
<strong>[[post-queue:content]] <i class="fa fa-fw fa-edit" data-bs-toggle="tooltip" title="[[post-queue:content-editable]]"></i></strong>
<div data-action="editContent" class="post-content text-break">{posts.data.content}</div>
<div class="post-content-editable hidden">
<textarea class="form-control w-100" style="height:300px;">{posts.data.rawContent}</textarea>
</div>
</div>
</li>
</ul>
</div>
<div class="card-footer text-end">
<div>
{{{ if ./canAccept }}}
<button class="btn btn-danger btn-sm" data-action="reject"><i class="fa fa-fw fa-times"></i> [[post-queue:reject]]</button>
<button class="btn btn-info btn-sm" data-action="notify"><i class="fa fa-fw fa-bell-o"></i> [[post-queue:notify]]</button>
<button class="btn btn-success btn-sm" data-action="accept"><i class="fa fa-fw fa-check"></i> [[post-queue:accept]] </button>
{{{ else }}}
<button class="btn btn-danger btn-sm" data-action="reject"><i class="fa fa-fw fa-times"></i> [[post-queue:remove]]</button>
{{{ end }}}
<div class="col-lg-9 d-flex flex-column">
<div class="post-content mb-auto text-break p-3 pb-0 h-100">{posts.data.content}</div>
<div class="post-content-editable flex-grow-1 hidden">
<textarea class="form-control w-100 h-100 p-3">{posts.data.rawContent}</textarea>
</div>
<div component="post-queue/link-container" class="hidden border-top mx-3 py-3">
<label class="text-secondary form-text mb-2">[[post-queue:links-in-this-post]]</label>
<ul component="post-queue/link-container/list" class="text-sm"></ul>
</div>
</div>
</div>
{{{ end }}}
</div>
<!-- IMPORT partials/paginator.tpl -->
{{{ end }}}
</div>
<!-- IMPORT partials/paginator.tpl -->
</div>

View File

@@ -985,15 +985,15 @@ describe('Post\'s', () => {
assert.equal(posts[1].data.content, 'this is a queued reply');
});
it('should error if data is invalid', (done) => {
socketPosts.editQueuedContent({ uid: globalModUid }, null, (err) => {
assert.equal(err.message, '[[error:invalid-data]]');
done();
});
it('should error if data is invalid', async () => {
await assert.rejects(
apiPosts.editQueuedPost({ uid: globalModUid }, null),
{ message: '[[error:invalid-data]]' },
);
});
it('should edit post in queue', async () => {
await socketPosts.editQueuedContent({ uid: globalModUid }, { id: queueId, content: 'newContent' });
await apiPosts.editQueuedPost({ uid: globalModUid }, { id: queueId, content: 'newContent' });
const { body } = await request.get(`${nconf.get('url')}/api/post-queue`, { jar });
const { posts } = body;
assert.equal(posts[1].type, 'reply');
@@ -1001,7 +1001,7 @@ describe('Post\'s', () => {
});
it('should edit topic title in queue', async () => {
await socketPosts.editQueuedContent({ uid: globalModUid }, { id: topicQueueId, title: 'new topic title' });
await apiPosts.editQueuedPost({ uid: globalModUid }, { id: topicQueueId, title: 'new topic title' });
const { body } = await request.get(`${nconf.get('url')}/api/post-queue`, { jar });
const { posts } = body;
assert.equal(posts[0].type, 'topic');
@@ -1009,39 +1009,39 @@ describe('Post\'s', () => {
});
it('should edit topic category in queue', async () => {
await socketPosts.editQueuedContent({ uid: globalModUid }, { id: topicQueueId, cid: 2 });
await apiPosts.editQueuedPost({ uid: globalModUid }, { id: topicQueueId, cid: 2 });
const { body } = await request.get(`${nconf.get('url')}/api/post-queue`, { jar });
const { posts } = body;
assert.equal(posts[0].type, 'topic');
assert.equal(posts[0].data.cid, 2);
await socketPosts.editQueuedContent({ uid: globalModUid }, { id: topicQueueId, cid: cid });
await apiPosts.editQueuedPost({ uid: globalModUid }, { id: topicQueueId, cid: cid });
});
it('should prevent regular users from approving posts', (done) => {
socketPosts.accept({ uid: uid }, { id: queueId }, (err) => {
assert.equal(err.message, '[[error:no-privileges]]');
done();
});
it('should prevent regular users from approving posts', async () => {
await assert.rejects(
apiPosts.acceptQueuedPost({ uid: uid }, { id: queueId }),
{ message: '[[error:no-privileges]]' },
);
});
it('should prevent regular users from approving non existing posts', (done) => {
socketPosts.accept({ uid: uid }, { id: 123123 }, (err) => {
assert.equal(err.message, '[[error:no-post]]');
done();
});
it('should prevent regular users from approving non existing posts', async () => {
await assert.rejects(
apiPosts.acceptQueuedPost({ uid: uid }, { id: 123123 }),
{ message: '[[error:no-post]]' },
);
});
it('should accept queued posts and submit', async () => {
const ids = await db.getSortedSetRange('post:queue', 0, -1);
await socketPosts.accept({ uid: globalModUid }, { id: ids[0] });
await socketPosts.accept({ uid: globalModUid }, { id: ids[1] });
await apiPosts.acceptQueuedPost({ uid: globalModUid }, { id: ids[0] });
await apiPosts.acceptQueuedPost({ uid: globalModUid }, { id: ids[1] });
});
it('should not crash if id does not exist', (done) => {
socketPosts.reject({ uid: globalModUid }, { id: '123123123' }, (err) => {
assert.equal(err.message, '[[error:no-post]]');
done();
});
it('should not crash if id does not exist', async () => {
await assert.rejects(
apiPosts.removeQueuedPost({ uid: globalModUid }, { id: '123123123' }),
{ message: '[[error:no-post]]' },
);
});
it('should bypass post queue if user is in exempt group', async () => {