lorify-ng

Юзерскрипт для сайта linux.org.ru поддерживающий загрузку комментариев через технологию WebSocket, а так же уведомления об ответах через системные оповещения.

// ==UserScript==
// @name        lorify-ng
// @description Юзерскрипт для сайта linux.org.ru поддерживающий загрузку комментариев через технологию WebSocket, а так же уведомления об ответах через системные оповещения.
// @namespace   https://github.com/OpenA
// @include     https://www.linux.org.ru/*
// @include     http://www.linux.org.ru/*
// @version     2.0.7
// @grant       none
// @homepageURL https://www.linux.org.ru/forum/talks/12371302
// @icon        https://rawgit.com/OpenA/lorify-ng/master/icons/penguin-32.png
// @run-at      document-start
// ==/UserScript==

const USER_SETTINGS = {
	'Realtime Loader': true,
	'CSS3 Animation' : true,
	'Delay Open Preview': 0,
	'Delay Close Preview': 800,
	'Desktop Notification': true
}

const pagesCache    = new Object;
const ResponsesMap  = new Object;
const CommentsCache = new Object;
const LoaderSTB     = _setup('div', { html: '<div class="page-loader"></div>' });
const LOR           = parseLORUrl(location.pathname);
const [,TOKEN = ''] = document.cookie.match(/CSRF_TOKEN="?([^;"]*)/);
const Timer         = {
	// clear timer by name
	clear: function(name) {
		clearTimeout(this[name]);
	},
	// set/replace timer by name
	set: function(name, func, t = 50) {
		this.clear(name);
		this[name] = setTimeout(func, USER_SETTINGS['Delay '+ name] || t);
	}
}
document.documentElement.append(
	_setup('script', { text: '('+ startRWS.toString() +')(window)', id: 'start-rws'}),
	_setup('style' , { text: `
		.newadded  { border: 1px solid #006880; }
		.msg-error { color: red; font-weight: bold; }
		.broken    { color: inherit !important; cursor: default; }
		.response-block, .response-block > a { padding: 0 3px !important; }
		.pushed { position: relative; }
		.pushed:after {
			content: attr(push);
			position: absolute;
			font-size: 12px;
			top: -6px;
			color: white;
			background: #3d96ab;
			line-height: 12px;
			padding: 3px;
			border-radius: 5px;
		}
		.deleted > .title:before {
			content: "Сообщение удалено";
			font-weight: bold;
			display: block;
		}
		.page-loader {
			border: 5px solid #f3f3f3;
			-webkit-animation: spin 1s linear infinite;
			animation: spin 1s linear infinite;
			border-top: 5px solid #555;
			border-radius: 50%;
			width: 50px;
			height: 50px;
			margin: 500px auto;
		}
		.terminate {
			animation-duration: .4s;
			position: relative;
		}
		.preview {
			animation-duration: .3s;
			position: absolute;
			z-index: 300;
			border: 1px solid grey;
		}
		.slide-down {
			max-height: 9999px;
			overflow-y: hidden;
			animation: slideDown 1.5s ease-in-out;
		}
		.slide-up {
			max-height: 0;
			overflow-y: hidden;
			animation: slideUp 1s ease-out;
		}
		
		@-webkit-keyframes slideDown { from { max-height: 0; } to { max-height: 3000px; } }
		@keyframes slideDown { from { max-height: 0; } to { max-height: 3000px; } }
		
		@-webkit-keyframes slideUp { from { max-height: 2000px; } to { max-height: 0; } }
		@keyframes slideUp { from { max-height: 2000px; } to { max-height: 0; } }
		
		@-webkit-keyframes toHide { from { opacity: 1; } to { opacity: 0; } }
		@keyframes toHide { from { opacity: 1; } to { opacity: 0; } }
		
		@-webkit-keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
		@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
		
		@-webkit-keyframes slideToShow { 0% { right: 100%; opacity: 0; } 100% { right: 0%; opacity: 1; } }
		@keyframes slideToShow { 0% { right: 100%; opacity: 0; } 100% { right: 0%; opacity: 1; } }
		
		@-webkit-keyframes slideToShow-reverse { 0% { left: 100%; opacity: 0; } 100% { left: 0%; opacity: 1; } }
		@keyframes slideToShow-reverse { 0% { left: 100%; opacity: 0; } 100% { left: 0%; opacity: 1; } }
`}));

const Navigation = {
	
	pagesCount: 1,
	
	bar: _setup('div', { class: 'nav', html: `
		<a class="page-number prev" href="#prev">←</a>
		<a class="page-number next" href="#next">→</a>
	`, onclick: navBarHandle }),
	
	addToBar: function(pNumEls) {
		
		this.pagesCount = pNumEls.length - 2;
		
		var i = this.bar.children.length - 1;
		var pageLinks = '';
		
		for (; i <= this.pagesCount; i++) {
			let lp = pNumEls[i].pathname || LOR.path +'#comments';
			pageLinks += '\t\t<a id="page_'+ (i - 1) +'" class="page-number" href="'+ lp +'">'+ i +'</a>\n';
		}
		this.bar.lastElementChild.insertAdjacentHTML('beforebegin', pageLinks);
		
		if (LOR.page === 0) {
			this.bar.firstElementChild.classList.add('broken');
			this.bar.lastElementChild.href = this.bar.children['page_'+ (LOR.page + 1)].href;
		} else 
		if (LOR.page === this.pagesCount - 1) {
			this.bar.lastElementChild.classList.add('broken');
			this.bar.firstElementChild.href = this.bar.children['page_'+ (LOR.page - 1)].href;
		}
		this.bar.children['page_'+ LOR.page].className = 'page-number broken';
		
		return this.bar;
	}
}

function navBarHandle(e) {
	e.target.classList.contains('broken') && e.preventDefault();
}

_setup(window, null, {
	dblclick: () => {
		var newadded = document.querySelectorAll('.newadded');
		newadded.forEach(nwc => nwc.classList.remove('newadded'));
		Tinycon.setBubble(
			(Tinycon.index -= newadded.length)
		);
	}
});

_setup(document, null, {
	
	'DOMContentLoaded': function onDOMReady() {
		
		this.removeEventListener('DOMContentLoaded', onDOMReady);
		this.getElementById('start-rws').remove();
		
		appInit();
		
		if (!LOR.topic) {
			return;
		}
		
		Tinycon.index = 0;
		sessionStorage['rtload'] = +USER_SETTINGS['Realtime Loader'];
		
		const pagesElements = this.querySelectorAll('.messages > .nav > .page-number');
		const comments      = this.getElementById('comments');
		
		if (pagesElements.length) {
			
			let bar = Navigation.addToBar(pagesElements);
			let nav = pagesElements[0].parentNode;
			
			nav.parentNode.replaceChild(bar, nav);
			
			_setup(comments.querySelector('.nav'), { html: bar.innerHTML, onclick: navBarHandle });
		}
		pagesCache[LOR.page] = comments;
		
		addToCommentsCache( comments.querySelectorAll('.msg[id^="comment-"]') );
	},
	
	'webSocketData': onWSData
});

function onWSData({ detail }) {
	// Get an HTML containing the comment
	fetch(detail.path +'?cid='+ detail[0] +'&skipdeleted=true', { credentials: 'same-origin' }).then(
		response => {
			if (response.ok) {
				const { page } = parseLORUrl(response.url);
				const  topic   = document.getElementById('topic-'+ LOR.topic);
				response.text().then(html => {
					
					const comms = getCommentsContent(html);
					
					comms.querySelectorAll('a[itemprop="replyToUrl"]').forEach(a => { a.onclick = toggleForm });
					
					if (page in pagesCache) {
					
						let parent = pagesCache[page];
						
						parent.querySelectorAll('.msg[id^="comment-"]').forEach(msg => {
							if (msg.id in comms.children) {
								var cand = comms.children[msg.id],
								    sign = cand.querySelector('.sign_more > time');
								if (sign && sign.dateTime !== (msg['last_modifed'] || {}).dateTime) {
									msg['last_modifed']    = sign;
									msg['edit_comment']    = cand.querySelector('.reply a[href^="/edit_comment"]');
									msg['response_block'] && cand.querySelector('.reply > ul')
										.appendChild(msg['response_block']);
									
									_setup(cand.querySelector('a[itemprop="replyToUrl"]'), { onclick: toggleForm })
									
									for (var R = msg.children.length; 0 < (R--);) {
										parent.replaceChild(cand.children[R], parent.children[R]);
									}
								} else if (msg['edit_comment']) {
									msg['edit_comment'].hidden = !cand.querySelector('.reply a[href^="/edit_comment"]');
								}
							} else {
								_setup(msg, { id: undefined, class: 'msg deleted' });
							}
						});
						
						for (var i = 0, arr = []; i < detail.length; i++) {
						
							let comment = _setup(comms.children['comment-'+ detail[i]], { class: 'msg newadded' });
							
							if (!comment) {
								detail.splice(0, i);
								onWSData({ detail });
								break;
							}
							arr.push( parent.appendChild(comment) );
						}
						Tinycon.index += i;
						if (LOR.page !== page) {
							let push = i + (
								Number ( Navigation.bar.children['page_'+ page].getAttribute('push') ) || 0
							);
							
							_setup(      Navigation.bar.children['page_'+ page], { class: 'page-number pushed', push: push });
							_setup( parent.querySelector('.nav > #page_'+ page), { class: 'page-number pushed', push: push });
						}
						addToCommentsCache( arr );
					} else {
						pagesCache[page] = comms;
						let nav = comms.querySelector('.nav');
						let bar = Navigation.addToBar(nav.children);
						let msg = comms.querySelectorAll('.msg[id^="comment-"]');
						
						bar.children['page_'+ page].setAttribute('push', msg.length);
						bar.children['page_'+ page].classList.add('pushed');
						if (!bar.parentNode) {
							let rt = document.getElementById('realtime');
							rt.parentNode.insertBefore(bar, rt.nextSibling);
							pagesCache[LOR.page].insertBefore(_setup(bar.cloneNode(true), { onclick: navBarHandle }),
								pagesCache[LOR.page].firstElementChild.nextSibling);
						} else {
							_setup(pagesCache[LOR.page].querySelector('.nav'), {
								html: bar.innerHTML, onclick: navBarHandle });
						}
						addToCommentsCache( msg );
						Tinycon.index += msg.length;
					}
					Tinycon.setBubble(Tinycon.index);
					history.replaceState(null, document.title, location.pathname);
				});
			} else {
				
			}
		});
}

function startRWS(win) {
	if ('WebSocket' in win || 'MozWebSocket' in win && (win.WebSocket = MozWebSocket)) {
		var timer, detail = new Array(0);
		Object.defineProperty(win, 'startRealtimeWS', {
			value: function(topic, path, cid, wss) {
				var wS = new WebSocket(wss +'ws'),
				    qA = false;
				wS.onmessage = function(e) {
					detail.push( (cid = e.data) );
					clearTimeout( timer );
					timer = setTimeout(function() {
						var realtime = document.getElementById('realtime');
						if (sessionStorage['rtload'] == '1') {
							detail.path = path;
							document.dispatchEvent(
								new CustomEvent('webSocketData', { detail })
							);
							detail = new Array(0);
							realtime.style.display = 'none';
						} else {
							realtime.innerHTML = 'Был добавлен новый комментарий.\n<a href="'+
								path + '?cid=' + cid +'">Обновить.</a>';
							realtime.style.display = null;
						}
					}, 2e3);
				}
				wS.onopen = function(e) {
					wS.send(topic + (cid == 0 ? '' : ' '+ cid));
				}
				wS.onclose = function(e) {
					setTimeout(function() {
						startRealtimeWS(topic, path, cid, wss)
					}, 5e3);
				}
			}
		});
	}
}

function addToCommentsCache(els) {
	
	for (var i = 0; i < els.length; i++) {
		
		let el  = els[i],
			cid = el.id.replace('comment-', '');
		
		el['last_modifed'] = el.querySelector('.sign_more > time');
		el['edit_comment'] = el.querySelector('.reply a[href^="/edit_comment"]');
		
		addPreviewHandler(
			(CommentsCache[cid] = el)
		);
		
		let acid = el.querySelector('.title > a[href*="cid="]');
		
		if (acid) {
			// Extract reply comment ID from the 'search' string
			let num = acid.search.match(/cid=(\d+)/)[1];
			let url = el.ownerDocument.evaluate('//*[@class="reply"]/ul/li/a[contains(text(), "Ссылка")]/@href',el,null,2,null);
			// Write special attributes
			_setup(acid, { class: 'link-pref', cid: num });
			// Create new response-map for this comment
			if (!(num in ResponsesMap)) {
				ResponsesMap[num] = new Array(0);
			}
			ResponsesMap[num].push({
				text: (el.querySelector('a[itemprop="creator"]') || { textContent: 'anonymous' }).textContent,
				href: url.stringValue,
				cid : cid
			});
		}
	}
	
	for (var cid in ResponsesMap) {
		if ( cid in CommentsCache ) {
			
			let comment = CommentsCache[cid];
			
			if(!comment['response_block']) {
				comment['response_block'] = comment.querySelector('.reply > ul')
				.appendChild( _setup('li', { class: 'response-block', text: 'Ответы:' }) );
			}
			
			ResponsesMap[cid].forEach(attrs => {
				attrs['class' ] = 'link-pref';
				attrs['search'] = '?cid='+ attrs.cid;
				comment['response_block'].appendChild( _setup('a', attrs) );
			});
			
			delete ResponsesMap[cid];
		}
	}
}

function addPreviewHandler(comment) {
	
	comment.addEventListener('mouseover', function(e) {
		switch (e.target.classList[0]) {
			case 'link-pref':
				Timer.clear('Close Preview');
				Timer.set('Open Preview', () => showPreview(e));
				e.preventDefault();
		}
	});
	
	comment.addEventListener('mouseout', function(e) {
		switch (e.target.classList[0]) {
			case 'link-pref':
				Timer.clear('Open Preview');
		}
	});
	
	comment.addEventListener('click', function(e) {
		switch (e.target.classList[0]) {
			case 'link-pref':
				let view = document.getElementById('comment-'+ e.target.getAttribute('cid'));
				if (view) {
					view.scrollIntoView({ block: 'start', behavior: 'smooth' });
					e.preventDefault();
				}
		}
	});
}

function getCommentsContent(html) {
	// Create new DOM tree
	const old = document.getElementById('topic-'+ LOR.topic);
	const doc = new DOMParser().parseFromString(html, 'text/html'),
	    topic = doc.getElementById('topic-'+ LOR.topic),
	    comms = doc.getElementById('comments');
	// Remove banner scripts
	comms.querySelectorAll('script').forEach(s => s.remove());
	// Replace topic if modifed
	if (old.textContent !== topic.textContent) {
		tpc.parentNode.replaceChild(topic, old);
		topic_memories_form_setup(0, true, LOR.topic, TOKEN);
		topic_memories_form_setup(0, false, LOR.topic, TOKEN);
		_setup(topic.querySelector('a[href="comment-message.jsp?topic='+ LOR.topic +'"]'), { onclick: toggleForm })
	}
	return comms;
}

function showPreview(e) {
	
	// Get comment's ID from custom attribute 
	var commentID = e.target.getAttribute('cid'),
	    commentEl;

	// Let's reduce an amount of GET requests
	// by searching a cache of comments first
	if (commentID in CommentsCache) {
		commentEl = document.getElementById('preview-'+ commentID);
		if (!commentEl) {
			// Without the 'clone' call we'll just move the original comment
			commentEl = CommentsCache[commentID].cloneNode(
				(e.isNew = true)
			);
		}
	} else {
		// Add Loading Process stub
		commentEl = _setup('article', { class: 'msg preview', text: 'Загрузка...'});
		// Get an HTML containing the comment
		fetch(e.target.href, { credentials: 'same-origin' }).then(
			response => {
				if (response.ok) {
					const { page } = parseLORUrl(response.url);
					response.text().then(html => {
						pagesCache[page] = getCommentsContent(html);
						
						addToCommentsCache(
							pagesCache[page].querySelectorAll('.msg[id^="comment-"]')
						);
						
						if (commentEl.parentNode) {
							commentEl.remove();
							showCommentInternal(
								pagesCache[page].children['comment-'+ commentID].cloneNode((e.isNew = true)),
								commentID,
								e
							);
						}
					})
				} else {
					commentEl.textContent = response.status +' '+ response.statusText;
					commentEl.classList.add('msg-error');
				}
			});
	}
	showCommentInternal(
		commentEl,
		commentID,
		e
	);
}

const openPreviews = document.getElementsByClassName('preview');

function removePreviews(comment) {
	var c = openPreviews.length - 1;
	while (openPreviews[c] !== comment) {
		openPreviews[c--].remove();
	}
}

function showCommentInternal(commentElement, commentID, e) {
	// From makaba
	const hoveredLink = e.target;
	const parentBlock = document.getElementById('comments');
	
	const { left, top, right, bottom } = hoveredLink.getBoundingClientRect();
	
	const visibleWidth  = innerWidth  / 2;
	const visibleHeight = innerHeight * 0.75;
	const offsetX       = pageXOffset + left + hoveredLink.offsetWidth / 2;
	const offsetY       = pageYOffset + bottom + 10;
	
	let postproc = () => {
		
		commentElement.style['left'] = Math.max(
			offsetX - (
				left < visibleWidth
				     ? 0
				     : commentElement.offsetWidth)
				, 5) + 'px';
		
		commentElement.style['top'] = pageYOffset + (
			top < visibleHeight
			    ? bottom + 10
			    : top - commentElement.offsetHeight - 10)
			+'px';
			
		if (!USER_SETTINGS['CSS3 Animation'])
			commentElement.style['animation-name'] = null;
	};
	
	if (e.isNew) {
		
		commentElement.setAttribute(
			'style',
				'animation-name: toShow; '+
				// There are no limitations for the 'z-index' in the CSS standard,
				// so it depends on the browser. Let's just set it to 300
				'max-width:'+ parentBlock.offsetWidth +
				'px; left: '+
				( left < visibleWidth
				       ? offsetX
				       : offsetX - visibleWidth ) +
				'px; top: '+
				( top < visibleHeight
				      ? offsetY
				      : 0 ) +'px;'
		);
		
		// Avoid duplicated IDs when the original comment was found on the same page
		commentElement.id = 'preview-'+ commentID;
		commentElement.classList.add('preview');
	
		// If this comment contains link to another comment,
		// set the 'mouseover' hook to that 'a' tag
		addPreviewHandler( commentElement );
		
		commentElement.addEventListener('animationstart', postproc, true);
	} else {
		commentElement.style['animation-name'] = null;
		postproc();
	}
	commentElement.onmouseleave = () => {
		// remove all preview's
		Timer.set('Close Preview', removePreviews)
	};
	commentElement.onmouseenter = () => {
		// remove all preview's after this one
		Timer.set('Close Preview', () => removePreviews(commentElement));
	};
	hoveredLink.onmouseleave = () => {
		// remove this preview
		Timer.set('Close Preview', () => commentElement.remove());
	};
	// Note that we append the comment to the '#comments' tag,
	// not the document's body
	// This is because we want to save the background-color and other styles
	// which can be customized by userscripts and themes
	parentBlock.appendChild(commentElement);
}

function _setup(el, _Attrs, _Events) {
	if (el) {
		if (typeof el === 'string') {
			el = document.createElement(el);
		}
		if (_Attrs) {
			for (var key in _Attrs) {
				_Attrs[key] === undefined ? el.removeAttribute(key) :
				key === 'html' ? el.innerHTML   = _Attrs[key] :
				key === 'text' ? el.textContent = _Attrs[key] :
				key in el    && (el[key]        = _Attrs[key] ) == _Attrs[key]
				             &&  el[key]       == _Attrs[key] || el.setAttribute(key, _Attrs[key]);
			}
		}
		if (_Events) {
			if ('remove' in _Events) {
				for (var type in _Events['remove']) {
					if (_Events['remove'][type].forEach) {
						_Events['remove'][type].forEach(function(fn) {
							el.removeEventListener(type, fn, false);
						});
					} else {
						el.removeEventListener(type, _Events['remove'][type], false);
					}
				}
				delete _Events['remove'];
			}
			for (var type in _Events) {
				el.addEventListener(type, _Events[type], false);
			}
		}
	}
	return el;
}

function parseLORUrl(uri) {
	const out = new Object;
	var m = uri.match(/^(?:https?:\/\/www\.linux\.org\.ru)?(\/\w+\/(?!archive)\w+\/(\d+))(?:\/page(\d+))?/);
	if (m) {
		out.path  = m[1];
		out.topic = m[2];
		out.page  = Number(m[3]) || 0;
	}
	return out;
}

function toggleForm(e) {
	const form = document.forms['commentForm'], parent = form.parentNode;
	const [, topic, replyto = 0 ] = this.href.match(/jsp\?topic=(\d+)(?:&replyto=(\d+))?$/);
	if (!form.elements['csrf'].value) {
		form.elements['csrf'].value = TOKEN;
	}
	if (form.elements['replyto'].value != replyto) {
		parent.style['display'] = 'none';
	}
	if (parent.style['display'] == 'none') {
		parent.className = 'slide-down';
		parent.addEventListener('animationend', function(e, _) {
			_setup(parent, { class: _ }, { remove: { animationend: arguments.callee }});
			form.elements['msg'].focus();
		});
		this.parentNode.parentNode.parentNode.parentNode.appendChild(parent).style['display'] = null;
		form.elements['replyto'].value = replyto;
		form.elements[ 'topic' ].value = topic;
	} else {
		parent.className = 'slide-up';
		parent.addEventListener('animationend', function(e, _) {
			_setup(this, { class: _, style: 'display: none;'}, { remove: { animationend: arguments.callee }});
		});
	}
	e.preventDefault();
}

const appInit = (ext => {
	
	if (ext && ext.storage) {
		ext.storage.sync.get(USER_SETTINGS, items => {
			for (let name in items) {
				USER_SETTINGS[name] = items[name];
			}
		});
		ext.storage.onChanged.addListener(items => {
			for (let name in items) {
				USER_SETTINGS[name] = items[name].newValue;
			}
			sessionStorage['rtload'] = +USER_SETTINGS['Realtime Loader'];
		});
		let port = ext.runtime.connect({ name: location.href });
		return function() {
			var main_events_count = document.getElementById('main_events_count'),
				onResponseHandler = main_events_count ? text => {
					main_events_count.textContent = text;
				} : () => void 0;
			// We can't show notification from the content script directly,
			// so let's send a corresponding message to the background script
			ext.runtime.sendMessage({ action: 'lorify-ng init' }, onResponseHandler);
			port.onMessage.addListener(onResponseHandler);
		};
	} else {
		var main_events_count,
		    sendNotify = () => void 0,
		    defaults   = Object.assign({}, USER_SETTINGS),
		    delay      = 2e4;
		    start      = () => {
				const xhr = new XMLHttpRequest;
				xhr.open('GET', location.origin +'/notifications-count', true);
				xhr.onload = function() {
					switch (this.status) {
						case 403:
							break;
						case 200:
							var text = '';
							if (this.response != '0') {
								text = '('+ this.response +')';
								if (USER_SETTINGS['Desktop Notification'] && localStorage['notes'] != this.response) {
									sendNotify( (localStorage['notes'] = this.response) );
									delay = 0;
								}
							}
							main_events_count.textContent = lorynotify.textContent = text;
						default:
							setTimeout(start, delay < 18e4 ? (delay += 2e4) : delay);
					}
				}
				xhr.send(null);
			}
			
		if (localStorage['lorify-ng']) {
			let storData = JSON.parse(localStorage.getItem('lorify-ng'));
			for (let name in storData) {
				USER_SETTINGS[name] = storData[name];
			}
		}
		
		const onValueChange = function({ target }) {
			Timer.clear('Settings on Changed');
			switch (target.type) {
				case 'checkbox':
					USER_SETTINGS[target.id] = target.checked;
					break;
				case 'number':
					USER_SETTINGS[target.id] = target.valueAsNumber >= 0 ? target.valueAsNumber : (target.value = 0);
			}
			localStorage.setItem('lorify-ng', JSON.stringify(USER_SETTINGS));
			applymsg.classList.add('apply-anim');
			Timer.set('Apply Setting MSG', () => applymsg.classList.remove('apply-anim'), 2e3);
			sessionStorage['rtload'] = +USER_SETTINGS['Realtime Loader'];
		}
		
		const loryform = _setup('form', { id: 'loryform', html: `
			<div class="tab-row">
				<span class="tab-cell">Автоподгрузка комментариев:</span>
				<span class="tab-cell" id="applymsg"><input type="checkbox" id="Realtime Loader" ${
					USER_SETTINGS['Realtime Loader'] ? 'checked' : '' }></span>
			</div>
			<div class="tab-row">
				<span class="tab-cell">Задержка появления превью:</span>
				<span class="tab-cell"><input type="number" id="Delay Open Preview" min="0" step="10" value="${
					USER_SETTINGS['Delay Open Preview'] }">
				мс
				</span>
			</div>
			<div class="tab-row">
				<span class="tab-cell">Задержка исчезания превью:</span>
				<span class="tab-cell"><input type="number" id="Delay Close Preview" min="0" step="10" value="${
					USER_SETTINGS['Delay Close Preview'] }">
				мс
				</span>
			</div>
			<div class="tab-row">
				<span class="tab-cell">Оповещения на рабочий стол:</span>
				<span class="tab-cell"><input type="checkbox" id="Desktop Notification" ${
					USER_SETTINGS['Desktop Notification'] ? 'checked' : '' }>
				</span>
			</div>
			<div class="tab-row">
				<span class="tab-cell">CSS анимация:</span>
				<span class="tab-cell"><input type="checkbox" id="CSS3 Animation" ${
					USER_SETTINGS['CSS3 Animation'] ? 'checked' : '' }>
					<input type="button" id="resetSettings" value="сброс" title="вернуть настройки по умолчанию">
				</span>
			</div>`,
				onchange: onValueChange,
				oninput: e => Timer.set('Settings on Changed', () => {
					loryform.onchange = () => { loryform.onchange = onValueChange };
					onValueChange(e)
				}, 750)
			}),
			applymsg = loryform.querySelector('#applymsg');
			loryform.elements['resetSettings'].onclick = () => {
				for (let name in defaults) {
					let inp = loryform.elements[name];
					inp[inp.type === 'checkbox' ? 'checked' : 'value'] = (USER_SETTINGS[name] = defaults[name]);
				}
				localStorage.setItem('lorify-ng', JSON.stringify(USER_SETTINGS));
				applymsg.classList.add('apply-anim');
				Timer.set('Apply Setting MSG', () => applymsg.classList.remove('apply-anim'), 2e3);
				sessionStorage['rtload'] = +USER_SETTINGS['Realtime Loader'];
			}
			const lorynotify = _setup( 'a' , { id: 'lorynotify', class: 'lory-btn', href: 'notifications' });
			const lorytoggle = _setup('div', { id: 'lorytoggle', class: 'lory-btn', html: `<style>
				#lorynotify {
					right: 60px;
					text-decoration: none;
					color: inherit;
					font: bold 1.2em "Open Sans";
				}
				#lorytoggle {
					width: 32px;
					height: 32px;
					right: 5px;
					cursor: pointer;
					opacity: .5;
					background: url(//icons.iconarchive.com/icons/icons8/christmas-flat-color/32/penguin-icon.png) center / 100%;
				}
				#loryform {
					display: table;
					min-width: 360px;
					padding: 3px 6px;
					position: fixed;
					right: 5px;
					top: 40px;
					background: #eee;
					border-radius: 5px;
				}
				#lorytoggle:hover, #lorytoggle.pinet { opacity: 1; }
				.lory-btn { position: fixed; top: 5px; }
				.tab-row  { display: table-row; font-size: 85%; color: #666; }
				.tab-cell { display: table-cell;
					position: relative;
					padding: 4px 2px;
					vertical-align: middle;
					max-width: 180px;
				}
				#resetSettings, .apply-anim:after { position: absolute; right: 0; }
				.apply-anim:after {
					content: 'Настройки сохранены.';
					-webkit-animation: apply 2s infinite;
					animation: apply 2s infinite;
					color: red;
				}
				@keyframes apply { 0% { opacity: .1; } 50% { opacity: 1; } 100% { opacity: 0; } }
				@-webkit-keyframes apply { 0% { opacity: .1; } 50% { opacity: 1; } 100% { opacity: 0; } }
			</style>`}, {
				click: () => { lorytoggle.classList.toggle('pinet') ? document.body.appendChild(loryform) : loryform.remove() }
			});
		if (Notification.permission === 'granted') {
			// Если разрешено то создаем уведомлений
			sendNotify = count => new Notification('loryfy-ng', {
				icon: '//icons.iconarchive.com/icons/icons8/christmas-flat-color/64/penguin-icon.png',
				body: 'Уведомлений: '+ count
			}).onclick = () => window.focus();
		} else
		if (Notification.permission !== 'denied') {
			Notification.requestPermission(function(permission) {
				// Если пользователь разрешил, то создаем уведомление 
				if (permission === 'granted') {
					sendNotify = count => new Notification('loryfy-ng', {
						icon: '//icons.iconarchive.com/icons/icons8/christmas-flat-color/64/penguin-icon.png',
						body: 'Уведомлений: '+ count
					}).onclick = () => window.focus();
				}
			});
		}
		return function() {
			if ( (main_events_count = document.getElementById('main_events_count')) ) {
				localStorage['notes'] = (
					lorynotify.textContent = main_events_count.textContent
				).replace(/\d+/, '$1');
				setTimeout(start, delay);
			}
			document.body.append(lorynotify, lorytoggle);
		};
	}
})(window.chrome || window.browser);