From 6aff2fbf8bd5d2a974a7354dbfda36f8e76c65b4 Mon Sep 17 00:00:00 2001 From: Sergei Ilinykh Date: Sun, 30 Jun 2024 02:05:05 +0300 Subject: [PATCH] move move stuff to utils from Bubble theme --- src/chatviewcommon.cpp | 5 + themes/chatview/psi/bubble/index.html | 27 +- themes/chatview/util.js | 1425 +++++++++++++------------ 3 files changed, 740 insertions(+), 717 deletions(-) diff --git a/src/chatviewcommon.cpp b/src/chatviewcommon.cpp index 2030957cd..933c93b0b 100644 --- a/src/chatviewcommon.cpp +++ b/src/chatviewcommon.cpp @@ -166,6 +166,11 @@ ChatViewCommon::updateReactions(const QString &senderNickname, const QString &me auto sanitized = orig.remove(skinRemove); ret << ReactionsItem { sanitized != orig ? sanitized : QString {}, orig, it.value() }; } + if (total.isEmpty()) { + _reactions.erase(msgIt); + } else if (userIt->isEmpty()) { + msgIt.value().perUser.erase(userIt); + } return ret; } diff --git a/themes/chatview/psi/bubble/index.html b/themes/chatview/psi/bubble/index.html index 50f44360c..ea99dda82 100644 --- a/themes/chatview/psi/bubble/index.html +++ b/themes/chatview/psi/bubble/index.html @@ -14,6 +14,7 @@ var themeStyle = document.getElementById("themeStyle").sheet; var cssBody = util.findStyleSheet(themeStyle, "body").style; const reactionsSelector = new shared.chat.ReactionsSelector(shared.session); +const likeButton = new shared.chat.LikeButton(reactionsSelector, document.documentElement); const chatMenu = new shared.chat.ContextMenu(); var applyPsiSettings = function() { @@ -78,26 +79,7 @@ } }, postProcess: function(el) { - let shared_timer = {} - el.addEventListener("mouseleave", function () { - if (shared_timer.timer) { // if we were going to show it - clearTimeout(shared_timer.timer); - shared_timer.timer = null; - } else { - const rs = el.getElementsByClassName("like_button")[0]; - rs.parentNode.removeChild(rs); - } - }); - el.addEventListener("mouseenter", function () { - shared_timer.timer = setTimeout(function () { - let selector = el.appendChild(document.createElement("div")); - selector.classList.add("like_button"); - selector.textContent = "❤️"; - setTimeout(() => { selector.classList.add('noopacity'); }, 0); - shared_timer.timer = null; - selector.addEventListener("click", () => reactionsSelector.show(el.id, selector, document.documentElement)); - }, 500); - }); + likeButton.setupForMessageElement(el); if (shared.cdata.reply) { const bq = el.getElementsByTagName("blockquote")[0]; if (bq) { @@ -107,8 +89,6 @@ } }); - - function setup_context_menu() { chatMenu.addItemProvider((event) => { const isNick = event.target.className == "nick"; @@ -413,8 +393,7 @@ padding-top: 1rem; /*background-color: green;*/ opacity: 0; - transition: opacity 0.5s linear, bottom .5s, font-size .5s; - ; + transition: opacity 0.3s linear, bottom .3s, font-size .3s; } .noopacity { diff --git a/themes/chatview/util.js b/themes/chatview/util.js index 6f4136955..658a96458 100644 --- a/themes/chatview/util.js +++ b/themes/chatview/util.js @@ -29,6 +29,85 @@ function initPsiTheme() { this.stop = function() { if (this.id){clearInterval(this.id); this.id = null;}}; } + function DateTimeFormatter(formatStr) { + function convertToTr35(format) + { + var ret="" + var i = 0; + var m = {M: "mm", H: "HH", S: "ss", c: "EEEE', 'MMMM' 'd', 'yyyy' 'G", + A: "EEEE", I: "hh", p: "a", Y: "yyyy"}; // if you want me, report it. + + var txtAcc = ""; + while (i < format.length) { + var c; + if (format[i] === "'" || + (format[i] === "%" && i < (format.length - 1) && (c = m[format[i+1]]))) + { + if (txtAcc) { + ret += "'" + txtAcc + "'"; + txtAcc = ""; + } + if (format[i] === "'") { + ret += "''"; + } else { + ret += c; + i++; + } + } else { + txtAcc += format[i]; + } + i++; + } + if (txtAcc) { + ret += "'" + txtAcc + "'"; + txtAcc = ""; + } + return ret; + } + + function convertToMoment(format) { + var inTxt = false; + var i; + var m = {j:"h"}; // sadly "j" is not supported + var ret = ""; + for (i = 0; i < format.length; i++) { + if (format[i] == "'") { + ret += (inTxt? ']' : '['); + inTxt = !inTxt; + } else { + var c; + if (!inTxt && (c = m[format[i]])) { + ret += c; + } else { + ret += format[i]; + } + } + } + if (inTxt) { + ret += "]"; + } + + ret = ret.replace("EEEE", "dddd"); + ret = ret.replace("EEE", "ddd"); + + return ret; + } + + formatStr = formatStr || "j:mm"; + if (formatStr.indexOf('%') !== -1) { + formatStr = convertToTr35(formatStr); + } + + formatStr = convertToMoment(formatStr); + + this.format = function(val) { + if (val instanceof String) { + val = Date.parse(val); + } + return moment(val).format(formatStr); // FIXME we could speedup it by keeping fomatter instances + } + } + function AudioMessage(el) { var playing = false; @@ -114,787 +193,747 @@ function initPsiTheme() { return that; } - var chat = { - async : async, - console : server.console, - server : server, - session : session, - hooks: [], - - util: { - console : server.console, - showCriticalError : function(text) { - var e=document.body || document.documentElement.appendChild(document.createElement("body")); - var er = e.appendChild(document.createElement("div")) - er.style.cssText = "background-color:red;color:white;border:1px solid black;padding:1em;margin:1em;font-weight:bold"; - er.innerHTML = chat.util.escapeHtml(text).replace(/\n/, "
"); - }, - - // just for debug - escapeHtml : function(html) { - html += ""; //hack - return html.split("&").join("&").split( "<").join("<").split(">").join(">"); - }, - - // just for debug - props : function(e, rec) { - var ret=''; - for (var i in e) { - var gotValue = true; - var val = null; - try { - val = e[i]; - } catch(err) { - val = err.toString(); - gotValue = false; - } - if (gotValue) { - if (val instanceof Object && rec && val.constructor != Date) { - ret+=i+" = "+val.constructor.name+"{"+chat.util.props(val, rec)+"}\n"; - } else { - if (val instanceof Function) { - ret+=i+" = Function: "+i+"\n"; - } else { - ret+=i+" = "+(val === null?"null\n":val.constructor.name+"(\""+val+"\")\n"); - } - } - } else { - ret+=i+" = [CAN'T GET VALUE: "+val+"]\n"; - } - } - return ret; - }, - - startSessionTransaction: function(starter, finisher) { - var tId = "st" + (++nextServerTransaction); - serverTransctions[tId] = finisher; - starter(tId); - }, + function WindowScroller(animate) { + var o=this, state, timerId + var ignoreNextScroll = false; + o.animate = animate; + o.atBottom = true; //just a state of aspiration + + var animationStep = function() { + timerId = null; + var before = document.body.clientHeight - (window.innerHeight+window.pageYOffset); + var step = before; + if (o.animate) { + step = step>200?200:(step<8?step:Math.floor(step/1.7)); + } + ignoreNextScroll = true; + window.scrollTo(0, document.body.clientHeight - window.innerHeight - before + step); + if (before>0) { + timerId = setTimeout(animationStep, 70); //next step in 250ms even if we are already at bottom (control shot) + } + } - _remoteCallEval : function(func, args, cb) { - function ecb(val) { val = eval("[" + val + "][0]"); cb(val); } + var startAnimation = function() { + if (timerId) return; + if (document.body.clientHeight > window.innerHeight) { //if we have what to scroll + timerId = setTimeout(animationStep, 0); + } + } - if (chat.async) { - args.push(ecb) - func.apply(this, args) - } else { - var val = func.apply(this, args); - ecb(val); - } - }, + var stopAnimation = function() { + if (timerId) { + clearTimeout(timerId); + timerId = null; + } + } - _remoteCall : function(func, args, cb) { - if (chat.async) { - args.push(cb) - func.apply(this, args) - } else { - var val = func.apply(this, args); - cb(val); + // ensure we at bottom on window resize + if (typeof ResizeObserver === 'undefined') { + + // next code is copied from www.backalleycoder.com/2013/03/18/cross-browser-event-based-element-resize-detection/ on 7 Dec 2018 + (function(){ + var attachEvent = document.attachEvent; + var isIE = navigator.userAgent.match(/Trident/); + //console.log(isIE); + var requestFrame = (function(){ + var raf = window.requestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || + function(fn){ return window.setTimeout(fn, 20); }; + return function(fn){ return raf(fn); }; + })(); + + var cancelFrame = (function(){ + var cancel = window.cancelAnimationFrame || window.mozCancelAnimationFrame || window.webkitCancelAnimationFrame || + window.clearTimeout; + return function(id){ return cancel(id); }; + })(); + + function resizeListener(e){ + var win = e.target || e.srcElement; + if (win.__resizeRAF__) cancelFrame(win.__resizeRAF__); + win.__resizeRAF__ = requestFrame(function(){ + var trigger = win.__resizeTrigger__; + trigger.__resizeListeners__.forEach(function(fn){ + fn.call(trigger, e); + }); + }); + } + + function objectLoad(e){ + this.contentDocument.defaultView.__resizeTrigger__ = this.__resizeElement__; + this.contentDocument.defaultView.addEventListener('resize', resizeListener); + } + + window.addResizeListener = function(element, fn){ + if (!element.__resizeListeners__) { + element.__resizeListeners__ = []; + if (attachEvent) { + element.__resizeTrigger__ = element; + element.attachEvent('onresize', resizeListener); + } + else { + if (getComputedStyle(element).position == 'static') element.style.position = 'relative'; + var obj = element.__resizeTrigger__ = document.createElement('object'); + obj.setAttribute('style', 'display: block; position: absolute; top: 0; left: 0; height: 100%; width: 100%; overflow: hidden; pointer-events: none; z-index: -1;'); + obj.__resizeElement__ = element; + obj.onload = objectLoad; + obj.type = 'text/html'; + if (isIE) element.appendChild(obj); + obj.data = 'about:blank'; + if (!isIE) element.appendChild(obj); + } } - }, - - psiOption : function(option, cb) { chat.util._remoteCallEval(server.psiOption, [option], cb); }, - colorOption : function(option, cb) { chat.util._remoteCallEval(server.colorOption, [option], cb); }, - getFont : function(cb) { chat.util._remoteCallEval(session.getFont, [], cb); }, - getPaletteColor : function(name, cb) { chat.util._remoteCall(session.getPaletteColor, [name], cb); }, - connectOptionChange: function(option, cb) { - if (typeof optionChangeHandlers[option] == 'undefined') { - optionChangeHandlers[option] = {value: undefined, handlers:[]}; + element.__resizeListeners__.push(fn); + }; + + window.removeResizeListener = function(element, fn){ + element.__resizeListeners__.splice(element.__resizeListeners__.indexOf(fn), 1); + if (!element.__resizeListeners__.length) { + if (attachEvent) element.detachEvent('onresize', resizeListener); + else { + element.__resizeTrigger__.contentDocument.defaultView.removeEventListener('resize', resizeListener); + element.__resizeTrigger__ = !element.removeChild(element.__resizeTrigger__); + } } - optionChangeHandlers[option].handlers.push(cb); - }, - rereadOptions: function() { - onOptionsChanged(Object.getOwnPropertyNames(optionChangeHandlers)); - }, - - // replaces - // with - icon2img : function (obj) { - var img = document.createElement('img'); - img.src = "/psi/icon/" + obj.getAttribute("name"); - img.title = obj.getAttribute("text"); - img.className = "psi-" + (obj.getAttribute("type") || "icon"); - // ignore size attribute. it's up to css style how to size. - obj.parentNode.replaceChild(img, obj); - }, + } + })(); + // end of copied code + addResizeListener(document.body, function(){ + o.invalidate(); + }); + } else { + const ro = new ResizeObserver(function(entries) { + o.invalidate(); + }); - // replaces all occurrence of by function above - replaceIcons : function(el) { - var els = el.querySelectorAll("icon"); // frozen list - for (var i=0; i < els.length; i++) { - chat.util.icon2img(els[i]); - } - }, + // Observe the scrollingElement for when the window gets resized + ro.observe(document.scrollingElement); + // Observe the timeline to process new messages + // ro.observe(timeline); - replaceBob : function(el, sender) { - var els = el.querySelectorAll("img"); // frozen list - for (var i=0; i < els.length; i++) { - if (els[i].src.indexOf('cid:') == 0) { - els[i].src = "psibob/" + els[i].src.slice(4); - if (sender) { - els[i].src += "?sender=" + encodeURIComponent(sender); - } - } - } - }, + } - updateObject : function(object, update) { - for (var i in update) { - object[i] = update[i] - } - }, + //let's consider scroll may happen only by user action + window.addEventListener("scroll", function(){ + if (ignoreNextScroll) { + ignoreNextScroll = false; + return; + } + stopAnimation(); + o.atBottom = document.body.clientHeight == (window.innerHeight+window.pageYOffset); + }, false); + + //EXTERNAL API + // checks current state of scroll and wish and activates necessary actions + o.invalidate = function() { + if (o.atBottom) { + startAnimation(); + } + } - findStyleSheet : function (sheet, selector) { - for (var i=0; i reactionsSelector.show(pdata.parent.id, likeButton, chatElement)); + + that = { + setupForMessageElement: function(el) { + el.addEventListener("mouseleave", function () { + if (pdata.timer) { // if we were going to show it + clearTimeout(pdata.timer); + pdata.timer = null; } - } else { - link = linkEl.href; - } + likeButton.classList.remove('noopacity'); + pdata.parent = null; + }); + el.addEventListener("mouseenter", function () { + pdata.timer = setTimeout(function () { + if (likeButton.parentNode != el) { + el.appendChild(likeButton); + } + setTimeout(()=>{likeButton.classList.add('noopacity');},10); + pdata.timer = null; + pdata.parent = el; + }, 500); + }); + } + } - if (link) { - var iframe = chat.util.createHtmlNode('
'); - linkEl.parentNode.insertBefore(iframe, linkEl.nextSibling); - } - }, + return that; + } - replaceImage : function(linkEl) - { - var img = chat.util.createHtmlNode('
'); - linkEl.parentNode.insertBefore(img, linkEl.nextSibling); - }, + function ReactionsSelector(session) { + var available_reactions = [ + "😂", + "🤣", + "🔥", + "👍", + "😭", + "🙏", + "❤️", + "😘", + "🥰", + "😍", + "😊", + ] + + const rs = document.createElement("div"); + rs.style.display = "none"; + rs.classList.add("reactions_selector"); + available_reactions.forEach(emoji => { + const em = rs.appendChild(document.createElement("em")); + em.textContent = emoji; + }); + document.body.appendChild(rs); - replaceAudio : function(linkEl) - { - var audio = chat.util.createHtmlNode('
'); - linkEl.parentNode.insertBefore(audio, linkEl.nextSibling); - }, + var selector = this; + rs.addEventListener("click", function (event) { + if (event.target.nodeName == "EM") { + session.react(selector.currentMessage, event.target.textContent); + event.target.parentNode.style.display = "none"; + } + event.stopPropagation(); + }); + rs.addEventListener("mouseleave", function (event) { + rs.style.display = "none"; + event.stopPropagation(); + }); - replaceVideo : function(linkEl) - { - var audio = chat.util.createHtmlNode('
'); - linkEl.parentNode.insertBefore(audio, linkEl.nextSibling); - }, + this.show = function(messageId, nearEl, scrollEl) { + this.currentMessage = messageId; + const nbr = nearEl.getBoundingClientRect(); + rs.style.top = (nbr.top + scrollEl.scrollTop) + "px"; + rs.style.display = "flex"; + const selectorRect = rs.getBoundingClientRect(); + const scrollRect = scrollEl.getBoundingClientRect(); + if (nbr.left + selectorRect.width > scrollRect.right) { + rs.style.left = (nbr.right - selectorRect.width) + "px"; + } else { + rs.style.left = nbr.left + "px"; + } + } + this.hide = function() { + rs.style.display = "none"; + } + } - replaceLinkAsync : function(linkEl) - { - chat.util.startSessionTransaction(function(tId) { - session.getUrlHeaders(tId, linkEl.href); - },function(result) { - //chat.console("result ready " + chat.util.props(result, true)); - var ct = result['content-type']; - if ((typeof(ct) == "string") && (ct != "application/octet-stream")) { - ct = ct.split("/")[0].trim(); - switch (ct) { - case "image": - chat.util.replaceImage(linkEl); - break; - case "audio": - chat.util.replaceAudio(linkEl); - break; - case "video": - chat.util.replaceVideo(linkEl); - break; - } - } else { // fallback when no content type - //chat.console("fallback") - var imageExts = ["png", "jpg", "jpeg", "gif", "webp"]; - var audioExts = ["mp3", "ogg", "aac", "flac", "wav", "m4a"]; - var videoExts = ["mp4", "webm", "mkv", "mov", "avi", "ogv"]; - var lpath = linkEl.pathname.toLowerCase().split('#')[0].split('?')[0]; - function checkExt(exts, replacer) { - for (var i = 0; i < exts.length; i++) { - if (lpath.slice(lpath.length - exts[i].length - 1) == ("." + exts[i])) { - replacer(linkEl); - break; - } + function ContextMenu() { + this.items = []; + this.providers = []; + + this.addItem = function(text, action) { + this.items.push({text: text, action: action}); + }; + this.addItemProvider = function(itemProvider) { + this.providers.push(itemProvider); + }; + this.show = function(x, y, items) { + if (window.activeMenu) { + window.activeMenu.destroyMenu(); + } + const menu = document.body.appendChild(document.createElement("div")); + menu.classList.add("context_menu"); + return new Promise((resolve, reject) => { + for (let i = 0; i < items.length; i++) { + const item = menu.appendChild(document.createElement("div")); + item.textContent = items[i].text; + const action = items[i].action; + item.addEventListener("click", (event) => { + event.stopPropagation(); + menu.destroyMenu(); + try { + if (action instanceof Function) { + action(); } + } finally { + resolve(action); } - checkExt(imageExts, chat.util.replaceImage); - checkExt(audioExts, chat.util.replaceAudio); - checkExt(videoExts, chat.util.replaceVideo); - } - }); - }, - - handleLinks : function(el) - { - if (!previewsEnabled) - return; - var links = el.querySelectorAll("a"); - var youtube = ["youtu.be", "www.youtube.com", "youtube.com", "m.youtube.com"]; - for (var li = 0; li < links.length; li++) { - var linkEl = links[li]; - if (youtube.indexOf(linkEl.hostname) != -1) { - chat.util.replaceYoutube(linkEl); - } else if ((linkEl.protocol == "http:" || linkEl.protocol == "https:" || linkEl.protocol == "file:") && linkEl.hostname != "psi") { - chat.util.replaceLinkAsync(linkEl); - } + }, { "once": true }); } - }, - - handleShares : function(el) { - var shares = el.querySelectorAll("share"); - for (var li = 0; li < shares.length; li++) { - var share = shares[li]; - var info = ""; // TODO - var source = share.getAttribute("id"); - var type = share.getAttribute("type"); - if (type.startsWith("audio")) { - var hg = share.getAttribute("amplitudes"); - if (hg && hg.length) - hg.split(",").forEach(v => { info += `` }); - var playerFragment = chat.util.createHtmlNode(`
-
-
-
- ${info} -
-
- - -
`); - var player = playerFragment.firstChild; - if (share.nextSibling) - share.parentNode.insertBefore(playerFragment, share.nextSibling); - else - share.parentNode.appendChild(playerFragment); - new AudioMessage(player); - } - else if (type.startsWith("image")) { - let img = chat.util.createHtmlNode(`
`); - if (share.nextSibling) - share.parentNode.insertBefore(img, share.nextSibling); - else - share.parentNode.appendChild(img); - } + menu.destroyMenu = () => { + document.body.removeEventListener("click", menu.destroyMenu); + menu.parentNode.removeChild(menu); + window.activeMenu = undefined; + reject(); + }; + document.body.addEventListener("click", menu.destroyMenu, { "once": true }); + + menu.style.top = y + "px"; + menu.style.left = x + "px"; + window.activeMenu = menu; + }); + }; + + var menu = this; + document.addEventListener("contextmenu", function (event) { + var all_items = menu.items.slice(); + try { + for (let i = 0; i < menu.providers.length; i++) { + all_items = all_items.concat(menu.providers[i](event)); } - }, + } catch(e) { + chat.console(e+""); + } + if (!all_items.length) { + return true; + } - prepareContents : function(html, sender) { - htmlSource.innerHTML = html; - chat.util.replaceBob(htmlSource, sender); - chat.util.handleLinks(htmlSource); - chat.util.replaceIcons(htmlSource); - chat.util.handleShares(htmlSource); - }, + event.stopPropagation(); + event.preventDefault(); - appendHtml : function(dest, html, sender) { - chat.util.prepareContents(html, sender); - var firstAdded = htmlSource.firstChild; - while (htmlSource.firstChild) dest.appendChild(htmlSource.firstChild); - return firstAdded; - }, + var totalScrollY = 0; + var el = event.target; + while (el) { + if (el.scrollTop !== undefined) { + totalScrollY += el.scrollTop; + } + el = el.parentNode; + } + menu.show(event.x, event.y + totalScrollY, all_items).catch(()=>{}); + }); + } - siblingHtml : function(dest, html, sourceUser) { - chat.util.prepareContents(html, sourceUser); - var firstAdded = htmlSource.firstChild; - while (htmlSource.firstChild) dest.parentNode.insertBefore(htmlSource.firstChild, dest); - return firstAdded; - }, + var util = { + console : server.console, + showCriticalError : function(text) { + var e=document.body || document.documentElement.appendChild(document.createElement("body")); + var er = e.appendChild(document.createElement("div")) + er.style.cssText = "background-color:red;color:white;border:1px solid black;padding:1em;margin:1em;font-weight:bold"; + er.innerHTML = chat.util.escapeHtml(text).replace(/\n/, "
"); + }, - ensureDeleted : function(id) { - if (id) { - var el = document.getElementById(id); - if (el) { - el.parentNode.removeChild(el); - } - } - }, + // just for debug + escapeHtml : function(html) { + html += ""; //hack + return html.split("&").join("&").split( "<").join("<").split(">").join(">"); + }, - loadXML : function(path, callback) { - function cb(text){ - if (!text) { - throw new Error("File " + path + " is empty. can't parse xml"); - } - var xml; - try { - xml = new DOMParser().parseFromString(text, "text/xml"); - } catch (e) { - server.console("failed to parse xml from file " + path); - throw e; - } - callback(xml); + // just for debug + props : function(e, rec) { + var ret=''; + for (var i in e) { + var gotValue = true; + var val = null; + try { + val = e[i]; + } catch(err) { + val = err.toString(); + gotValue = false; } - if (chat.async) { - //server.console("loading xml async: " + path); - loader.getFileContents(path, cb); + if (gotValue) { + if (val instanceof Object && rec && val.constructor != Date) { + ret+=i+" = "+val.constructor.name+"{"+chat.util.props(val, rec)+"}\n"; + } else { + if (val instanceof Function) { + ret+=i+" = Function: "+i+"\n"; + } else { + ret+=i+" = "+(val === null?"null\n":val.constructor.name+"(\""+val+"\")\n"); + } + } } else { - //server.console("loading xml sync: " + path); - cb(loader.getFileContents(path)); + ret+=i+" = [CAN'T GET VALUE: "+val+"]\n"; } - }, - - dateFormat : function(val, format) { - return (new chat.DateTimeFormatter(format)).format(val); - }, + } + return ret; + }, - avatarForNick : function(nick) { - var u = usersMap[nick]; - return u && u.avatar; - }, + startSessionTransaction: function(starter, finisher) { + var tId = "st" + (++nextServerTransaction); + serverTransctions[tId] = finisher; + starter(tId); + }, - nickColor : function(nick) { - var u = usersMap[nick]; - return u && u.nickcolor || "#888"; - }, + _remoteCallEval : function(func, args, cb) { + function ecb(val) { val = eval("[" + val + "][0]"); cb(val); } - replaceableMessage : function(isMuc, isLocal, nick, msgId, text) { - // if we have an id then this is a replacable message. - // next weird logic is as follows: - // - wrapping to some element may break elements flow - // - using well known elements may break styles - // - setting just starting mark is useless (we will never find the correct end) - var uniqId; - if (isMuc) { - var u = usersMap[nick]; - if (!u) { - return text; - } + if (chat.async) { + args.push(ecb) + func.apply(this, args) + } else { + var val = func.apply(this, args); + ecb(val); + } + }, - uniqId = "pmr"+uniqReplId.toString(36); // pmr - psi message replace :-) - //chat.console("Sender:"+nick); - usersMap[nick].msgs[msgId] = uniqId; - } else { - var uId = isLocal?"l":"r"; - uniqId = "pmr"+uId+uniqReplId.toString(36); - if (!usersMap[uId]) { - usersMap[uId]={msgs:{}}; - } - usersMap[uId].msgs[msgId] = uniqId; - } + _remoteCall : function(func, args, cb) { + if (chat.async) { + args.push(cb) + func.apply(this, args) + } else { + var val = func.apply(this, args); + cb(val); + } + }, - uniqReplId++; - // TODO better remember elements themselves instead of some id. - return "" + text + ""; - }, + psiOption : function(option, cb) { chat.util._remoteCallEval(server.psiOption, [option], cb); }, + colorOption : function(option, cb) { chat.util._remoteCallEval(server.colorOption, [option], cb); }, + getFont : function(cb) { chat.util._remoteCallEval(session.getFont, [], cb); }, + getPaletteColor : function(name, cb) { chat.util._remoteCall(session.getPaletteColor, [name], cb); }, + connectOptionChange: function(option, cb) { + if (typeof optionChangeHandlers[option] == 'undefined') { + optionChangeHandlers[option] = {value: undefined, handlers:[]}; + } + optionChangeHandlers[option].handlers.push(cb); + }, + rereadOptions: function() { + onOptionsChanged(Object.getOwnPropertyNames(optionChangeHandlers)); + }, - replaceMessage : function(parentEl, isMuc, isLocal, nick, msgId, newId, text) { - var u - if (isMuc) { - u = usersMap[nick]; - } else { - u = usersMap[isLocal?"l":"r"]; - } - //chat.console(isMuc + " " + isLocal + " " + nick + " " + msgId + " " + chat.util.props(u, true)); + // replaces + // with + icon2img : function (obj) { + var img = document.createElement('img'); + img.src = "/psi/icon/" + obj.getAttribute("name"); + img.title = obj.getAttribute("text"); + img.className = "psi-" + (obj.getAttribute("type") || "icon"); + // ignore size attribute. it's up to css style how to size. + obj.parentNode.replaceChild(img, obj); + }, - var uniqId = u && u.msgs[msgId]; - if (!uniqId) - return false; // replacing something we didn't use replaceableMessage for? hm. + // replaces all occurrence of by function above + replaceIcons : function(el) { + var els = el.querySelectorAll("icon"); // frozen list + for (var i=0; i < els.length; i++) { + chat.util.icon2img(els[i]); + } + }, - var se =parentEl.querySelector("psims[mid='"+uniqId+"']"); - var ee =parentEl.querySelector("psime[mid='"+uniqId+"']"); -// chat.console("Replace: start: " + (se? "found, ":"not found, ") + -// "end: " + (ee? "found, ":"not found, ") + -// "parent match: " + ((se && ee && se.parentNode === ee.parentNode)?"yes":"no")); - if (se && ee && se.parentNode === ee.parentNode) { - delete u.msgs[msgId]; // no longer used. will be replaced with newId. - while (se.nextSibling !== ee) { - se.parentNode.removeChild(se.nextSibling); + replaceBob : function(el, sender) { + var els = el.querySelectorAll("img"); // frozen list + for (var i=0; i < els.length; i++) { + if (els[i].src.indexOf('cid:') == 0) { + els[i].src = "psibob/" + els[i].src.slice(4); + if (sender) { + els[i].src += "?sender=" + encodeURIComponent(sender); } - var node = chat.util.createHtmlNode(chat.util.replaceableMessage(isMuc, isLocal, nick, newId, text + '')); - //chat.console(chat.util.props(node)); - chat.util.handleLinks(node); - chat.util.replaceIcons(node); - ee.parentNode.insertBefore(node, ee); - se.parentNode.removeChild(se); - ee.parentNode.removeChild(ee); - return true; } - return false; - }, - - CSSString2CSSStyleSheet : function(css) { - const style = document.createElement ( 'style' ); - style.innerText = css; - document.head.appendChild ( style ); - const {sheet} = style; - document.head.removeChild ( style ); - return sheet; } }, - WindowScroller : function(animate) { - var o=this, state, timerId - var ignoreNextScroll = false; - o.animate = animate; - o.atBottom = true; //just a state of aspiration + updateObject : function(object, update) { + for (var i in update) { + object[i] = update[i] + } + }, - var animationStep = function() { - timerId = null; - var before = document.body.clientHeight - (window.innerHeight+window.pageYOffset); - var step = before; - if (o.animate) { - step = step>200?200:(step<8?step:Math.floor(step/1.7)); - } - ignoreNextScroll = true; - window.scrollTo(0, document.body.clientHeight - window.innerHeight - before + step); - if (before>0) { - timerId = setTimeout(animationStep, 70); //next step in 250ms even if we are already at bottom (control shot) - } + findStyleSheet : function (sheet, selector) { + for (var i=0; i window.innerHeight) { //if we have what to scroll - timerId = setTimeout(animationStep, 0); + createHtmlNode : function(html, context) { + var range = document.createRange(); + range.selectNode(context || document.body); + return range.createContextualFragment(html); + }, + + replaceYoutube : function(linkEl) { + var baseLink = "https://www.youtube.com/embed/"; + var link; + + if (linkEl.hostname == "youtu.be") { + link = baseLink + linkEl.pathname.slice(1); + } else if (linkEl.pathname.indexOf("/embed/") != 0) { + var m = linkEl.href.match(/^.*[?&]v=([a-zA-Z0-9_-]+).*$/); + var code = m && m[1]; + if (code) { + link = baseLink + code; } + } else { + link = linkEl.href; } - var stopAnimation = function() { - if (timerId) { - clearTimeout(timerId); - timerId = null; - } + if (link) { + var iframe = chat.util.createHtmlNode('
'); + linkEl.parentNode.insertBefore(iframe, linkEl.nextSibling); } + }, - // ensure we at bottom on window resize - if (typeof ResizeObserver === 'undefined') { - - // next code is copied from www.backalleycoder.com/2013/03/18/cross-browser-event-based-element-resize-detection/ on 7 Dec 2018 - (function(){ - var attachEvent = document.attachEvent; - var isIE = navigator.userAgent.match(/Trident/); - //console.log(isIE); - var requestFrame = (function(){ - var raf = window.requestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || - function(fn){ return window.setTimeout(fn, 20); }; - return function(fn){ return raf(fn); }; - })(); - - var cancelFrame = (function(){ - var cancel = window.cancelAnimationFrame || window.mozCancelAnimationFrame || window.webkitCancelAnimationFrame || - window.clearTimeout; - return function(id){ return cancel(id); }; - })(); - - function resizeListener(e){ - var win = e.target || e.srcElement; - if (win.__resizeRAF__) cancelFrame(win.__resizeRAF__); - win.__resizeRAF__ = requestFrame(function(){ - var trigger = win.__resizeTrigger__; - trigger.__resizeListeners__.forEach(function(fn){ - fn.call(trigger, e); - }); - }); - } + replaceImage : function(linkEl) + { + var img = chat.util.createHtmlNode('
'); + linkEl.parentNode.insertBefore(img, linkEl.nextSibling); + }, - function objectLoad(e){ - this.contentDocument.defaultView.__resizeTrigger__ = this.__resizeElement__; - this.contentDocument.defaultView.addEventListener('resize', resizeListener); - } + replaceAudio : function(linkEl) + { + var audio = chat.util.createHtmlNode('
'); + linkEl.parentNode.insertBefore(audio, linkEl.nextSibling); + }, - window.addResizeListener = function(element, fn){ - if (!element.__resizeListeners__) { - element.__resizeListeners__ = []; - if (attachEvent) { - element.__resizeTrigger__ = element; - element.attachEvent('onresize', resizeListener); - } - else { - if (getComputedStyle(element).position == 'static') element.style.position = 'relative'; - var obj = element.__resizeTrigger__ = document.createElement('object'); - obj.setAttribute('style', 'display: block; position: absolute; top: 0; left: 0; height: 100%; width: 100%; overflow: hidden; pointer-events: none; z-index: -1;'); - obj.__resizeElement__ = element; - obj.onload = objectLoad; - obj.type = 'text/html'; - if (isIE) element.appendChild(obj); - obj.data = 'about:blank'; - if (!isIE) element.appendChild(obj); - } + replaceVideo : function(linkEl) + { + var audio = chat.util.createHtmlNode('
'); + linkEl.parentNode.insertBefore(audio, linkEl.nextSibling); + }, + + replaceLinkAsync : function(linkEl) + { + chat.util.startSessionTransaction(function(tId) { + session.getUrlHeaders(tId, linkEl.href); + },function(result) { + //chat.console("result ready " + chat.util.props(result, true)); + var ct = result['content-type']; + if ((typeof(ct) == "string") && (ct != "application/octet-stream")) { + ct = ct.split("/")[0].trim(); + switch (ct) { + case "image": + chat.util.replaceImage(linkEl); + break; + case "audio": + chat.util.replaceAudio(linkEl); + break; + case "video": + chat.util.replaceVideo(linkEl); + break; } - element.__resizeListeners__.push(fn); - }; - - window.removeResizeListener = function(element, fn){ - element.__resizeListeners__.splice(element.__resizeListeners__.indexOf(fn), 1); - if (!element.__resizeListeners__.length) { - if (attachEvent) element.detachEvent('onresize', resizeListener); - else { - element.__resizeTrigger__.contentDocument.defaultView.removeEventListener('resize', resizeListener); - element.__resizeTrigger__ = !element.removeChild(element.__resizeTrigger__); - } + } else { // fallback when no content type + //chat.console("fallback") + var imageExts = ["png", "jpg", "jpeg", "gif", "webp"]; + var audioExts = ["mp3", "ogg", "aac", "flac", "wav", "m4a"]; + var videoExts = ["mp4", "webm", "mkv", "mov", "avi", "ogv"]; + var lpath = linkEl.pathname.toLowerCase().split('#')[0].split('?')[0]; + function checkExt(exts, replacer) { + for (var i = 0; i < exts.length; i++) { + if (lpath.slice(lpath.length - exts[i].length - 1) == ("." + exts[i])) { + replacer(linkEl); + break; + } + } } - } - })(); - // end of copied code - addResizeListener(document.body, function(){ - o.invalidate(); - }); - } else { - const ro = new ResizeObserver(function(entries) { - o.invalidate(); - }); - - // Observe the scrollingElement for when the window gets resized - ro.observe(document.scrollingElement); - // Observe the timeline to process new messages - // ro.observe(timeline); + checkExt(imageExts, chat.util.replaceImage); + checkExt(audioExts, chat.util.replaceAudio); + checkExt(videoExts, chat.util.replaceVideo); + } + }); + }, + handleLinks : function(el) + { + if (!previewsEnabled) + return; + var links = el.querySelectorAll("a"); + var youtube = ["youtu.be", "www.youtube.com", "youtube.com", "m.youtube.com"]; + for (var li = 0; li < links.length; li++) { + var linkEl = links[li]; + if (youtube.indexOf(linkEl.hostname) != -1) { + chat.util.replaceYoutube(linkEl); + } else if ((linkEl.protocol == "http:" || linkEl.protocol == "https:" || linkEl.protocol == "file:") && linkEl.hostname != "psi") { + chat.util.replaceLinkAsync(linkEl); + } } + }, - //let's consider scroll may happen only by user action - window.addEventListener("scroll", function(){ - if (ignoreNextScroll) { - ignoreNextScroll = false; - return; + handleShares : function(el) { + var shares = el.querySelectorAll("share"); + for (var li = 0; li < shares.length; li++) { + var share = shares[li]; + var info = ""; // TODO + var source = share.getAttribute("id"); + var type = share.getAttribute("type"); + if (type.startsWith("audio")) { + var hg = share.getAttribute("amplitudes"); + if (hg && hg.length) + hg.split(",").forEach(v => { info += `` }); + var playerFragment = chat.util.createHtmlNode(`
+
+
+
+${info} +
+
+ + +
`); + var player = playerFragment.firstChild; + if (share.nextSibling) + share.parentNode.insertBefore(playerFragment, share.nextSibling); + else + share.parentNode.appendChild(playerFragment); + new AudioMessage(player); } - stopAnimation(); - o.atBottom = document.body.clientHeight == (window.innerHeight+window.pageYOffset); - }, false); - - //EXTERNAL API - // checks current state of scroll and wish and activates necessary actions - o.invalidate = function() { - if (o.atBottom) { - startAnimation(); + else if (type.startsWith("image")) { + let img = chat.util.createHtmlNode(`
`); + if (share.nextSibling) + share.parentNode.insertBefore(img, share.nextSibling); + else + share.parentNode.appendChild(img); } } + }, - o.force = function() { - o.atBottom = true; - o.invalidate(); - } - - o.cancel = stopAnimation; // stops any current in-progress autoscroll + prepareContents : function(html, sender) { + htmlSource.innerHTML = html; + chat.util.replaceBob(htmlSource, sender); + chat.util.handleLinks(htmlSource); + chat.util.replaceIcons(htmlSource); + chat.util.handleShares(htmlSource); }, - ReactionsSelector : function(session) { - var available_reactions = [ - "😂", - "🤣", - "🔥", - "👍", - "😭", - "🙏", - "❤️", - "😘", - "🥰", - "😍", - "😊", - ] - - const rs = document.createElement("div"); - rs.style.display = "none"; - rs.classList.add("reactions_selector"); - available_reactions.forEach(emoji => { - const em = rs.appendChild(document.createElement("em")); - em.textContent = emoji; - }); - document.body.appendChild(rs); + appendHtml : function(dest, html, sender) { + chat.util.prepareContents(html, sender); + var firstAdded = htmlSource.firstChild; + while (htmlSource.firstChild) dest.appendChild(htmlSource.firstChild); + return firstAdded; + }, - var selector = this; - rs.addEventListener("click", function (event) { - if (event.target.nodeName == "EM") { - session.react(selector.currentMessage, event.target.textContent); - event.target.parentNode.style.display = "none"; - } - event.stopPropagation(); - }); - rs.addEventListener("mouseleave", function (event) { - rs.style.display = "none"; - event.stopPropagation(); - }); + siblingHtml : function(dest, html, sourceUser) { + chat.util.prepareContents(html, sourceUser); + var firstAdded = htmlSource.firstChild; + while (htmlSource.firstChild) dest.parentNode.insertBefore(htmlSource.firstChild, dest); + return firstAdded; + }, - this.show = function(messageId, nearEl, scrollEl) { - this.currentMessage = messageId; - const nbr = nearEl.getBoundingClientRect(); - rs.style.top = (nbr.top + scrollEl.scrollTop) + "px"; - rs.style.display = "flex"; - const selectorRect = rs.getBoundingClientRect(); - const scrollRect = scrollEl.getBoundingClientRect(); - if (nbr.left + selectorRect.width > scrollRect.right) { - rs.style.left = (nbr.right - selectorRect.width) + "px"; - } else { - rs.style.left = nbr.left + "px"; + ensureDeleted : function(id) { + if (id) { + var el = document.getElementById(id); + if (el) { + el.parentNode.removeChild(el); } } - this.hide = function() { - rs.style.display = "none"; - } }, - ContextMenu : function() { - this.items = []; - this.providers = []; - - this.addItem = function(text, action) { - this.items.push({text: text, action: action}); - }; - this.addItemProvider = function(itemProvider) { - this.providers.push(itemProvider); - }; - this.show = function(x, y, items) { - if (window.activeMenu) { - window.activeMenu.destroyMenu(); + loadXML : function(path, callback) { + function cb(text){ + if (!text) { + throw new Error("File " + path + " is empty. can't parse xml"); } - const menu = document.body.appendChild(document.createElement("div")); - menu.classList.add("context_menu"); - return new Promise((resolve, reject) => { - for (let i = 0; i < items.length; i++) { - const item = menu.appendChild(document.createElement("div")); - item.textContent = items[i].text; - const action = items[i].action; - item.addEventListener("click", (event) => { - event.stopPropagation(); - menu.destroyMenu(); - try { - if (action instanceof Function) { - action(); - } - } finally { - resolve(action); - } - }, { "once": true }); - } - menu.destroyMenu = () => { - document.body.removeEventListener("click", menu.destroyMenu); - menu.parentNode.removeChild(menu); - window.activeMenu = undefined; - reject(); - }; - document.body.addEventListener("click", menu.destroyMenu, { "once": true }); - - menu.style.top = y + "px"; - menu.style.left = x + "px"; - window.activeMenu = menu; - }); - }; - - var menu = this; - document.addEventListener("contextmenu", function (event) { - var all_items = menu.items.slice(); + var xml; try { - for (let i = 0; i < menu.providers.length; i++) { - all_items = all_items.concat(menu.providers[i](event)); - } - } catch(e) { - chat.console(e+""); - } - if (!all_items.length) { - return true; + xml = new DOMParser().parseFromString(text, "text/xml"); + } catch (e) { + server.console("failed to parse xml from file " + path); + throw e; } + callback(xml); + } + if (chat.async) { + //server.console("loading xml async: " + path); + loader.getFileContents(path, cb); + } else { + //server.console("loading xml sync: " + path); + cb(loader.getFileContents(path)); + } + }, - event.stopPropagation(); - event.preventDefault(); + dateFormat : function(val, format) { + return (new chat.DateTimeFormatter(format)).format(val); + }, - var totalScrollY = 0; - var el = event.target; - while (el) { - if (el.scrollTop !== undefined) { - totalScrollY += el.scrollTop; - } - el = el.parentNode; - } - menu.show(event.x, event.y + totalScrollY, all_items).catch(()=>{}); - }); + avatarForNick : function(nick) { + var u = usersMap[nick]; + return u && u.avatar; }, - DateTimeFormatter : function(formatStr) { - function convertToTr35(format) - { - var ret="" - var i = 0; - var m = {M: "mm", H: "HH", S: "ss", c: "EEEE', 'MMMM' 'd', 'yyyy' 'G", - A: "EEEE", I: "hh", p: "a", Y: "yyyy"}; // if you want me, report it. + nickColor : function(nick) { + var u = usersMap[nick]; + return u && u.nickcolor || "#888"; + }, - var txtAcc = ""; - while (i < format.length) { - var c; - if (format[i] === "'" || - (format[i] === "%" && i < (format.length - 1) && (c = m[format[i+1]]))) - { - if (txtAcc) { - ret += "'" + txtAcc + "'"; - txtAcc = ""; - } - if (format[i] === "'") { - ret += "''"; - } else { - ret += c; - i++; - } - } else { - txtAcc += format[i]; - } - i++; - } - if (txtAcc) { - ret += "'" + txtAcc + "'"; - txtAcc = ""; - } - return ret; - } - - function convertToMoment(format) { - var inTxt = false; - var i; - var m = {j:"h"}; // sadly "j" is not supported - var ret = ""; - for (i = 0; i < format.length; i++) { - if (format[i] == "'") { - ret += (inTxt? ']' : '['); - inTxt = !inTxt; - } else { - var c; - if (!inTxt && (c = m[format[i]])) { - ret += c; - } else { - ret += format[i]; - } - } - } - if (inTxt) { - ret += "]"; + replaceableMessage : function(isMuc, isLocal, nick, msgId, text) { + // if we have an id then this is a replacable message. + // next weird logic is as follows: + // - wrapping to some element may break elements flow + // - using well known elements may break styles + // - setting just starting mark is useless (we will never find the correct end) + var uniqId; + if (isMuc) { + var u = usersMap[nick]; + if (!u) { + return text; } - ret = ret.replace("EEEE", "dddd"); - ret = ret.replace("EEE", "ddd"); - - return ret; + uniqId = "pmr"+uniqReplId.toString(36); // pmr - psi message replace :-) + //chat.console("Sender:"+nick); + usersMap[nick].msgs[msgId] = uniqId; + } else { + var uId = isLocal?"l":"r"; + uniqId = "pmr"+uId+uniqReplId.toString(36); + if (!usersMap[uId]) { + usersMap[uId]={msgs:{}}; + } + usersMap[uId].msgs[msgId] = uniqId; } - formatStr = formatStr || "j:mm"; - if (formatStr.indexOf('%') !== -1) { - formatStr = convertToTr35(formatStr); + uniqReplId++; + // TODO better remember elements themselves instead of some id. + return "" + text + ""; + }, + + replaceMessage : function(parentEl, isMuc, isLocal, nick, msgId, newId, text) { + var u + if (isMuc) { + u = usersMap[nick]; + } else { + u = usersMap[isLocal?"l":"r"]; } + //chat.console(isMuc + " " + isLocal + " " + nick + " " + msgId + " " + chat.util.props(u, true)); - formatStr = convertToMoment(formatStr); + var uniqId = u && u.msgs[msgId]; + if (!uniqId) + return false; // replacing something we didn't use replaceableMessage for? hm. - this.format = function(val) { - if (val instanceof String) { - val = Date.parse(val); + var se =parentEl.querySelector("psims[mid='"+uniqId+"']"); + var ee =parentEl.querySelector("psime[mid='"+uniqId+"']"); +// chat.console("Replace: start: " + (se? "found, ":"not found, ") + +// "end: " + (ee? "found, ":"not found, ") + +// "parent match: " + ((se && ee && se.parentNode === ee.parentNode)?"yes":"no")); + if (se && ee && se.parentNode === ee.parentNode) { + delete u.msgs[msgId]; // no longer used. will be replaced with newId. + while (se.nextSibling !== ee) { + se.parentNode.removeChild(se.nextSibling); } - return moment(val).format(formatStr); // FIXME we could speedup it by keeping fomatter instances + var node = chat.util.createHtmlNode(chat.util.replaceableMessage(isMuc, isLocal, nick, newId, text + '')); + //chat.console(chat.util.props(node)); + chat.util.handleLinks(node); + chat.util.replaceIcons(node); + ee.parentNode.insertBefore(node, ee); + se.parentNode.removeChild(se); + ee.parentNode.removeChild(ee); + return true; } + return false; }, + CSSString2CSSStyleSheet : function(css) { + const style = document.createElement ( 'style' ); + style.innerText = css; + document.head.appendChild ( style ); + const {sheet} = style; + document.head.removeChild ( style ); + return sheet; + } + } + + var chat = { + async : async, + console : server.console, + server : server, + session : session, + hooks: [], + + util: util, + WindowScroller: WindowScroller, + LikeButton: LikeButton, + ReactionsSelector: ReactionsSelector, + ContextMenu: ContextMenu, + DateTimeFormatter : DateTimeFormatter, AudioMessage : AudioMessage, receiveObject : function(data) {