⚡Мгновенные "Быстроплюсы" и ⚡Мгновенные "Сказали спасибо"
Предыстория
Есть скрипт "Быстроплюсы от Deff" https://forum.mybb.ru/viewtopic.php?id= … 29#p982958 , он позволяет ставить положительные "лайки" однократным нажатием на "сердечко".
И есть скрипт "Поблагодарили" от Reysler https://forum.mybb.ru/viewtopic.php?id= … 78#p995185 и "Список поддержавших пост" от Виплич https://forum.mybb.ru/viewtopic.php?id= … 29#p995548 . Смысл этих скриптов - вывести в сообщениях, под подписью, список поставивших "лайки".
Эти скрипты, "Быстроплюсы" и список их поставивших, органично дополняют друг друга.
В моём варианте список поставивших положительные "лайки" называется "Сказали спасибо", поэтому далее я буду называть эти функции "Быстроплюсы" и "Сказали спасибо".
Вышеприведённые скрипты выполняют свою работу, но у них есть свои функциональные ограничения:
● "Быстроплюсы" не работают при включении бесплатного скрипта "Живых тем" от Alex_63 https://forum.mybb.ru/viewtopic.php?id=41002#p993848
● "Сказали спасибо" для отображения в обычном режиме требуют перезагрузки страницы.
● "Сказали спасибо" для отображения в режиме "Живые темы" тоже требуют перезагрузки страницы.
● При использовании платного дополнения "Мгновенные уведомления" от Alex_63 https://forum.mybb.ru/viewtopic.php?id=38567#p939931 , "Сказали спасибо" тоже требуют перезагрузки страницы.
А чего мы хотим? Мы хотим, чтобы всё работало 🚀быстрее, чем лайки в Телеграме!
Далее будет приведён скрипт, при котором "Быстроплюсы" и "Сказали спасибо" отлично работают и отображаются мгновенно в ЛЮБОМ варианте работы форума!
"Быстроплюсы" и "Сказали спасибо" используют единое логическое ядро и непосредственно связаны друг с другом через вызов функции.
Кому не нужны "Сказали спасибо", а нужны лишь всегда быстро и чётко работающие сердечки "Быстроплюсов", для тех выложим сокращённую версию скрипта без "Сказали спасибо".
Подготовительная работа.
1. В панели администрирования форума Администрирование - Права поставьте "Разрешить ajax для предпросмотра и отправки сообщения", затем нажмите кнопку "Сохранить".
Скриншот (кликабельно)

2. В панели администрирования форума Администрирование - Настройки в блоке "Система отношений" поставьте "Тип оценок - Только плюсы", затем нажмите кнопку "Сохранить".
Скриншот

🛠️Установка скрипта
В Администрирование - Формы - HTML низ вставить код:
Посмотреть код
Код:<!-- ⚡Мгновенные" Быстроплюсы" и ⚡мгновенные "Сказали спасибо" -->
<style>
/*Быстроплюсы для Топика!*/
div .post-rating p a {
text-align: center;
outline: 1px solid transparent;
font-weight: 700;
background: url(https://upforme.ru/uploads/001a/f0/7d/2/466540.png) no-repeat center;
background-size: 36px auto;
position: relative;
z-index: 100;
width: 36px;
height: 36px;
font-size: 12px !important;
display: inline-block;
vertical-align: middle;
line-height: 34px;
letter-spacing: -.1px;
}
.noNull:before {
content: "+";
display: inline-block;
margin-left: -1px;
font-size: 9px;
letter-spacing: 0 !important;
}
.punbb .post-rating p {
padding: 0 !important;
}
body.dark-theme div .post-rating p a {
background-image: url(https://forumstatic.ru/files/001a/f0/7d/94257.png?v=1) !important;
}
</style>
<script>
'use strict';
// --- ПРОВЕРКА СТРАНИЦЫ (один раз при загрузке) ---
const isViewTopicPage = document.getElementById('pun-viewtopic') !== null;
if (!isViewTopicPage) {
// Скрипт работает только на страницах просмотра тем
} else {
// --- ОБЩИЕ ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ---
// Получает ID постов из указанного DOM-контекста
function getPostIds(scope = document) {
const postElements = scope.querySelectorAll('div.post');
const ids = [];
postElements.forEach(function(el) {
const id = el.id;
if (id && id.startsWith('p')) {
ids.push(id.slice(1));
}
});
return ids;
}
// Поиск ближайшего родителя по селектору
function findParent(el, selector) {
let current = el;
while (current && current !== document) {
if (current.matches(selector)) {
return current;
}
current = current.parentElement;
}
return null;
}
// --- ЧАСТЬ 1: БЫСТРОПЛЮСЫ ---
// Обновляет отображение цифры рейтинга и управляет классом noNull
function setDigit(th) {
var d = parseInt(th.textContent);
if (d > 0) {
th.classList.add('noNull');
} else {
th.classList.remove('noNull');
}
th.textContent = d;
}
// Сохранение оригинального alert для восстановления после голосования
var Busy = window.alert;
// Обработка клика по кнопке "плюса": отправка запроса, обновление рейтинга и мини-профиля
function setPlus(linkElement) {
window.alert = null;
var href = linkElement.href;
var pidMatch = href.match(/\?id=(\d+)/);
if (!pidMatch) return;
var pid = pidMatch[1];
var postElement = findParent(linkElement, '.post');
if (!postElement) return;
var profileLink = postElement.querySelector('.pl-email a[href*="profile.php?"]');
var uid = null;
if (profileLink) {
var uidMatch = profileLink.href.match(/\?id=(\d+)$/);
if (uidMatch) uid = uidMatch[1];
}
var vMatch = href.match(/&v=(\d+)/);
if (!vMatch) return;
var v = vMatch[1] == 0 ? -1 : 1;
var postVoteElement = document.getElementById('post-' + pid + '-vote');
if (postVoteElement) {
postVoteElement.style.display = 'none';
}
fetch(href + '&format=json')
.then(response => response.json())
.then(data => {
if (data.error && data.error.message) {
// Ошибка обрабатывается без уведомлений
} else if (data.delta) {
var ratingLink = document.querySelector('#p' + pid + ' .post-rating a');
if (ratingLink) {
if (data.response !== undefined) {
ratingLink.textContent = data.response;
setDigit(ratingLink);
} else {
var oldRating = parseInt(ratingLink.textContent) || 0;
var newRating = oldRating + data.delta;
ratingLink.textContent = newRating;
setDigit(ratingLink);
}
}
// Обновление мини-профиля автора и текущего пользователя
var respectElements = document.querySelectorAll('.pl-email a[href$="profile.php?id=' + uid + '"]');
var respectContainers = [];
respectElements.forEach(function(el) {
var post = findParent(el, '.post');
if (post) {
var respect = post.querySelector('.pa-respect');
if (respect) respectContainers.push(respect);
}
});
var positiveElements = document.querySelectorAll('.pl-email a[href$="profile.php?id=' + UserID + '"]');
var positiveContainers = [];
positiveElements.forEach(function(el) {
var post = findParent(el, '.post');
if (post) {
var positive = post.querySelector('.pa-positive');
if (positive) positiveContainers.push(positive);
}
});
function replaceRating(container, v, revert) {
var html = container.innerHTML;
var delta = v;
if (revert) delta = delta > 0 ? -1 : 1;
if (v > 0) {
html = html.replace(/\[\+(\d+)\//g, function(str, p1) {
return '[+' + (parseInt(p1) + delta) + '/';
});
} else {
html = html.replace(/\/-(\d+)\]/g, function(str, p1) {
return '/-' + (parseInt(p1) - delta) + ']';
});
}
container.innerHTML = html;
}
if (respectContainers.length > 0 && respectContainers[0].innerHTML.indexOf('[') != -1) {
respectContainers.forEach(function(el) { replaceRating(el, v); });
positiveContainers.forEach(function(el) { replaceRating(el, v); });
if (Math.abs(data.delta) == 2) {
v = v > 0 ? -1 : 1;
respectContainers.forEach(function(el) { replaceRating(el, v, 1); });
positiveContainers.forEach(function(el) { replaceRating(el, v, 1); });
}
} else {
respectContainers.forEach(function(container) {
var span = container.querySelector('span:not(.fld-name)');
if (span) {
var d0 = span.textContent;
var d1 = parseInt(d0) + v;
if (d1 && d1 > 0) {
d1 = '+' + d1;
}
span.textContent = d1;
}
});
positiveContainers.forEach(function(container) {
var span = container.querySelector('span:not(.fld-name)');
if (span) {
var p0 = span.textContent;
var p1 = parseInt(p0) + v;
if (p1 && p1 > 0) {
p1 = '+' + p1;
}
span.textContent = p1;
}
});
}
// Генерация события для обновления списка "Спасибо" при лайке
if (v === 1) {
document.dispatchEvent(new CustomEvent('vote:happened', {
detail: { pid: pid, type: 'thank' }
}));
}
}
setTimeout(function() { window.alert = Busy; }, 1300);
})
.catch(error => {
setTimeout(function() { window.alert = Busy; }, 1300);
});
}
// Обёртка для вызова setPlus из onclick
window.BR = function(th) {
var postBox = findParent(th, '.post-box');
if (!postBox) return;
var lnk = postBox.querySelector('.post-vote > p > a');
if (lnk) setPlus(lnk);
};
// Скрывает все вновь добавленные элементы .post-vote
function hideAllNewPostVotes(scope = document) {
scope.querySelectorAll('.post-vote[id^="post-"]').forEach(function(el) {
var postId = el.id.replace('post-', '');
var postRatingLink = document.querySelector('.post#p' + postId + ' .post-rating p a');
if (postRatingLink) {
el.style.display = 'none';
}
});
}
// Назначает обработчики для кнопок "плюса" в указанной области
function applyPlusHandlers(scope = document) {
scope.querySelectorAll('.post .post-rating p a').forEach(function(el) {
if (!el.hasAttribute('data-plus-handler')) {
el.setAttribute("onclick", "BR(this)");
setDigit(el);
var postElement = findParent(el, '.post');
if (postElement) {
var pid = postElement.id.slice(1);
var postVoteLink = document.getElementById('post-' + pid + '-vote');
if (postVoteLink) {
postVoteLink.style.display = 'none';
}
}
el.setAttribute('data-plus-handler', 'true');
}
});
hideAllNewPostVotes(scope);
}
// --- ЧАСТЬ 2: СПИСОК ПОБЛАГОДАРИВШИХ (API-ЛОГИКА) ---
// Отслеживание постов, для которых уже запрашивались "спасибо"
const processedPostsForThanks = new Set();
// Отслеживание ID постов, для которых выполняется запрос (для дедупликации)
const pendingUpdates = new Set();
// Вставка списка благодаривших в пост
function insertThanksList(postId, thankersListHtml) {
if (!thankersListHtml) {
return;
}
var postBox = document.querySelector('.post#p' + postId + ' .post-box');
if (postBox && postBox.querySelector('.postVoters') === null) {
var html = '<div class="postVoters"><strong><i><font color="#13355e">Сказали спасибо</font></i>:</strong> ' + thankersListHtml + '</div>';
postBox.insertAdjacentHTML('beforeend', html);
processedPostsForThanks.add(postId);
} else {
if (!postBox) {
var postElement = document.querySelector('.post#p' + postId);
if (postElement && postElement.querySelector('.postVoters') === null) {
var html = '<div class="postVoters"><strong><i><font color="#13355e">Сказали спасибо</font></i>:</strong> ' + thankersListHtml + '</div>';
postElement.insertAdjacentHTML('beforeend', html);
processedPostsForThanks.add(postId);
}
} else {
var existingVoters = postBox.querySelector('.postVoters');
if (existingVoters) {
existingVoters.innerHTML = '<strong><i><font color="#13355e">Сказали спасибо</font></i>:</strong> ' + thankersListHtml;
}
}
}
}
// Обработка ответа API: группировка и вставка благодаривших
function processVotes(data) {
if (!data || !data.response) {
const postIdsFromRequest = currentFetchRequestIds || [];
postIdsFromRequest.forEach(id => processedPostsForThanks.add(id));
currentFetchRequestIds = null;
return;
}
const votesByPost = {};
data.response.forEach(item => {
const postId = item.post_id;
if (item.votes && Array.isArray(item.votes)) {
item.votes.forEach(vote => {
if (vote.value === '1') {
if (!votesByPost[postId]) {
votesByPost[postId] = [];
}
votesByPost[postId].push(vote);
}
});
}
});
Object.keys(votesByPost).forEach(postId => {
const votesForThisPost = votesByPost[postId];
let thankersHtml = '';
votesForThisPost.forEach((vote, index) => {
thankersHtml += '<a href="/profile.php?id=' + vote.user_id + '">' + vote.username + '</a>';
if (index < votesForThisPost.length - 1) {
thankersHtml += ', ';
}
});
insertThanksList(postId, thankersHtml);
});
Object.keys(votesByPost).forEach(id => processedPostsForThanks.add(id));
data.response.forEach(item => {
if (!processedPostsForThanks.has(item.post_id)) {
processedPostsForThanks.add(item.post_id);
}
});
}
// Выполнение API-запроса для получения списка благодаривших
let currentFetchRequestIds = null;
function fetchVotesForPosts(postIds) {
if (!postIds || postIds.length === 0) {
postIds.forEach(id => pendingUpdates.delete(id));
return;
}
const apiParams = new URLSearchParams({
method: 'post.getVotesByPosts',
post_id: postIds.join(','),
fields: 'post_id,user_id,username,value,datetime',
sort_dir: 'desc'
});
currentFetchRequestIds = [...postIds];
return fetch('/api.php?' + apiParams.toString())
.then(response => response.json())
.then(data => {
processVotes(data);
})
.catch(error => {
postIds.forEach(id => processedPostsForThanks.add(id));
currentFetchRequestIds = null;
});
}
// Планирование обновления списка "спасибо" для новых постов
function scheduleUpdate(scope = document) {
requestAnimationFrame(() => {
const allPostIds = getPostIds(scope);
const newPostIds = allPostIds.filter(id => !processedPostsForThanks.has(id));
if (newPostIds.length > 0) {
const idsToRequest = newPostIds.filter(id => !pendingUpdates.has(id));
if (idsToRequest.length > 0) {
idsToRequest.forEach(id => pendingUpdates.add(id));
fetchVotesForPosts(idsToRequest).finally(() => {
idsToRequest.forEach(id => pendingUpdates.delete(id));
});
}
}
});
}
// --- ИНИЦИАЛИЗАЦИЯ И ПОДПИСКИ НА СОБЫТИЯ ---
// Инициализация при загрузке DOM
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.post .post-rating p a').forEach(function(el) {
el.setAttribute("onclick", "BR(this)");
setDigit(el);
var postElement = findParent(el, '.post');
if (postElement) {
var pid = postElement.id.slice(1);
var postVoteLink = document.getElementById('post-' + pid + '-vote');
if (postVoteLink && el.classList.contains('noNull')) {
postVoteLink.style.display = 'none';
}
}
el.setAttribute('data-plus-handler', 'true');
});
scheduleUpdate(document);
});
// Подписка на событие добавления нового поста (например, через AJAX)
document.addEventListener('pun_post', function(e) {
var scope = e.detail?.post || document.body;
applyPlusHandlers(scope);
scheduleUpdate(scope);
});
// Подписка на собственное событие после успешного лайка
document.addEventListener('vote:happened', function(e) {
if (e.detail && e.detail.pid && e.detail.type === 'thank') {
var pid = e.detail.pid;
var specificPost = document.getElementById('p' + pid);
if (specificPost) {
if (!pendingUpdates.has(pid)) {
pendingUpdates.add(pid);
fetchVotesForPosts([pid]).finally(() => {
pendingUpdates.delete(pid);
});
}
}
}
});
// Перехват fetch для отслеживания лайков от любых пользователей
const originalFetch = window.fetch;
window.fetch = function(...args) {
const url = args[0];
if (typeof url === 'string' && /relation\.php.*[&?]v=(\d+)/.test(url)) {
const match = url.match(/id=(\d+)/);
const pid = match ? match[1] : null;
const voteType = url.match(/v=(\d+)/)?.[1];
if (pid && voteType === '1') {
if (!pendingUpdates.has(pid)) {
pendingUpdates.add(pid);
fetchVotesForPosts([pid]).finally(() => {
pendingUpdates.delete(pid);
});
}
}
}
return originalFetch.apply(this, args);
};
// Наблюдатель за изменениями DOM для динамических постов
const observer = new MutationObserver(function(mutationsList) {
let shouldUpdate = false;
let relevantScope = document.body;
for (let mutation of mutationsList) {
if (mutation.type === 'childList') {
for (let node of mutation.addedNodes) {
if (node.nodeType === 1) {
if (node.classList && node.classList.contains('post')) {
relevantScope = node;
shouldUpdate = true;
break;
} else if (node.querySelector && node.querySelector('div.post')) {
relevantScope = node;
shouldUpdate = true;
break;
} else {
const nestedPosts = node.querySelectorAll && node.querySelectorAll('div.post');
if (nestedPosts && nestedPosts.length > 0) {
relevantScope = node;
shouldUpdate = true;
break;
}
}
}
}
if (shouldUpdate) break;
}
}
if (shouldUpdate) {
requestAnimationFrame(() => {
applyPlusHandlers(relevantScope);
hideAllNewPostVotes(relevantScope);
scheduleUpdate(relevantScope);
});
}
});
observer.observe(document.body, { childList: true, subtree: true });
} // Конец блока if (isViewTopicPage)
</script>
<!-- ⚡Мгновенные" Быстроплюсы" и ⚡мгновенные "Сказали спасибо" -->
Ссылку замените на файл c Вашей картинкой сердечка (с нужным Вам фоном или на прозрачном фоне).
🔀Способ для опытных администраторов:
Исходные коды CSS и JS вставляете в HTML Низ, а ещё лучше - сохраните коды в файлы (без строчек с <style> и </style> в файле с CSS и без <script> и </script> для файла с JS) и подключите как внешние ресурсы через link rel и script src.
Если вам нужны лишь "Быстроплюсы", то вы берёте вышеприведённый CSS, а JS будет такой:
JS (⚡Мгновенные "Быстроплюсы")
Развитие проекта:
В код внесены исправления для надёжности.
Список изменений:
Добавлена проверка if (!$('#pun-viewtopic').length) return; в следующие места:
✅ getPostIds() - не получает ID постов вне топика
✅ setPlus() - не обрабатывает клики вне топика
✅ BR() - обёртка для кликов
✅ hideAllNewPostVotes() - не скрывает элементы вне топика
✅ applyPlusHandlers() - не назначает обработчики вне топика
✅ insertThanksList() - не вставляет списки вне топика
✅ processVotes() - не обрабатывает ответы вне топика
✅ fetchVotesForPosts() - не делает запросы вне топика
✅ scheduleUpdate() - не планирует обновления вне топика
✅ combinedBackupUpdate() - не обновляет вне топика
✅ $(function() {...}) - не инициализирует вне топика
✅ document.addEventListener('pun_post', ...) - не реагирует на события вне топика
✅ document.addEventListener('vote:happened', ...) - не обрабатывает лайки вне топика
✅ $(document).on('ajaxSuccess', ...) - не слушает AJAX вне топика
✅ MutationObserver callback - не наблюдает за изменениями вне топика
P.S. Последняя версия: код стал полностью независимым от jQuery и работает на чистом JavaScript.
При разработке использовались нейросети, затем код был проверен и протестирован, сейчас всё работает на реальных форумах.
Отредактировано Merlin777 (Сб, 4 Июл 2026 05:54:51)