// ==UserScript==
// @name Tabun Replies
// @name:ru Ответы на Табуне
// @namespace https://andreymal.org
// @version 0.5.0
// @description Добавляет уведомления об ответах на Табуне
// @author andreymal
// @license MIT
// @homepage https://tabun.everypony.ru/blog/computers/108927.html
// @downloadURL https://andreymal.org/files/userscripts/tabun-replies/tabun-replies.user.js
// @updateURL https://andreymal.org/files/userscripts/tabun-replies/tabun-replies.meta.js
// @match https://tabun.everypony.ru/*
// @match https://tabun.everypony.org/*
// @match https://tabun.everypony.info/*
// @connect tabun.andreymal.org
// @connect *
// @noframes
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_notification
// @grant GM.xmlHttpRequest
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.deleteValue
// @grant GM.notification
// @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js
// ==/UserScript==
'use strict';
const baseHost = 'https://tabun.andreymal.org';
const defaultSettings = {
v: 1,
interval: 10,
commentsPerPage: 20,
iconUrl: `${baseHost}/files/templates/tabun/icon.png`,
loadingIconUrl: `${baseHost}/files/templates/tabun/tabunyasha.gif`,
width: 500,
height: 600,
notifications: true,
infoUrl: `${baseHost}/notifications/info.json`,
contentUrl: `${baseHost}/notifications/inline.json`,
challengeUrl: `${baseHost}/crosssite/challenge`,
token: '',
};
let tabunReplies = null;
const mainHtml = `
`;
const dropdownHtml = `
Ответы
(всё сломалось)
`;
const mainCss = `
.tabun-replies-container {
z-index: 10000;
position: absolute;
right: 100px;
top: 100px;
bottom: 8px;
color: #333;
background-color: #fff;
transition-property: transform, opacity;
transition-duration: 150ms;
transform: translateY(0px);
opacity: 1.0;
}
.tabun-replies-container.show-start {
transform: translateY(-20px);
opacity: 0.0;
}
.tabun-replies-container.hide-end {
pointer-events: none;
transform: translateY(20px);
opacity: 0.0;
}
.tabun-replies-layout {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.tabun-replies-content-wrap {
position: relative;
overflow: hidden;
flex-grow: 1;
}
.tabun-replies-content {
width: 100%;
height: 100%;
padding: 8px;
box-sizing: border-box;
overflow-x: auto;
overflow-y: scroll;
}
.tabun-replies-loading-overlay {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
overflow: hidden;
align-items: center;
justify-content: center;
display: none;
}
.tabun-replies-container.loading .tabun-replies-loading-overlay {
display: flex;
}
.tabun-replies-container.loading .tabun-replies-content {
opacity: 0.5;
pointer-events: none;
}
.tabun-replies-statusbar {
border-top: 1px solid rgba(128, 128, 128, 0.25);
overflow: hidden;
height: 20px;
line-height: 20px;
padding: 4px;
flex-shrink: 0;
text-align: right;
}
.tabun-replies-settings-link {
text-decoration-style: dotted;
}
.tabun-replies-settings-overlay {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.tabun-replies-settings {
position: absolute;
left: 4%;
right: 4%;
top: 4%;
max-height: 92%;
overflow: auto;
background-color: white;
padding: 8px;
border-radius: 4px;
box-sizing: border-box;
}
.tabun-replies-test-notification-link {
text-decoration-style: dotted;
}
.tabun-replies-settings-resetbtn {
float: right;
}
.tabun-replies-viewed-line-wrap {
background-color: #fff;
height: 1.5em;
}
.tabun-replies-viewed-line {
height: 0.75em;
line-height: 1.5em;
overflow: visible;
border-bottom: 1px solid rgba(128, 128, 128, 0.5);
text-align: center;
}
.tabun-replies-viewed-line-text {
display: inline-block;
padding: 0 1em;
background-color: #fff;
}
.tabun-replies-more {
text-align: center;
}
.tabun-replies-more-link {
text-decoration-style: dotted;
}
`;
// Основное окно с содержимым и настройками
class MainWindow {
constructor() {
this._visible = false;
const container = document.createElement('div');
container.innerHTML = mainHtml;
container.className = 'tabun-replies-container';
container.style.display = 'none';
this._dom = {
container,
content: container.querySelector('.tabun-replies-content'),
loadingImg: container.querySelector('.tabun-replies-loading-img'),
settingsLink: container.querySelector('.tabun-replies-settings-link'),
settingsOverlay: container.querySelector('.tabun-replies-settings-overlay'),
settingsForm: container.querySelector('.tabun-replies-settings-form'),
settingsCancelBtn: container.querySelector('.tabun-replies-settings-cancelbtn'),
settingsResetBtn: container.querySelector('.tabun-replies-settings-resetbtn'),
testNotificationLink: container.querySelector('.tabun-replies-test-notification-link'),
}
this._hideTask = null;
// Клик по ссылке настроек
this._dom.settingsLink.addEventListener('click', (e) => {
e.preventDefault();
this.toggleSettingsForm(true);
return false;
});
// Сохранение настроек (перехват отправки html-формы)
this._dom.settingsForm.addEventListener('submit', (e) => {
e.preventDefault();
asyncTry(this.saveSettingsForm());
return false;
});
// Закрытие настроек без сохранения
this._dom.settingsCancelBtn.addEventListener('click', (e) => {
e.preventDefault();
this.toggleSettingsForm(false);
return false;
});
// Сброс настроек на стандартные
this._dom.settingsResetBtn.addEventListener('click', (e) => {
e.preventDefault();
asyncTry(this.resetSettings());
return false;
});
// Пробное уведомление
this._dom.testNotificationLink.addEventListener('click', (e) => {
e.preventDefault();
const form = this._dom.settingsForm;
tabunReplies.testNotification(form.url_icon.value);
});
// Пересчёт координат при изменении размера окна браузера
window.addEventListener('resize', this._resizeEvent.bind(this));
// Закрытие окна ответов при клике за его пределами
document.body.addEventListener('mousedown', this._bodyClickEvent.bind(this));
// Клик по ссылкам внутри окна
this._dom.content.addEventListener('click', this._contentClickEvent.bind(this));
document.body.appendChild(container);
}
updateLayout() {
this._dom.container.style.width = `${tabunReplies.settings.get('width')}px`;
this._dom.container.style.maxHeight = `${tabunReplies.settings.get('height')}px`;
const loadingIconUrl = tabunReplies.settings.get('loadingIconUrl');
if (loadingIconUrl) {
this._dom.loadingImg.style.display = '';
this._dom.loadingImg.src = loadingIconUrl;
} else {
this._dom.loadingImg.style.display = 'none';
}
this.updatePosition();
}
updatePosition() {
// Чтобы не бодаться с overflow: hidden, пихаем окно в конец ,
// а правильные координаты вычисляем вручную
const anchorElement = tabunReplies.dropdown.getContainer();
const anchorPos = anchorElement.getBoundingClientRect();
const pagePos = document.body.getBoundingClientRect();
this._dom.container.style.right = (pagePos.right - anchorPos.right + 4) + 'px';
this._dom.container.style.top = (anchorPos.bottom - pagePos.top + 4) + 'px';
}
show() {
this.updatePosition();
this._dom.container.style.display = '';
this._visible = true;
// Запуск анимации плавного появления
this._dom.container.classList.remove('hide-end');
this._dom.container.classList.add('show-start');
this._dom.container.offsetWidth; // force reflow
this._dom.container.classList.remove('show-start');
if (this._hideTask !== null) {
clearTimeout(this._hideTask);
this._hideTask = null;
}
}
hide() {
// Запуск анимации плавного скрытия
if (this._hideTask === null) {
this._dom.container.classList.add('hide-end');
this._hideTask = setTimeout(this._hideEnd.bind(this), 150);
}
this._visible = false;
}
_hideEnd() {
this._hideTask = null;
this._dom.container.style.display = 'none';
this._dom.container.classList.remove('hide-end');
this.toggleSettingsForm(false);
this.setContent('');
}
isVisible() {
return this._visible;
}
toggle() {
if (this._visible) {
this.hide();
} else {
this.show();
}
}
setLoading(isLoading) {
if (isLoading !== this._dom.container.classList.contains('loading')) {
this._dom.container.classList.toggle('loading');
}
}
setContent(html) {
this._dom.content.innerHTML = html;
this._dom.content.scrollTop = 0;
}
loadSettingsFormData() {
const form = this._dom.settingsForm;
form.interval.value = tabunReplies.settings.get('interval');
form.comments_per_page.value = tabunReplies.settings.get('commentsPerPage');
form.url_icon.value = tabunReplies.settings.get('iconUrl');
form.url_loading_icon.value = tabunReplies.settings.get('loadingIconUrl');
form.width.value = tabunReplies.settings.get('width');
form.height.value = tabunReplies.settings.get('height');
form.notifications.checked = tabunReplies.settings.get('notifications');
form.url_info.value = tabunReplies.settings.get('infoUrl');
form.url_content.value = tabunReplies.settings.get('contentUrl');
form.url_challenge.value = tabunReplies.settings.get('challengeUrl');
form.token.value = tabunReplies.settings.get('token');
}
toggleSettingsForm(visible) {
if (visible) {
this.loadSettingsFormData();
this._dom.settingsOverlay.querySelectorAll('.spoiler-body').forEach(s => { s.style.display = 'none'; } );
}
this._dom.settingsOverlay.style.display = visible ? '' : 'none';
}
async saveSettingsForm() {
const form = this._dom.settingsForm;
tabunReplies.settings.set('interval', parseInt(form.interval.value, 10));
tabunReplies.settings.set('commentsPerPage', parseInt(form.comments_per_page.value, 10));
tabunReplies.settings.set('iconUrl', form.url_icon.value);
tabunReplies.settings.set('loadingIconUrl', form.url_loading_icon.value);
tabunReplies.settings.set('width', parseInt(form.width.value, 10));
tabunReplies.settings.set('height', parseInt(form.height.value, 10));
tabunReplies.settings.set('notifications', form.notifications.checked);
tabunReplies.settings.set('infoUrl', form.url_info.value);
tabunReplies.settings.set('contentUrl', form.url_content.value);
tabunReplies.settings.set('challengeUrl', form.url_challenge.value);
tabunReplies.settings.set('token', form.token.value);
try {
await tabunReplies.settings.save();
tabunReplies.stopPolling();
tabunReplies.startPolling(true);
this.updateLayout();
} catch (err) {
console.error(err);
alert('Произошла ошибка при сохранении настроек');
return;
}
this.toggleSettingsForm(false);
}
async resetSettings() {
if (!confirm('Сбросить настройки на стандартные?\nВам понадобится авторизоваться заново.')) {
return;
}
await tabunReplies.settings.reset();
tabunReplies.stopPolling();
tabunReplies.startPolling(true);
this.updateLayout();
this.toggleSettingsForm(false);
}
_resizeEvent() {
if (this._visible && window.innerWidth < 760) {
this.hide();
} else if (this._visible) {
this.updatePosition();
}
}
_bodyClickEvent(event) {
if (!this._visible) {
return;
}
if (!this._dom.container.contains(event.target) && !tabunReplies.dropdown.getContainer().contains(event.target)) {
this.hide();
}
}
_contentClickEvent(event) {
let target = event.target;
while (target && target.tagName !== 'A') {
target = target.parentNode;
if (target === this._dom.content) {
target = null;
}
}
if (!target || !target.getAttribute('data-inline-href')) {
return;
}
event.preventDefault();
asyncTry(tabunReplies.getContent(target.getAttribute('data-inline-href')));
return false;
}
}
// Ссылка «Ответы» в шапке сайта
class Dropdown {
constructor() {
const container = document.createElement('li');
container.innerHTML = dropdownHtml;
this._dom = {
container,
link: container.querySelector('.tabun-replies-dropdown-link'),
unreadCount: container.querySelector('.tabun-replies-unread-count'),
errorMsg: container.querySelector('.tabun-replies-error-msg'),
}
this._lastUnreadCount = 0;
this._lastShowError = false;
this._lastPageUrl = this._dom.link.href.toString();
this._dom.link.addEventListener('mousedown', this._linkDownEvent.bind(this));
this._dom.link.addEventListener('click', this._linkClickEvent.bind(this));
const dropdownUserMenu = document.getElementById('dropdown-user-menu');
if (dropdownUserMenu) {
dropdownUserMenu.insertBefore(container, dropdownUserMenu.firstElementChild);
} else {
const authPanel = document.querySelector('header ul.auth');
if (authPanel) {
authPanel.insertBefore(container, authPanel.firstElementChild);
} else {
console.log('Tabun Replies: cannot insert dropdown link');
}
}
}
_shouldHandleClick(e) {
// Клавиши-модификаторы оставляем браузеру
if ((e.which || e.button) !== 1 || e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) {
return false;
}
// На слишком маленьком окне открываем новую вкладку вместо окна ответов
if (window.innerWidth < 760 || window.innerHeight < 360) {
return false;
}
return true;
}
_linkDownEvent(e) {
if (this._shouldHandleClick(e)) {
e.preventDefault();
tabunReplies.mainWindow.toggle();
if (tabunReplies.mainWindow.isVisible()) {
asyncTry(tabunReplies.getContent());
}
return false;
}
}
_linkClickEvent(e) {
if (this._shouldHandleClick(e)) {
e.preventDefault();
return false;
}
}
getContainer() {
return this._dom.container;
}
setUnreadCount(count) {
if (this._lastUnreadCount === count) {
return;
}
if (!count || count < 0) {
this._lastUnreadCount = 0;
this._dom.unreadCount.style.display = 'none';
return;
}
this._lastUnreadCount = count;
this._dom.unreadCount.textContent = `+${count}`;
this._dom.unreadCount.style.display = '';
}
toggleError(showError) {
if (this._lastShowError !== !!showError) {
this._dom.errorMsg.style.display = showError ? '' : 'none';
this._lastShowError = !!showError;
}
}
// URL на ссылке «Ответы», который будет открываться по среднему клику мыши
setPageUrl(pageUrl) {
if (this._lastPageUrl !== pageUrl) {
this._dom.link.href = pageUrl || '#';
this._lastPageUrl = pageUrl;
}
}
}
// Чтение и изменение настроек
class Settings {
constructor() {
this._data = {v: defaultSettings.v};
}
async load() {
this._data = {v: defaultSettings.v};
const dataString = await GM.getValue('tabunRepliesSettings');
if (!dataString) {
return;
}
const newData = JSON.parse(dataString);
if (typeof this._data === 'object' && typeof this._data.v === 'number' && this._data.v >= 1) {
this._data = newData;
}
}
async save() {
const dataString = JSON.stringify(this._data);
await GM.setValue('tabunRepliesSettings', dataString);
}
async reset() {
this._data = {v: defaultSettings.v};
await this.save();
}
get(name) {
if (Object.prototype.hasOwnProperty.call(this._data, name)) {
return this._data[name];
}
return defaultSettings[name];
}
set(name, value, force) {
if (force || this.get(name) !== value) {
this._data[name] = value;
}
}
}
// Основная логика
class TabunReplies {
constructor() {
this.mainWindow = new MainWindow();
this.dropdown = new Dropdown();
this.settings = new Settings();
this._pollTaskId = null;
this._pollingNow = 0;
this._lastUnreadCount = null;
this._tabId = Math.floor(Math.random() * 1000000000);
}
async init() {
// Загружаем настройки из хранилища Greasemonkey
await this.settings.load();
this.mainWindow.updateLayout();
// Если в location.hash есть токен для авторизации — обрабатываем его
await this.getTokenFromHash();
// Запускаем периодический опрос числа непрочитанных ответов
this.startPolling(true);
}
getIntervalMs() {
const intervalMs = this.settings.get('interval') * 1000;
if (isNaN(intervalMs) || intervalMs < 1000) {
console.error(`Tabun Replies: bad interval ${this.settings.get('interval')}`);
this.settings.set('interval', 10);
return 10000;
}
return intervalMs;
}
startPolling(forceFetchNow) {
if (this._pollTaskId === null) {
this._pollTaskId = setInterval(() => { asyncTry(this.poll()); }, this.getIntervalMs());
}
asyncTry(this.poll(forceFetchNow));
}
stopPolling() {
if (this._pollTaskId !== null) {
clearInterval(this._pollTaskId);
this._pollTaskId = null;
}
}
async getTokenFromHash() {
// После авторизации на tabun.andreymal.org он перенаправляет обратно
// на tabun.everypony.ru с токеном для авторизации скрипта в location.hash
const f = location.hash.indexOf('tabun_replies_token=');
if (f < 0) {
return;
}
const challengeToken = location.hash.substring(f + 20);
history.replaceState(null, '', location.toString().split('#')[0]);
// Отправляем запрос для авторизации скрипта и получения токена доступа
let data = null;
try {
const response = await fetch(
this.settings.get('challengeUrl'),
{
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `challenge_token=${encodeURIComponent(challengeToken)}`,
},
);
data = await response.json();
} catch (err) {
console.error(err);
alert('Произошла какая-то ошибка при получении токена для юзерскрипта ответов');
return;
}
if (!data.success || !data.token) {
alert(`Ошибка при получении токена для юзерскрипта ответов:\n${data.error || '?'}`);
return;
}
this.settings.set('token', data.token);
await this.settings.save();
}
async poll(force) {
if (this._pollingNow > 0) {
return; // Кто-то уже запустил запрос информации (или предыдущий не успел завершиться)
}
// Если другая вкладка уже получила недавно информацию — выводим её вместо нового запроса
const info = await this.getSavedInfo();
if (info !== null && info.tabId !== this._tabId && Date.now() - info.time < this.getIntervalMs() - 100) {
this.dropdown.toggleError(false);
this.processInfo(info.info, false);
return;
}
if (!this.settings.get('token')) {
// Если нет токена, то просим пользователя авторизоваться
this.dropdown.toggleError(false);
this.updateUnreadCount(1, false);
return;
}
this._pollingNow++;
try {
await this.getInfo();
} finally {
this._pollingNow--;
}
}
async getInfo() {
const token = this.settings.get('token');
const headers = {'Authorization': token ? `Bearer ${token}` : null};
let result = null;
try {
result = await fetchJson(this.settings.get('infoUrl'), {headers});
} catch (err) {
console.error(err);
this.dropdown.toggleError(true);
return;
}
this.dropdown.toggleError(false);
if (result.need_auth) {
// Возможно, токен протух - просим авторизоваться заново
this.settings.set('token', '');
this.updateUnreadCount(1, false);
await this.saveInfo(null);
return;
}
// Отправляем полученную информацию в другие вкладки
if (result.success) {
try {
await this.saveInfo(result);
} catch (err) {
console.error(err);
}
}
// И в текущей вкладке тоже обрабатываем
this.processInfo(result, this.settings.get('notifications'));
}
async getSavedInfo() {
let data = null;
try {
const dataString = await GM.getValue('tabunRepliesInfo');
if (!dataString) {
return null;
}
data = JSON.parse(dataString);
} catch (err) {
console.error(err);
return null;
}
if (typeof data !== 'object' || typeof data.time !== 'number' || typeof data.info !== 'object') {
return null;
}
return data;
}
async saveInfo(info) {
if (!info) {
await GM.deleteValue('tabunRepliesInfo');
return;
}
await GM.setValue('tabunRepliesInfo', JSON.stringify({
info,
time: Date.now(),
tabId: this._tabId,
}));
}
processInfo(info, showNotification) {
if (info.page_url) {
this.dropdown.setPageUrl(info.page_url);
}
this.updateUnreadCount(info.unread_count || 0, showNotification);
}
updateUnreadCount(count, showNotification) {
this.dropdown.setUnreadCount(count);
if (!showNotification || this._lastUnreadCount === null || this._lastUnreadCount >= count || count < 1) {
this._lastUnreadCount = count;
return;
}
this._lastUnreadCount = count;
this.sendNotification();
}
getDefaultContentUrl() {
// URL по умолчанию, который будет загружаться первым при нажатии на ссылку «Ответы»
let url = this.settings.get('contentUrl');
if (!url) {
return null;
}
const params = new URLSearchParams();
params.append('count', this.settings.get('commentsPerPage').toString()); // В Firefox 52 работает только append
url += (url.indexOf('?') >= 0 ? '&' : '?') + params.toString();
return url;
}
async getContent(url) {
try {
const token = this.settings.get('token');
const headers = {'Authorization': token ? `Bearer ${token}` : null};
this.mainWindow.setLoading(true);
const data = await fetchJson(url || this.getDefaultContentUrl(), {headers});
this.mainWindow.setContent(data.html || '');
if (data.info && data.info.success) {
await this.saveInfo(data.info);
this.processInfo(data.info, false);
}
} catch (err) {
console.error(err);
this.mainWindow.setContent('Кажется, что-то пошло не так :(');
} finally {
this.mainWindow.setLoading(false);
}
}
testNotification(customIconUrl) {
this.sendNotification(customIconUrl);
}
sendNotification(customIconUrl) {
let iconUrl = customIconUrl;
if (iconUrl == null) {
iconUrl = this.settings.get('iconUrl') || null;
}
GM.notification({
title: 'Ответ на комментарий',
text: `Новых ответов: ${this._lastUnreadCount}`,
image: iconUrl,
});
}
}
// utils
async function fetchJson(url, options) {
if (!url) {
throw new Error('empty url');
}
const readyOptions = {
headers: {
'X-Userscript-Version': GM.info.script.version,
'Accept': 'application/json',
},
referrer: '',
};
if (typeof options !== 'undefined') {
for (const key of Object.keys(options)) {
if (key === 'headers') {
for (const header of Object.keys(options.headers)) {
if (options.headers[header] !== null) {
readyOptions.headers[header] = options.headers[header];
} else {
delete readyOptions.headers[header];
}
}
} else {
readyOptions[key] = options[key];
}
}
}
const response = await fetch(url, readyOptions);
return await response.json();
}
// Привет некрофилам с Greasemonkey 3.11
function fallbackNotification(textOrOptions, title, image, onclick) {
if (typeof Notification === 'undefined') {
return;
}
let options = textOrOptions;
if (typeof textOrOptions !== 'object') {
options = {text: textOrOptions, title, image, onclick};
}
const n = new Notification(options.title || GM.info.script.name, {
body: options.text,
icon: options.image,
});
if (options.onclick != null) {
n.addEventListener('click', () => { options.onclick.apply(undefined); });
}
if (options.ondone != null) {
n.addEventListener('close', () => { options.ondone.apply(undefined); });
}
}
function asyncTry(promise) {
return promise.then(null, (err) => {
console.error('Tabun Replies error!');
console.error(err);
});
}
// init
function initNotifications() {
if (typeof GM.notification === 'undefined') {
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
Notification.requestPermission();
}
GM.notification = fallbackNotification;
}
}
function injectStyles() {
// Базовые стили
const styles = [mainCss];
// Адаптация под кастомные юзерстили (Night Tabun и т.п.)
const backgroundColor = window.getComputedStyle(document.getElementById('container')).backgroundColor;
const textColor = window.getComputedStyle(document.getElementById('content')).color;
styles.push(`.tabun-replies-container { color: ${textColor}; background-color: ${backgroundColor}; }`);
styles.push(`.tabun-replies-settings, .tabun-replies-viewed-line-wrap, .tabun-replies-viewed-line-text { background-color: ${backgroundColor}; }`);
const c = textColor.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i);
if (c) {
styles.push(`.tabun-replies-container { box-shadow: 0 0 5px rgba(${c[1]}, ${c[2]}, ${c[3]}, 0.4); }`);
}
// Цвета ссылок в шапке иногда переопределяются юзерстилями индивидуально,
// поэтому для соответствия приходится костылять вот так
const newMessagesElem = document.getElementById('new_messages');
if (newMessagesElem) {
const linkColor = window.getComputedStyle(newMessagesElem).color;
styles.push(`.tabun-replies-dropdown-link { color: ${linkColor} !important; }`);
}
// Добавляем стили на страницу
const style = document.createElement('style');
style.textContent = styles.join('\n\n');
document.head.appendChild(style);
}
async function init() {
initNotifications();
injectStyles();
tabunReplies = new TabunReplies();
await tabunReplies.init();
console.log('Tabun Replies: started!');
}
init().then(null, (err) => {
console.error('Tabun Replies: init failed!');
console.error(err);
});