mirror of
https://github.com/NodeBB/NodeBB.git
synced 2026-02-28 09:31:17 +01:00
Merge commit '1b4e0c87260b4d2c5eb9b0b0f954480182ff2a96' into v4.x
This commit is contained in:
31
CHANGELOG.md
31
CHANGELOG.md
@@ -1,3 +1,34 @@
|
||||
#### v4.0.2 (2025-02-02)
|
||||
|
||||
##### Chores
|
||||
|
||||
* up persona (0298a3af)
|
||||
* up harmony (d77d2055)
|
||||
* up themes, closes #13102 (6672de00)
|
||||
* incrementing version number - v4.0.1 (a461b758)
|
||||
* update changelog for v4.0.1 (3dbd2b30)
|
||||
* incrementing version number - v4.0.0 (c1eaee45)
|
||||
|
||||
##### New Features
|
||||
|
||||
* allow selecting empty for custom selects (be62ae24)
|
||||
* add uid to post.parent (4d733590)
|
||||
* add description and keywords to api/config (933c18f4)
|
||||
|
||||
##### Bug Fixes
|
||||
|
||||
* bad logic that invisibly broke outgoing user follows completely (51e660d5)
|
||||
* closes #13096, fix regression from renaming language files (0b92d525)
|
||||
|
||||
##### Refactors
|
||||
|
||||
* remove old comment (d4a1b4da)
|
||||
|
||||
##### Tests
|
||||
|
||||
* fix schema (ef5ae006)
|
||||
* fix schema (47734d4c)
|
||||
|
||||
#### v4.0.1 (2025-01-29)
|
||||
|
||||
##### Chores
|
||||
|
||||
@@ -108,10 +108,10 @@
|
||||
"nodebb-plugin-spam-be-gone": "2.3.0",
|
||||
"nodebb-plugin-web-push": "0.7.2",
|
||||
"nodebb-rewards-essentials": "1.0.0",
|
||||
"nodebb-theme-harmony": "2.0.7",
|
||||
"nodebb-theme-harmony": "2.0.18",
|
||||
"nodebb-theme-lavender": "7.1.17",
|
||||
"nodebb-theme-peace": "2.2.36",
|
||||
"nodebb-theme-persona": "14.0.8",
|
||||
"nodebb-theme-peace": "2.2.38",
|
||||
"nodebb-theme-persona": "14.0.14",
|
||||
"nodebb-widget-essentials": "7.0.32",
|
||||
"nodemailer": "6.9.16",
|
||||
"nprogress": "0.2.0",
|
||||
@@ -200,4 +200,4 @@
|
||||
"url": "https://github.com/barisusakli"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"settings.mobileTopicTeasers": "Show topic teasers on mobile",
|
||||
"settings.stickyToolbar": "Sticky toolbar",
|
||||
"settings.stickyToolbar.help": "The toolbar on topic and category pages will stick to the top of the page",
|
||||
"settings.topicSidebarTools": "Topic sidebar tools",
|
||||
"settings.topicSidebarTools.help": "This option will move the topic tools to the sidebar on desktop",
|
||||
"settings.autohideBottombar": "Auto hide bottom bar",
|
||||
"settings.autohideBottombar.help": "The bottom bar on mobile view will be hidden when the page is scrolled down",
|
||||
"settings.openSidebars": "Open sidebars",
|
||||
|
||||
@@ -27,6 +27,8 @@ get:
|
||||
type: string
|
||||
keywords:
|
||||
type: string
|
||||
brand:logo:
|
||||
type: string
|
||||
titleLayout:
|
||||
type: string
|
||||
showSiteTitle:
|
||||
|
||||
@@ -27,6 +27,8 @@ get:
|
||||
type: string
|
||||
keywords:
|
||||
type: string
|
||||
brand:logo:
|
||||
type: string
|
||||
titleLayout:
|
||||
type: string
|
||||
showSiteTitle:
|
||||
|
||||
@@ -50,19 +50,6 @@ get:
|
||||
type: array
|
||||
items:
|
||||
$ref: ../../components/schemas/PostObject.yaml#/PostDataObject
|
||||
events:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
id:
|
||||
type: number
|
||||
timestamp:
|
||||
type: number
|
||||
timestampISO:
|
||||
type: string
|
||||
category:
|
||||
$ref: ../../components/schemas/CategoryObject.yaml#/CategoryObject
|
||||
tagWhitelist:
|
||||
|
||||
@@ -154,11 +154,9 @@ body {
|
||||
}
|
||||
|
||||
.dropdown-left {
|
||||
[component="category-selector-search"] { left:0!important; }
|
||||
.dropdown-menu { --bs-position: start; }
|
||||
}
|
||||
.dropdown-right {
|
||||
[component="category-selector-search"] { right:0!important; }
|
||||
.dropdown-menu { --bs-position: end; }
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,12 @@ body.page-user-chats {
|
||||
}
|
||||
}
|
||||
|
||||
[component="chat/message/parent"] {
|
||||
[component="chat/message/parent/content"] > p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.expanded-chat {
|
||||
.chat-content {
|
||||
.message-body {
|
||||
|
||||
@@ -24,11 +24,9 @@
|
||||
}
|
||||
}
|
||||
.dropdown-left {
|
||||
[component="category-selector-search"] { left:0!important; }
|
||||
.dropdown-menu { --bs-position: start; }
|
||||
}
|
||||
.dropdown-right {
|
||||
[component="category-selector-search"] { right:0!important; }
|
||||
.dropdown-menu { --bs-position: end; }
|
||||
}
|
||||
|
||||
|
||||
@@ -136,11 +136,13 @@ define('admin/extend/plugins', [
|
||||
|
||||
require(['compare-versions'], function (compareVersions) {
|
||||
const currentVersion = parent.find('.currentVersion').text();
|
||||
if (payload.version !== 'latest' && compareVersions.compare(payload.version, currentVersion, '>')) {
|
||||
if (payload.version && payload.version !== 'latest' && compareVersions.compare(payload.version, currentVersion, '>')) {
|
||||
upgrade(pluginID, btn, payload.version);
|
||||
} else if (payload.version === 'latest') {
|
||||
confirmInstall(pluginID, function () {
|
||||
upgrade(pluginID, btn, payload.version);
|
||||
} else if (payload.version === 'latest' || payload.version === null) {
|
||||
confirmInstall(pluginID, function (confirm) {
|
||||
if (confirm) {
|
||||
upgrade(pluginID, btn, payload.version);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
bootbox.alert(translator.compile('admin/extend/plugins:alert.incompatible', app.config.version, payload.version));
|
||||
|
||||
@@ -24,7 +24,7 @@ define('forum/topic', [
|
||||
bootbox, clipboard
|
||||
) {
|
||||
const Topic = {};
|
||||
let tid = 0;
|
||||
let tid = '0';
|
||||
let currentUrl = '';
|
||||
|
||||
$(window).on('action:ajaxify.start', function (ev, data) {
|
||||
@@ -38,8 +38,8 @@ define('forum/topic', [
|
||||
});
|
||||
|
||||
Topic.init = async function () {
|
||||
const tidChanged = !tid || parseInt(tid, 10) !== parseInt(ajaxify.data.tid, 10);
|
||||
tid = ajaxify.data.tid;
|
||||
const tidChanged = tid === '0' || String(tid) !== String(ajaxify.data.tid);
|
||||
tid = String(ajaxify.data.tid);
|
||||
currentUrl = ajaxify.currentPage;
|
||||
hooks.fire('action:topic.loading');
|
||||
|
||||
@@ -264,15 +264,32 @@ define('forum/topic', [
|
||||
}
|
||||
|
||||
function addParentHandler() {
|
||||
components.get('topic').on('click', '[component="post/parent"]', function (e) {
|
||||
const toPid = $(this).attr('data-topid');
|
||||
|
||||
function gotoPost(event, toPid) {
|
||||
const toPost = $('[component="topic"]>[component="post"][data-pid="' + toPid + '"]');
|
||||
if (toPost.length) {
|
||||
e.preventDefault();
|
||||
event.preventDefault();
|
||||
navigator.scrollToIndex(toPost.attr('data-index'), true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
components.get('topic').on('click', '[component="post/parent"]', function (e) {
|
||||
const parentEl = $(this);
|
||||
const contentEl = parentEl.find('[component="post/parent/content"]');
|
||||
if (contentEl.length) {
|
||||
const isCollapsed = contentEl.hasClass('line-clamp-1');
|
||||
contentEl.toggleClass('line-clamp-1');
|
||||
parentEl.find('.timeago').toggleClass('hidden');
|
||||
parentEl.toggleClass('flex-column').toggleClass('flex-row');
|
||||
if (isCollapsed) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return gotoPost(e, parentEl.attr('data-topid'));
|
||||
}
|
||||
});
|
||||
|
||||
components.get('topic').on('click', '[component="post/parent"] .timeago', function (e) {
|
||||
return gotoPost(e, $(this).parents('[data-parent-pid]').attr('data-parent-pid'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -298,7 +315,7 @@ define('forum/topic', [
|
||||
destroyed = true;
|
||||
}
|
||||
$(window).one('action:ajaxify.start', destroyTooltip);
|
||||
$('[component="topic"]').on('mouseenter', '[component="post/parent"], [component="post/content"] a, [component="topic/event"] a', async function () {
|
||||
$('[component="topic"]').on('mouseenter', 'a[component="post/parent"], [component="post/content"] a, [component="topic/event"] a', async function () {
|
||||
const link = $(this);
|
||||
destroyed = false;
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ define('forum/topic/events', [
|
||||
|
||||
function updateBookmarkCount(data) {
|
||||
$('[data-pid="' + data.post.pid + '"] .bookmarkCount').filter(function (index, el) {
|
||||
return parseInt($(el).closest('[data-pid]').attr('data-pid'), 10) === parseInt(data.post.pid, 10);
|
||||
return $(el).closest('[data-pid]').attr('data-pid') === String(data.post.pid);
|
||||
}).html(data.post.bookmarks).attr('data-bookmarks', data.post.bookmarks);
|
||||
}
|
||||
|
||||
@@ -88,14 +88,14 @@ define('forum/topic/events', [
|
||||
if (
|
||||
ajaxify.data.category &&
|
||||
ajaxify.data.category.slug &&
|
||||
parseInt(data.tid, 10) === parseInt(ajaxify.data.tid, 10)
|
||||
String(data.tid) === String(ajaxify.data.tid)
|
||||
) {
|
||||
ajaxify.go('category/' + ajaxify.data.category.slug, null, true);
|
||||
}
|
||||
}
|
||||
|
||||
function onTopicMoved(data) {
|
||||
if (data && data.slug && parseInt(data.tid, 10) === parseInt(ajaxify.data.tid, 10)) {
|
||||
if (data && data.slug && String(data.tid) === String(ajaxify.data.tid)) {
|
||||
ajaxify.go('topic/' + data.slug, null, true);
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,17 @@ define('forum/topic/events', [
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const parentEl = $(`[component="post/parent"][data-parent-pid="${data.post.pid}"]`);
|
||||
if (parentEl.length) {
|
||||
parentEl.find('[component="post/parent/content"]').html(
|
||||
translator.unescape(data.post.content)
|
||||
);
|
||||
parentEl.find('img:not(.not-responsive)').addClass('img-fluid');
|
||||
parentEl.find('[component="post/parent/content]" img:not(.emoji)').each(function () {
|
||||
images.wrapImageInLink($(this));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
hooks.fire('action:posts.edited', data);
|
||||
}
|
||||
@@ -173,7 +184,7 @@ define('forum/topic/events', [
|
||||
}
|
||||
|
||||
function onPostPurged(postData) {
|
||||
if (!postData || parseInt(postData.tid, 10) !== parseInt(ajaxify.data.tid, 10)) {
|
||||
if (!postData || String(postData.tid) !== String(ajaxify.data.tid)) {
|
||||
return;
|
||||
}
|
||||
components.get('post', 'pid', postData.pid).fadeOut(500, function () {
|
||||
@@ -185,32 +196,45 @@ define('forum/topic/events', [
|
||||
require(['forum/topic/replies'], function (replies) {
|
||||
replies.onPostPurged(postData);
|
||||
});
|
||||
$(`[component="post/parent"][data-parent-pid="${postData.pid}"]`).remove();
|
||||
}
|
||||
|
||||
function togglePostDeleteState(data) {
|
||||
const postEl = components.get('post', 'pid', data.pid);
|
||||
|
||||
if (!postEl.length) {
|
||||
return;
|
||||
const { isAdminOrMod } = ajaxify.data.privileges;
|
||||
const isSelfPost = String(data.uid) === String(app.user.uid);
|
||||
const isDeleted = !!data.deleted;
|
||||
if (postEl.length) {
|
||||
postEl.toggleClass('deleted');
|
||||
postTools.toggle(data.pid, isDeleted);
|
||||
|
||||
if (!isAdminOrMod && !isSelfPost) {
|
||||
postEl.find('[component="post/tools"]').toggleClass('hidden', isDeleted);
|
||||
if (isDeleted) {
|
||||
postEl.find('[component="post/content"]').translateHtml('[[topic:post-is-deleted]]');
|
||||
} else {
|
||||
postEl.find('[component="post/content"]').html(translator.unescape(data.content));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
postEl.toggleClass('deleted');
|
||||
const isDeleted = postEl.hasClass('deleted');
|
||||
postTools.toggle(data.pid, isDeleted);
|
||||
|
||||
if (!ajaxify.data.privileges.isAdminOrMod && parseInt(data.uid, 10) !== parseInt(app.user.uid, 10)) {
|
||||
postEl.find('[component="post/tools"]').toggleClass('hidden', isDeleted);
|
||||
if (isDeleted) {
|
||||
postEl.find('[component="post/content"]').translateHtml('[[topic:post-is-deleted]]');
|
||||
} else {
|
||||
postEl.find('[component="post/content"]').html(translator.unescape(data.content));
|
||||
}
|
||||
const parentEl = $(`[component="post/parent"][data-parent-pid="${data.pid}"]`);
|
||||
if (parentEl.length) {
|
||||
parentEl.each((i, el) => {
|
||||
const $parent = $(el);
|
||||
if (isDeleted) {
|
||||
$parent.find('[component="post/parent/content"]').translateHtml('[[topic:post-is-deleted]]');
|
||||
} else {
|
||||
$parent.find('[component="post/parent/content"]').html(translator.unescape(data.content));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function togglePostBookmark(data) {
|
||||
const el = $('[data-pid="' + data.post.pid + '"] [component="post/bookmark"]').filter(function (index, el) {
|
||||
return parseInt($(el).closest('[data-pid]').attr('data-pid'), 10) === parseInt(data.post.pid, 10);
|
||||
return $(el).closest('[data-pid]').attr('data-pid') === String(data.post.pid);
|
||||
});
|
||||
if (!el.length) {
|
||||
return;
|
||||
@@ -234,7 +258,7 @@ define('forum/topic/events', [
|
||||
|
||||
function onNewNotification(data) {
|
||||
const tid = ajaxify.data.tid;
|
||||
if (data && data.tid && parseInt(data.tid, 10) === parseInt(tid, 10)) {
|
||||
if (data && data.tid && String(data.tid) === String(tid)) {
|
||||
socket.emit('topics.markTopicNotificationsRead', [tid]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ define('forum/topic/move-post', [
|
||||
const tidInput = moveModal.find('#topicId');
|
||||
let targetTid = null;
|
||||
if (ajaxify.data.template.topic && ajaxify.data.tid &&
|
||||
parseInt(ajaxify.data.tid, 10) !== fromTid
|
||||
String(ajaxify.data.tid) !== String(fromTid)
|
||||
) {
|
||||
targetTid = ajaxify.data.tid;
|
||||
}
|
||||
@@ -98,8 +98,8 @@ define('forum/topic/move-post', [
|
||||
}
|
||||
const targetTid = getTargetTid();
|
||||
if (postSelect.pids.length) {
|
||||
if (targetTid && parseInt(targetTid, 10) !== parseInt(fromTid, 10)) {
|
||||
api.get('/topics/' + targetTid, {}).then(function (data) {
|
||||
if (targetTid && String(targetTid) !== String(fromTid)) {
|
||||
api.get(`/topics/${targetTid}`, {}).then(function (data) {
|
||||
if (!data || !data.tid) {
|
||||
return alerts.error('[[error:no-topic]]');
|
||||
}
|
||||
@@ -123,7 +123,7 @@ define('forum/topic/move-post', [
|
||||
}
|
||||
const targetTid = getTargetTid();
|
||||
if (postSelect.pids.length && targetTid &&
|
||||
parseInt(targetTid, 10) !== parseInt(fromTid, 10)
|
||||
String(targetTid) !== String(fromTid)
|
||||
) {
|
||||
moveCommit.removeAttr('disabled');
|
||||
} else {
|
||||
@@ -150,7 +150,7 @@ define('forum/topic/move-post', [
|
||||
});
|
||||
});
|
||||
if (data.pids.length && ajaxify.data.template.topic &&
|
||||
parseInt(data.tid, 10) === parseInt(ajaxify.data.tid, 10)) {
|
||||
String(data.tid) === String(ajaxify.data.tid)) {
|
||||
ajaxify.go(`/post/${data.pids[0]}`);
|
||||
}
|
||||
closeMoveModal();
|
||||
|
||||
@@ -22,7 +22,7 @@ define('forum/topic/posts', [
|
||||
!data ||
|
||||
!data.posts ||
|
||||
!data.posts.length ||
|
||||
parseInt(data.posts[0].tid, 10) !== parseInt(ajaxify.data.tid, 10)
|
||||
String(data.posts[0].tid) !== String(ajaxify.data.tid)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ define('forum/topic/replies', ['forum/topic/posts', 'hooks', 'alerts', 'api'], f
|
||||
'downvote:disabled': ajaxify.data['downvote:disabled'],
|
||||
'reputation:disabled': ajaxify.data['reputation:disabled'],
|
||||
loggedIn: !!app.user.uid,
|
||||
hideParent: true,
|
||||
hideReplies: config.hasOwnProperty('showNestedReplies') ? !config.showNestedReplies : true,
|
||||
};
|
||||
app.parseAndTranslate('topic', 'posts', tplData, async function (html) {
|
||||
|
||||
@@ -294,7 +294,7 @@ define('forum/topic/threadTools', [
|
||||
|
||||
ThreadTools.setLockedState = function (data) {
|
||||
const threadEl = components.get('topic');
|
||||
if (parseInt(data.tid, 10) !== parseInt(threadEl.attr('data-tid'), 10)) {
|
||||
if (String(data.tid) !== threadEl.attr('data-tid')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -322,7 +322,7 @@ define('forum/topic/threadTools', [
|
||||
|
||||
ThreadTools.setDeleteState = function (data) {
|
||||
const threadEl = components.get('topic');
|
||||
if (parseInt(data.tid, 10) !== parseInt(threadEl.attr('data-tid'), 10)) {
|
||||
if (String(data.tid) !== threadEl.attr('data-tid')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -356,7 +356,7 @@ define('forum/topic/threadTools', [
|
||||
|
||||
ThreadTools.setPinnedState = function (data) {
|
||||
const threadEl = components.get('topic');
|
||||
if (parseInt(data.tid, 10) !== parseInt(threadEl.attr('data-tid'), 10)) {
|
||||
if (String(data.tid) !== threadEl.attr('data-tid')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,16 +21,12 @@ define('categorySearch', ['alerts', 'bootstrap', 'api'], function (alerts, boots
|
||||
return;
|
||||
}
|
||||
|
||||
const toggleVisibility = searchEl.parent('[component="category/dropdown"]').length > 0 ||
|
||||
searchEl.parent('[component="category-selector"]').length > 0;
|
||||
const toggleVisibility = searchEl.parents('[component="category/dropdown"]').length > 0 ||
|
||||
searchEl.parents('[component="category-selector"]').length > 0;
|
||||
|
||||
el.on('show.bs.dropdown', function () {
|
||||
if (toggleVisibility) {
|
||||
el.find('.dropdown-toggle').css({ visibility: 'hidden' });
|
||||
searchEl.removeClass('hidden');
|
||||
searchEl.css({
|
||||
'z-index': el.find('.dropdown-toggle').css('z-index') + 1,
|
||||
});
|
||||
}
|
||||
|
||||
function doSearch() {
|
||||
@@ -61,7 +57,6 @@ define('categorySearch', ['alerts', 'bootstrap', 'api'], function (alerts, boots
|
||||
|
||||
el.on('hide.bs.dropdown', function () {
|
||||
if (toggleVisibility) {
|
||||
el.find('.dropdown-toggle').css({ visibility: 'inherit' });
|
||||
searchEl.addClass('hidden');
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ define('groupSearch', function () {
|
||||
if (!searchEl.length) {
|
||||
return;
|
||||
}
|
||||
const toggleVisibility = searchEl.parent('[component="group-selector"]').length > 0;
|
||||
const toggleVisibility = searchEl.parents('[component="group-selector"]').length > 0;
|
||||
|
||||
const groupEls = el.find('[component="group-list"] [data-name]');
|
||||
el.on('show.bs.dropdown', function () {
|
||||
@@ -31,11 +31,7 @@ define('groupSearch', function () {
|
||||
el.find('[component="group-list"] [component="group-no-matches"]').toggleClass('hidden', !noMatch);
|
||||
}
|
||||
if (toggleVisibility) {
|
||||
el.find('.dropdown-toggle').css({ visibility: 'hidden' });
|
||||
searchEl.removeClass('hidden');
|
||||
searchEl.css({
|
||||
'z-index': el.find('.dropdown-toggle').css('z-index') + 1,
|
||||
});
|
||||
}
|
||||
|
||||
searchEl.on('click', function (ev) {
|
||||
@@ -52,7 +48,6 @@ define('groupSearch', function () {
|
||||
|
||||
el.on('hide.bs.dropdown', function () {
|
||||
if (toggleVisibility) {
|
||||
el.find('.dropdown-toggle').css({ visibility: 'inherit' });
|
||||
searchEl.addClass('hidden');
|
||||
}
|
||||
searchEl.off('click').find('input').off('keyup');
|
||||
|
||||
@@ -27,16 +27,12 @@ define('tagFilter', ['hooks', 'alerts', 'bootstrap'], function (hooks, alerts, b
|
||||
}
|
||||
initialTags = selectedTags.slice();
|
||||
|
||||
const toggleSearchVisibilty = searchEl.parent('[component="tag/filter"]').length &&
|
||||
const toggleSearchVisibilty = searchEl.parents('[component="tag/filter"]').length &&
|
||||
app.user.privileges['search:tags'];
|
||||
|
||||
el.on('show.bs.dropdown', function () {
|
||||
if (toggleSearchVisibilty) {
|
||||
el.find('.dropdown-toggle').css({ visibility: 'hidden' });
|
||||
searchEl.removeClass('hidden');
|
||||
searchEl.css({
|
||||
'z-index': el.find('.dropdown-toggle').css('z-index') + 1,
|
||||
});
|
||||
}
|
||||
|
||||
function doSearch() {
|
||||
@@ -67,7 +63,6 @@ define('tagFilter', ['hooks', 'alerts', 'bootstrap'], function (hooks, alerts, b
|
||||
|
||||
el.on('hidden.bs.dropdown', function () {
|
||||
if (toggleSearchVisibilty) {
|
||||
el.find('.dropdown-toggle').css({ visibility: 'inherit' });
|
||||
searchEl.addClass('hidden');
|
||||
}
|
||||
|
||||
|
||||
@@ -187,12 +187,12 @@ Mocks.post = async (objects) => {
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case image && image.hasOwnProperty('url') && image.url && mime.getType(image.url).startsWith('image/'): {
|
||||
case image && image.hasOwnProperty('url') && image.url: {
|
||||
image = image.url;
|
||||
break;
|
||||
}
|
||||
|
||||
case image && typeof image === 'string' && mime.getType(image).startsWith('image/'): {
|
||||
case image && typeof image === 'string': {
|
||||
// no change
|
||||
break;
|
||||
}
|
||||
@@ -201,6 +201,13 @@ Mocks.post = async (objects) => {
|
||||
image = null;
|
||||
}
|
||||
}
|
||||
if (image) {
|
||||
const parsed = new URL(image);
|
||||
if (!mime.getType(parsed.pathname).startsWith('image/')) {
|
||||
activitypub.helpers.log(`[activitypub/mocks.post] Received image not identified as image due to MIME type: ${image}`);
|
||||
image = null;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
uid,
|
||||
|
||||
@@ -60,6 +60,7 @@ topicsAPI.create = async function (caller, data) {
|
||||
}
|
||||
|
||||
const payload = { ...data };
|
||||
delete payload.tid;
|
||||
payload.tags = payload.tags || [];
|
||||
apiHelpers.setDefaultPostData(caller, payload);
|
||||
const isScheduling = parseInt(data.timestamp, 10) > payload.timestamp;
|
||||
@@ -98,6 +99,7 @@ topicsAPI.reply = async function (caller, data) {
|
||||
throw new Error('[[error:invalid-data]]');
|
||||
}
|
||||
const payload = { ...data };
|
||||
delete payload.pid;
|
||||
apiHelpers.setDefaultPostData(caller, payload);
|
||||
|
||||
await meta.blacklist.test(caller.ip);
|
||||
|
||||
@@ -17,7 +17,6 @@ require('./data')(Categories);
|
||||
require('./create')(Categories);
|
||||
require('./delete')(Categories);
|
||||
require('./topics')(Categories);
|
||||
require('./unread')(Categories);
|
||||
require('./activeusers')(Categories);
|
||||
require('./recentreplies')(Categories);
|
||||
require('./update')(Categories);
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const db = require('../database');
|
||||
|
||||
module.exports = function (Categories) {
|
||||
Categories.markAsRead = async function (cids, uid) {
|
||||
// TODO: remove in 4.0
|
||||
console.warn('[deprecated] Categories.markAsRead deprecated');
|
||||
if (!Array.isArray(cids) || !cids.length || parseInt(uid, 10) <= 0) {
|
||||
return;
|
||||
}
|
||||
let keys = cids.map(cid => `cid:${cid}:read_by_uid`);
|
||||
const hasRead = await db.isMemberOfSets(keys, uid);
|
||||
keys = keys.filter((key, index) => !hasRead[index]);
|
||||
await db.setsAdd(keys, uid);
|
||||
};
|
||||
|
||||
Categories.markAsUnreadForAll = async function (cid) {
|
||||
// TODO: remove in 4.0
|
||||
console.warn('[deprecated] Categories.markAsUnreadForAll deprecated');
|
||||
if (!parseInt(cid, 10)) {
|
||||
return;
|
||||
}
|
||||
await db.delete(`cid:${cid}:read_by_uid`);
|
||||
};
|
||||
|
||||
Categories.hasReadCategories = async function (cids, uid) {
|
||||
// TODO: remove in 4.0
|
||||
console.warn('[deprecated] Categories.hasReadCategories deprecated, see Categories.setUnread');
|
||||
if (parseInt(uid, 10) <= 0) {
|
||||
return cids.map(() => false);
|
||||
}
|
||||
|
||||
const sets = cids.map(cid => `cid:${cid}:read_by_uid`);
|
||||
return await db.isMemberOfSets(sets, uid);
|
||||
};
|
||||
|
||||
Categories.hasReadCategory = async function (cid, uid) {
|
||||
// TODO: remove in 4.0
|
||||
console.warn('[deprecated] Categories.hasReadCategory deprecated, see Categories.setUnread');
|
||||
if (parseInt(uid, 10) <= 0) {
|
||||
return false;
|
||||
}
|
||||
return await db.isSetMember(`cid:${cid}:read_by_uid`, uid);
|
||||
};
|
||||
};
|
||||
@@ -34,6 +34,7 @@ apiController.loadConfig = async function (req) {
|
||||
browserTitle: validator.escape(String(meta.config.browserTitle || meta.config.title || 'NodeBB')),
|
||||
description: validator.escape(String(meta.config.description || '')),
|
||||
keywords: validator.escape(String(meta.config.keywords || '')),
|
||||
'brand:logo': validator.escape(String(meta.config['brand:logo'])),
|
||||
titleLayout: (meta.config.titleLayout || '{pageTitle} | {browserTitle}').replace(/{/g, '{').replace(/}/g, '}'),
|
||||
showSiteTitle: meta.config.showSiteTitle === 1,
|
||||
maintenanceMode: meta.config.maintenanceMode === 1,
|
||||
|
||||
@@ -65,7 +65,7 @@ recentController.getData = async function (req, url, sort) {
|
||||
data.title = meta.config.homePageTitle || '[[pages:home]]';
|
||||
} else {
|
||||
data.title = `[[pages:${url}]]`;
|
||||
data.breadcrumbs = helpers.buildBreadcrumbs([{ text: `[[activitypub:world-title]]` }]);
|
||||
data.breadcrumbs = helpers.buildBreadcrumbs([{ text: `[[${url}:title]]` }]);
|
||||
}
|
||||
|
||||
const query = { ...req.query };
|
||||
|
||||
@@ -137,7 +137,7 @@ module.exports = function (Messaging) {
|
||||
parentMids = parentMids.filter((mid, idx) => canView[idx]);
|
||||
|
||||
const parentMessages = await Messaging.getMessagesFields(parentMids, [
|
||||
'fromuid', 'content', 'timestamp', 'deleted',
|
||||
'mid', 'fromuid', 'content', 'timestamp', 'deleted',
|
||||
]);
|
||||
const parentUids = _.uniq(parentMessages.map(msg => msg && msg.fromuid));
|
||||
const usersMap = _.zipObject(
|
||||
|
||||
@@ -22,6 +22,7 @@ const Notifications = module.exports;
|
||||
|
||||
// ttlcache for email-only chat notifications
|
||||
const notificationCache = ttlCache({
|
||||
max: 1000,
|
||||
ttl: (meta.config.notificationSendDelay || 60) * 1000,
|
||||
noDisposeOnSet: true,
|
||||
dispose: sendEmail,
|
||||
@@ -242,17 +243,25 @@ async function pushToUids(uids, notification) {
|
||||
if (notification.type) {
|
||||
results = await getUidsBySettings(data.uids);
|
||||
}
|
||||
|
||||
await sendNotification(results.uidsToNotify);
|
||||
const delayNotificationTypes = ['new-chat', 'new-group-chat', 'new-public-chat'];
|
||||
if (delayNotificationTypes.includes(notification.type)) {
|
||||
const cacheKey = `${notification.mergeId}|${results.uidsToEmail.join(',')}`;
|
||||
if (notificationCache.has(cacheKey)) {
|
||||
|
||||
if (results.uidsToEmail.length) {
|
||||
const delayNotificationTypes = ['new-chat', 'new-group-chat', 'new-public-chat'];
|
||||
if (delayNotificationTypes.includes(notification.type)) {
|
||||
const cacheKey = `${notification.mergeId}|${results.uidsToEmail.join(',')}`;
|
||||
const payload = notificationCache.get(cacheKey);
|
||||
notification.bodyLong = [payload.notification.bodyLong, notification.bodyLong].join('\n');
|
||||
let { bodyLong } = notification;
|
||||
if (payload !== undefined) {
|
||||
bodyLong = [payload.notification.bodyLong, bodyLong].join('\n');
|
||||
}
|
||||
notificationCache.set(cacheKey, { uids: results.uidsToEmail, notification: { ...notification, bodyLong } });
|
||||
if (notification.bodyLong.length >= 1000) {
|
||||
notificationCache.delete(cacheKey);
|
||||
}
|
||||
} else {
|
||||
await sendEmail({ uids: results.uidsToEmail, notification });
|
||||
}
|
||||
notificationCache.set(cacheKey, { uids: results.uidsToEmail, notification });
|
||||
} else {
|
||||
await sendEmail({ uids: results.uidsToEmail, notification });
|
||||
}
|
||||
|
||||
plugins.hooks.fire('action:notification.pushed', {
|
||||
@@ -264,8 +273,7 @@ async function pushToUids(uids, notification) {
|
||||
}
|
||||
|
||||
async function sendEmail({ uids, notification }, mergeId, reason) {
|
||||
// Only act on cache item expiry
|
||||
if (reason && reason !== 'stale') {
|
||||
if ((reason && reason === 'set') || !uids.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ module.exports = function (Posts) {
|
||||
deleted: isDeleting ? 1 : 0,
|
||||
deleterUid: isDeleting ? uid : 0,
|
||||
});
|
||||
const postData = await Posts.getPostFields(pid, ['pid', 'tid', 'uid', 'content', 'timestamp']);
|
||||
const postData = await Posts.getPostFields(pid, ['pid', 'tid', 'uid', 'content', 'timestamp', 'deleted']);
|
||||
const topicData = await topics.getTopicFields(postData.tid, ['tid', 'cid', 'pinned']);
|
||||
postData.cid = topicData.cid;
|
||||
await Promise.all([
|
||||
|
||||
@@ -61,9 +61,9 @@ module.exports = function (Posts) {
|
||||
const tid = String(filter.tid);
|
||||
postData = postData.filter(item => item.data.tid && String(item.data.tid) === tid);
|
||||
} else if (Array.isArray(filter.tid)) {
|
||||
const tids = filter.tid.map(tid => parseInt(tid, 10));
|
||||
const tids = filter.tid.map(String);
|
||||
postData = postData.filter(
|
||||
item => item.data.tid && tids.includes(parseInt(item.data.tid, 10))
|
||||
item => item.data.tid && tids.includes(String(item.data.tid))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -200,9 +200,9 @@ module.exports = function (Posts) {
|
||||
}
|
||||
|
||||
async function updateTopicPosters(postData, toUid) {
|
||||
const postsByTopic = _.groupBy(postData, p => parseInt(p.tid, 10));
|
||||
const postsByTopic = _.groupBy(postData, p => String(p.tid));
|
||||
await async.eachOf(postsByTopic, async (posts, tid) => {
|
||||
const postsByUser = _.groupBy(posts, p => parseInt(p.uid, 10));
|
||||
const postsByUser = _.groupBy(posts, p => String(p.uid));
|
||||
await db.sortedSetIncrBy(`tid:${tid}:posters`, posts.length, toUid);
|
||||
await async.eachOf(postsByUser, async (posts, uid) => {
|
||||
await db.sortedSetIncrBy(`tid:${tid}:posters`, -posts.length, uid);
|
||||
|
||||
@@ -45,7 +45,7 @@ privsPosts.get = async function (pids, uid) {
|
||||
|
||||
const privileges = cids.map((cid, i) => {
|
||||
const isAdminOrMod = results.isAdmin || isModerator[cid];
|
||||
const editable = (privData['posts:edit'][cid] && (results.isOwner[i] || results.isModerator)) || results.isAdmin;
|
||||
const editable = (privData['posts:edit'][cid] && (results.isOwner[i] || results.isModerator[i])) || results.isAdmin;
|
||||
const viewDeletedPosts = results.isOwner[i] || privData['posts:view_deleted'][cid] || results.isAdmin;
|
||||
const viewHistory = results.isOwner[i] || privData['posts:history'][cid] || results.isAdmin;
|
||||
|
||||
|
||||
@@ -147,6 +147,6 @@ async function shutdown(code) {
|
||||
} catch (err) {
|
||||
winston.error(err.stack);
|
||||
|
||||
return process.exit(code || 0);
|
||||
process.exit(code || 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@ module.exports = function (Topics) {
|
||||
posts.getUserInfoForPosts([postOwner], uid),
|
||||
]);
|
||||
await Promise.all([
|
||||
Topics.addParentPosts([postData]),
|
||||
Topics.addParentPosts([postData], uid),
|
||||
Topics.syncBacklinks(postData),
|
||||
Topics.markAsRead([tid], uid),
|
||||
]);
|
||||
|
||||
@@ -91,7 +91,7 @@ module.exports = function (Topics) {
|
||||
};
|
||||
|
||||
Topics.movePostToTopic = async function (callerUid, pid, tid, forceScheduled = false) {
|
||||
tid = parseInt(tid, 10);
|
||||
tid = String(tid);
|
||||
const topicData = await Topics.getTopicFields(tid, ['tid', 'scheduled']);
|
||||
if (!topicData.tid) {
|
||||
throw new Error('[[error:no-topic]]');
|
||||
@@ -109,7 +109,7 @@ module.exports = function (Topics) {
|
||||
throw new Error('[[error:cant-move-from-scheduled-to-existing]]');
|
||||
}
|
||||
|
||||
if (postData.tid === tid) {
|
||||
if (String(postData.tid) === String(tid)) {
|
||||
throw new Error('[[error:cant-move-to-same-topic]]');
|
||||
}
|
||||
|
||||
|
||||
@@ -192,7 +192,6 @@ Topics.getTopicWithPosts = async function (topicData, set, uid, start, stop, rev
|
||||
|
||||
topicData.thumbs = thumbs[0];
|
||||
topicData.posts = posts;
|
||||
topicData.events = events;
|
||||
topicData.posts.forEach((p) => {
|
||||
p.events = events.filter(
|
||||
event => event.timestamp >= p.eventStart && event.timestamp < p.eventEnd
|
||||
|
||||
@@ -21,7 +21,7 @@ module.exports = function (Topics) {
|
||||
}
|
||||
|
||||
const otherTids = tids.sort((a, b) => a - b)
|
||||
.filter(tid => tid && parseInt(tid, 10) !== parseInt(mergeIntoTid, 10));
|
||||
.filter(tid => tid && String(tid) !== String(mergeIntoTid));
|
||||
|
||||
for (const tid of otherTids) {
|
||||
/* eslint-disable no-await-in-loop */
|
||||
|
||||
@@ -12,6 +12,7 @@ const meta = require('../meta');
|
||||
const activitypub = require('../activitypub');
|
||||
const plugins = require('../plugins');
|
||||
const utils = require('../utils');
|
||||
const privileges = require('../privileges');
|
||||
|
||||
const backlinkRegex = new RegExp(`(?:${nconf.get('url').replace('/', '\\/')}|\b|\\s)\\/topic\\/(\\d+)(?:\\/\\w+)?`, 'g');
|
||||
|
||||
@@ -129,7 +130,7 @@ module.exports = function (Topics) {
|
||||
getPostUserData('uid', async uids => await posts.getUserInfoForPosts(uids, uid)),
|
||||
getPostUserData('editor', async uids => await user.getUsersFields(uids, ['uid', 'username', 'userslug'])),
|
||||
getPostReplies(postData, uid),
|
||||
Topics.addParentPosts(postData),
|
||||
Topics.addParentPosts(postData, uid),
|
||||
]);
|
||||
|
||||
postData.forEach((postObj, i) => {
|
||||
@@ -178,7 +179,7 @@ module.exports = function (Topics) {
|
||||
});
|
||||
};
|
||||
|
||||
Topics.addParentPosts = async function (postData) {
|
||||
Topics.addParentPosts = async function (postData, callerUid) {
|
||||
let parentPids = postData
|
||||
.filter(p => p && p.hasOwnProperty('toPid') && (activitypub.helpers.isUri(p.toPid) || utils.isNumber(p.toPid)))
|
||||
.map(postObj => postObj.toPid);
|
||||
@@ -187,18 +188,40 @@ module.exports = function (Topics) {
|
||||
return;
|
||||
}
|
||||
parentPids = _.uniq(parentPids);
|
||||
const parentPosts = await posts.getPostsFields(parentPids, ['uid']);
|
||||
const postPrivileges = await privileges.posts.get(parentPids, callerUid);
|
||||
const pidToPrivs = _.zipObject(parentPids, postPrivileges);
|
||||
|
||||
parentPids = parentPids.filter(p => pidToPrivs[p]['topics:read']);
|
||||
const parentPosts = await posts.getPostsFields(parentPids, ['uid', 'pid', 'timestamp', 'content', 'deleted']);
|
||||
const parentUids = _.uniq(parentPosts.map(postObj => postObj && postObj.uid));
|
||||
const userData = await user.getUsersFields(parentUids, ['username']);
|
||||
const userData = await user.getUsersFields(parentUids, ['username', 'userslug', 'picture']);
|
||||
|
||||
const usersMap = _.zipObject(parentUids, userData);
|
||||
|
||||
await Promise.all(parentPosts.map(async (parentPost) => {
|
||||
const postPrivs = pidToPrivs[parentPost.pid];
|
||||
if (parentPost.deleted && String(parentPost.uid) !== String(callerUid, 10) && !postPrivs['posts:view_deleted']) {
|
||||
parentPost.content = `<p>[[topic:post-is-deleted]]</p>`;
|
||||
return;
|
||||
}
|
||||
const foundPost = postData.find(p => String(p.pid) === String(parentPost.pid));
|
||||
if (foundPost) {
|
||||
parentPost.content = foundPost.content;
|
||||
return;
|
||||
}
|
||||
parentPost = await posts.parsePost(parentPost);
|
||||
}));
|
||||
|
||||
const parents = {};
|
||||
parentPosts.forEach((post, i) => {
|
||||
if (usersMap[post.uid]) {
|
||||
parents[parentPids[i]] = {
|
||||
uid: post.uid,
|
||||
username: usersMap[post.uid].username,
|
||||
displayname: usersMap[post.uid].displayname,
|
||||
pid: post.pid,
|
||||
content: post.content,
|
||||
user: usersMap[post.uid],
|
||||
timestamp: post.timestamp,
|
||||
timestampISO: post.timestampISO,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ Thumbs.get = async function (tids, options) {
|
||||
};
|
||||
}
|
||||
|
||||
const isDraft = !await topics.exists(tids);
|
||||
const isDraft = (await topics.exists(tids)).map(exists => !exists);
|
||||
|
||||
if (!meta.config.allowTopicsThumbnail || !tids.length) {
|
||||
return singular ? [] : tids.map(() => []);
|
||||
@@ -71,9 +71,10 @@ Thumbs.get = async function (tids, options) {
|
||||
|
||||
if (!options.thumbsOnly) {
|
||||
// Add uploaded media to thumb sets
|
||||
const mainPidUploads = await Promise.all(mainPids.map(async pid => await posts.uploads.list(pid)));
|
||||
const mainPidUploads = await Promise.all(mainPids.map(posts.uploads.list));
|
||||
mainPidUploads.forEach((uploads, idx) => {
|
||||
uploads = uploads.map(path => `/${path}`);
|
||||
uploads = uploads.map(upath => path.join(path.sep, `${upath}`));
|
||||
|
||||
uploads = uploads.filter((upload) => {
|
||||
const type = mime.getType(upload);
|
||||
return !thumbs[idx].includes(upload) && type && type.startsWith('image/');
|
||||
|
||||
@@ -9,19 +9,23 @@
|
||||
<button type="button" class="btn btn-ghost btn-sm dropdown-toggle w-100" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span component="group-selector-selected">{group.displayName}</span> <span class="caret"></span>
|
||||
</button>
|
||||
<div component="group-selector-search" class="hidden position-absolute w-100">
|
||||
<input type="text" class="form-control" autocomplete="off">
|
||||
|
||||
<div class="dropdown-menu p-1">
|
||||
<div component="group-selector-search" class="p-1 hidden">
|
||||
<input type="text" class="form-control form-control-sm" placeholder="[[search:type-to-search]]" autocomplete="off">
|
||||
<hr class="mt-2 mb-0"/>
|
||||
</div>
|
||||
<ul component="group-list" class="list-unstyled mb-0 text-sm dropdown-menu-end group-dropdown-menu overflow-auto ghost-scrollbar" role="menu" style="max-height: 500px;">
|
||||
<li component="group-no-matches" role="presentation" class="group hidden">
|
||||
<a class="dropdown-item rounded-1" role="menuitem">[[search:no-matches]]</a>
|
||||
</li>
|
||||
{{{ each groupNames }}}
|
||||
<li role="presentation" class="group" data-name="{groupNames.displayName}">
|
||||
<a class="dropdown-item rounded-1" href="{config.relative_path}/admin/manage/groups/{groupNames.encodedName}" role="menuitem">{groupNames.displayName}</a>
|
||||
</li>
|
||||
{{{ end }}}
|
||||
</ul>
|
||||
</div>
|
||||
<ul component="group-list" class="dropdown-menu dropdown-menu-end group-dropdown-menu overflow-auto p-1" role="menu" style="max-height: 500px;">
|
||||
<li component="group-no-matches" role="presentation" class="group hidden">
|
||||
<a class="dropdown-item rounded-1" role="menuitem">[[search:no-matches]]</a>
|
||||
</li>
|
||||
{{{ each groupNames }}}
|
||||
<li role="presentation" class="group" data-name="{groupNames.displayName}">
|
||||
<a class="dropdown-item rounded-1" href="{config.relative_path}/admin/manage/groups/{groupNames.encodedName}" role="menuitem">{groupNames.displayName}</a>
|
||||
</li>
|
||||
{{{ end }}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 px-0 px-md-3 ">
|
||||
@@ -157,26 +161,30 @@
|
||||
<button type="button" class="btn btn-ghost btn-sm d-flex gap-2 align-items-center flex-fill dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<i class="fa fa-fw fa-lock text-primary"></i> <span>[[admin/manage/groups:privileges]]</span> <span class="caret"></span>
|
||||
</button>
|
||||
<div component="category-selector-search" class="hidden position-absolute">
|
||||
<input type="text" class="form-control form-control-sm" placeholder="[[search:type-to-search]]" autocomplete="off">
|
||||
|
||||
<div class="dropdown-menu p-1">
|
||||
<div component="category-selector-search" class="p-1 hidden">
|
||||
<input type="text" class="form-control form-control-sm" placeholder="[[search:type-to-search]]" autocomplete="off">
|
||||
<hr class="mt-2 mb-0"/>
|
||||
</div>
|
||||
<ul component="category/list" class="list-unstyled mb-0 text-sm category-dropdown-menu dropdown-menu-end ghost-scrollbar" role="menu">
|
||||
<li component="category/no-matches" role="presentation" class="category hidden">
|
||||
<a class="dropdown-item" role="menuitem">[[search:no-matches]]</a>
|
||||
</li>
|
||||
{{{each categories}}}
|
||||
<li role="presentation" class="category {{{ if categories.disabledClass }}}disabled{{{ end }}}" data-cid="{categories.cid}" data-name="{categories.name}" data-parent-cid="{categories.parentCid}">
|
||||
<a class="dropdown-item rounded-1" role="menuitem">{categories.level}
|
||||
<span component="category-markup">
|
||||
<div class="category-item d-inline-block">
|
||||
{buildCategoryIcon(@value, "24px", "rounded-circle")}
|
||||
{./name}
|
||||
</div>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{{{end}}}
|
||||
</ul>
|
||||
</div>
|
||||
<ul component="category/list" class="dropdown-menu category-dropdown-menu dropdown-menu-end p-1" role="menu">
|
||||
<li component="category/no-matches" role="presentation" class="category hidden">
|
||||
<a class="dropdown-item" role="menuitem">[[search:no-matches]]</a>
|
||||
</li>
|
||||
{{{each categories}}}
|
||||
<li role="presentation" class="category {{{ if categories.disabledClass }}}disabled{{{ end }}}" data-cid="{categories.cid}" data-name="{categories.name}" data-parent-cid="{categories.parentCid}">
|
||||
<a class="dropdown-item rounded-1" role="menuitem">{categories.level}
|
||||
<span component="category-markup">
|
||||
<div class="category-item d-inline-block">
|
||||
{buildCategoryIcon(@value, "24px", "rounded-circle")}
|
||||
{./name}
|
||||
</div>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{{{end}}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
<form type="form">
|
||||
<div class="form-group">
|
||||
<div component="category-selector" class="btn-group">
|
||||
<button type="button" class="btn btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span component="category-selector-selected">[[topic:thread-tools.select-category]]</span> <span class="caret"></span>
|
||||
</button>
|
||||
<div component="category-selector-search" class="hidden position-absolute">
|
||||
<input type="text" class="form-control" placeholder="[[search:type-to-search]]" autocomplete="off">
|
||||
<div class="mb-3">
|
||||
<div component="category-selector" class="btn-group">
|
||||
<button type="button" class="btn btn-ghost border dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span component="category-selector-selected">[[topic:thread-tools.select-category]]</span> <span class="caret"></span>
|
||||
</button>
|
||||
|
||||
<div class="dropdown-menu p-1">
|
||||
<div component="category-selector-search" class="p-1 hidden">
|
||||
<input type="text" class="form-control form-control-sm" placeholder="[[search:type-to-search]]" autocomplete="off">
|
||||
<hr class="mt-2 mb-0"/>
|
||||
</div>
|
||||
<ul component="category/list" class="dropdown-menu category-dropdown-menu" role="menu">
|
||||
<ul component="category/list" class="list-unstyled mb-0 text-sm category-dropdown-menu ghost-scrollbar" role="menu">
|
||||
<li component="category/no-matches" role="presentation" class="category hidden">
|
||||
<a class="dropdown-item" role="menuitem">[[search:no-matches]]</a>
|
||||
<a class="dropdown-item rounded-1" role="menuitem">[[search:no-matches]]</a>
|
||||
</li>
|
||||
{{{ each categories }}}
|
||||
<li role="presentation" class="category {{{if categories.disabledClass}}}disabled{{{end}}}" data-cid="{categories.cid}" data-name="{categories.name}">
|
||||
<a class="dropdown-item" role="menuitem">{categories.level}
|
||||
<a class="dropdown-item rounded-1" role="menuitem">{categories.level}
|
||||
<span component="category-markup">
|
||||
<div class="category-item d-inline-block">
|
||||
{buildCategoryIcon(@value, "24px", "rounded-circle")}
|
||||
@@ -26,7 +28,8 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{{ if message }}}
|
||||
<div>{message}</div>
|
||||
{{{ end }}}
|
||||
@@ -11,25 +11,30 @@
|
||||
</span>
|
||||
</span> <span class="caret"></span>
|
||||
</button>
|
||||
<div component="category-selector-search" class="hidden position-absolute">
|
||||
<input type="text" class="form-control form-control-sm" placeholder="[[search:type-to-search]]" autocomplete="off">
|
||||
</div>
|
||||
<ul component="category/list" class="dropdown-menu category-dropdown-menu p-1" role="menu">
|
||||
<li component="category/no-matches" role="presentation" class="category hidden">
|
||||
<a class="dropdown-item rounded-1" role="menu-item">[[search:no-matches]]</a>
|
||||
</li>
|
||||
{{{each categoryItems}}}
|
||||
<li role="presentation" class="category {{{ if ./disabledClass }}}disabled {{{ end }}}" data-cid="{./cid}" data-name="{./name}" data-parent-cid="{./parentCid}">
|
||||
<a href="#" class="dropdown-item rounded-1" role="menu-item">{./level}
|
||||
<span component="category-markup" style="{{{ if ./match }}}font-weight: bold;{{{end}}}">
|
||||
<div class="category-item d-inline-flex align-items-center gap-1">
|
||||
{{{ if ./icon }}}
|
||||
{buildCategoryIcon(@value, "24px", "rounded-circle")}
|
||||
{{{ end }}}
|
||||
{./name}
|
||||
</div>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{{{ end }}}
|
||||
</ul>
|
||||
|
||||
<div class="dropdown-menu p-1">
|
||||
<div component="category-selector-search" class="p-1 hidden">
|
||||
<input type="text" class="form-control form-control-sm" placeholder="[[search:type-to-search]]" autocomplete="off">
|
||||
<hr class="mt-2 mb-0"/>
|
||||
</div>
|
||||
|
||||
<ul component="category/list" class="list-unstyled mb-0 text-sm category-dropdown-menu" role="menu">
|
||||
<li component="category/no-matches" role="presentation" class="category hidden">
|
||||
<a class="dropdown-item rounded-1" role="menu-item">[[search:no-matches]]</a>
|
||||
</li>
|
||||
{{{each categoryItems}}}
|
||||
<li role="presentation" class="category {{{ if ./disabledClass }}}disabled {{{ end }}}" data-cid="{./cid}" data-name="{./name}" data-parent-cid="{./parentCid}">
|
||||
<a href="#" class="dropdown-item rounded-1" role="menu-item">{./level}
|
||||
<span component="category-markup" style="{{{ if ./match }}}font-weight: bold;{{{end}}}">
|
||||
<div class="category-item d-inline-flex align-items-center gap-1">
|
||||
{{{ if ./icon }}}
|
||||
{buildCategoryIcon(@value, "24px", "rounded-circle")}
|
||||
{{{ end }}}
|
||||
{./name}
|
||||
</div>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{{{ end }}}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1,25 +0,0 @@
|
||||
<div component="category-selector" class="btn-group">
|
||||
<button type="button" class="btn btn-light btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<i class="fa fa-fw fa-lock text-primary"></i> <span>[[admin/manage/groups:privileges]]</span> <span class="caret"></span>
|
||||
</button>
|
||||
<div component="category-selector-search" class="hidden position-absolute">
|
||||
<input type="text" class="form-control form-control-sm" placeholder="[[search:type-to-search]]" autocomplete="off">
|
||||
</div>
|
||||
<ul component="category/list" class="dropdown-menu category-dropdown-menu dropdown-menu-end p-1" role="menu">
|
||||
<li component="category/no-matches" role="presentation" class="category hidden">
|
||||
<a class="dropdown-item" role="menuitem">[[search:no-matches]]</a>
|
||||
</li>
|
||||
{{{each categories}}}
|
||||
<li role="presentation" class="category {{{ if categories.disabledClass }}}disabled{{{ end }}}" data-cid="{categories.cid}" data-name="{categories.name}" data-parent-cid="{categories.parentCid}">
|
||||
<a class="dropdown-item rounded-1" role="menuitem">{categories.level}
|
||||
<span component="category-markup">
|
||||
<div class="category-item d-inline-block">
|
||||
{buildCategoryIcon(@value, "24px", "rounded-circle")}
|
||||
{./name}
|
||||
</div>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{{{end}}}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -11,7 +11,7 @@
|
||||
<span class="input-group-text"><i class="fa fa-search"></i></span>
|
||||
</div>
|
||||
|
||||
<div class="quick-search-container dropdown-menu d-block p-2 hidden w-100">
|
||||
<div class="quick-search-container dropdown-menu d-block p-2 hidden">
|
||||
<div class="text-center loading-indicator"><i class="fa fa-spinner fa-spin"></i></div>
|
||||
<div class="quick-search-results-container"></div>
|
||||
</div>
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
<span class="d-none d-md-inline fw-semibold">[[unread:all-categories]]</span>{{{ end }}}
|
||||
</button>
|
||||
|
||||
<div component="category-selector-search" class="hidden position-absolute" style="min-width: 120px;">
|
||||
<input type="text" class="form-control form-control-sm" placeholder="[[search:type-to-search]]" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="dropdown-menu p-1">
|
||||
<div component="category-selector-search" class="p-1 hidden">
|
||||
<input type="text" class="form-control form-control-sm" placeholder="[[search:type-to-search]]" autocomplete="off">
|
||||
<hr class="mt-2 mb-0"/>
|
||||
</div>
|
||||
|
||||
<ul component="category/list" class="list-unstyled mb-0 text-sm category-dropdown-menu ghost-scrollbar" role="menu">
|
||||
<li role="presentation" class="category" data-cid="all">
|
||||
<a class="dropdown-item rounded-1 d-flex align-items-center gap-2" role="menuitem" href="{{{ if allCategoriesUrl }}}{config.relative_path}/{allCategoriesUrl}{{{ else }}}#{{{ end }}}">
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div component="category-selector-search" class="hidden position-absolute">
|
||||
<input type="text" class="form-control form-control-sm" placeholder="[[search:type-to-search]]" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="dropdown-menu p-1">
|
||||
<div component="category-selector-search" class="p-1 hidden">
|
||||
<input type="text" class="form-control form-control-sm" placeholder="[[search:type-to-search]]" autocomplete="off">
|
||||
<hr class="mt-2 mb-0"/>
|
||||
</div>
|
||||
<ul component="category/list" class="list-unstyled mb-0 text-sm category-dropdown-menu ghost-scrollbar" role="menu">
|
||||
<li component="category/no-matches" role="presentation" class="category hidden">
|
||||
<a class="dropdown-item rounded-1" role="menuitem">[[search:no-matches]]</a>
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
</div>
|
||||
<span class="chat-timestamp text-muted timeago text-nowrap hidden" title="{messages.parent.timestampISO}"></span>
|
||||
</div>
|
||||
<div component="chat/message/parent/content" class="text-muted line-clamp-1 w-100">{messages.parent.content}</div>
|
||||
<div component="chat/message/parent/content" class="text-muted line-clamp-1 text-break w-100">{messages.parent.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -23,11 +23,12 @@
|
||||
{{{ end }}}
|
||||
<span class="caret text-primary opacity-75"></span>
|
||||
</button>
|
||||
<div component="category-selector-search" class="hidden position-absolute">
|
||||
<input type="text" class="form-control form-control-sm" placeholder="[[search:type-to-search]]" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="dropdown-menu p-1">
|
||||
<div component="category-selector-search" class="p-1 hidden">
|
||||
<input type="text" class="form-control form-control-sm" placeholder="[[search:type-to-search]]" autocomplete="off">
|
||||
<hr class="mt-2 mb-0"/>
|
||||
</div>
|
||||
<ul component="category/list" class="list-unstyled mb-0 text-sm category-dropdown-menu ghost-scrollbar" role="menu">
|
||||
{{{each categoryItems}}}
|
||||
<li role="presentation" class="category {{{ if ../disabledClass }}}disabled{{{ end }}}" data-cid="{../cid}" data-parent-cid="{../parentCid}" data-name="{../name}">
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
{{{ end }}}
|
||||
</button>
|
||||
|
||||
<div component="tag/filter/search" class="hidden position-absolute" style="min-width: 120px;">
|
||||
<input type="text" class="form-control form-control-sm" placeholder="[[search:type-to-search]]" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="dropdown-menu p-1">
|
||||
<div component="tag/filter/search" class="p-1 hidden">
|
||||
<input type="text" class="form-control form-control-sm" placeholder="[[search:type-to-search]]" autocomplete="off">
|
||||
<hr class="mt-2 mb-0"/>
|
||||
</div>
|
||||
<ul component="tag/filter/list" class="list-unstyled mb-0 text-sm overflow-auto ghost-scrollbar" role="menu" style="max-height: 500px;" role="menu">
|
||||
<li role="presentation" data-tag="">
|
||||
<a class="dropdown-item rounded-1 d-flex align-items-center gap-2" role="menuitem" href="#">
|
||||
|
||||
12
src/views/partials/topic/post-parent.tpl
Normal file
12
src/views/partials/topic/post-parent.tpl
Normal file
@@ -0,0 +1,12 @@
|
||||
<div component="post/parent" data-parent-pid="{./parent.pid}" data-uid="{./parent.uid}" class="btn btn-ghost btn-sm d-flex gap-2 text-start flex-row mb-2" style="font-size: 13px;">
|
||||
<div class="d-flex gap-2 text-nowrap">
|
||||
<div><i class="fa fa-sm fa-reply opacity-50"></i></div>
|
||||
<div class="d-flex flex-nowrap gap-1 align-items-center">
|
||||
<a href="{config.relative_path}/user/{./parent.user.userslug}" class="text-decoration-none lh-1">{buildAvatar(./parent.user, "14px", true, "not-responsive align-middle")}</a>
|
||||
<a class="fw-semibold" href="{config.relative_path}/user/{./parent.user.userslug}">{./parent.user.displayname}</a>
|
||||
</div>
|
||||
|
||||
<a href="{config.relative_path}/post/{./parent.pid}" class="text-muted timeago text-nowrap hidden" title="{./parent.timestampISO}"></a>
|
||||
</div>
|
||||
<div component="post/parent/content" class="text-muted line-clamp-1 text-break w-100">{./parent.content}</div>
|
||||
</div>
|
||||
@@ -69,11 +69,16 @@ server.on('connection', (conn) => {
|
||||
});
|
||||
});
|
||||
|
||||
exports.destroy = function (callback) {
|
||||
server.close(callback);
|
||||
for (const connection of Object.values(connections)) {
|
||||
connection.destroy();
|
||||
}
|
||||
exports.destroy = function () {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
for (const connection of Object.values(connections)) {
|
||||
connection.destroy();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
exports.getConnectionCount = function () {
|
||||
|
||||
@@ -77,8 +77,14 @@ describe('Admin Controllers', () => {
|
||||
|
||||
it('should load admin dashboard', async () => {
|
||||
await groups.join('administrators', adminUid);
|
||||
const today = new Date();
|
||||
const end = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
today.setDate(today.getDate() - 1);
|
||||
const start = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
|
||||
const dashboards = [
|
||||
'/admin', '/admin/dashboard/logins', '/admin/dashboard/users', '/admin/dashboard/topics', '/admin/dashboard/searches',
|
||||
'/admin', '/admin/dashboard/logins', '/admin/dashboard/users', '/admin/dashboard/topics',
|
||||
'/admin/dashboard/searches', `/admin/dashboard/searches?start=${start}&end=${end}`,
|
||||
];
|
||||
await async.each(dashboards, async (url) => {
|
||||
const { response, body } = await request.get(`${nconf.get('url')}${url}`, { jar: jar });
|
||||
|
||||
Reference in New Issue
Block a user