Add workaround for LW API change: userId can sometimes be null.
[lw2-viewer.git] / www / head.js
blobf17af116a5e661417c1adf6a22db29a3026f7da3
2 // FOR TESTING ONLY, COMMENT WHEN DEPLOYING.
3 // GW.loggingEnabled = true;
5 // Links to comments generated by LW have a hash that consists of just the 
6 // comment ID, which can start with a number. Prefix it with "comment-".
7 if (location.hash.length == 18) {
8         location.hash = "#comment-" + location.hash.substring(1);
11 /****************************************************/
12 /* CSS CLASS MANIPULATION (polyfill for .classList) */
13 /****************************************************/
15 Element.prototype.addClass = function(className) {
16         if (!this.hasClass(className))
17                 this.className = (this.className + " " + className).trim();
19 Element.prototype.addClasses = function(classNames) {
20         let elementClassNames = this.className.trim().split(/\s/);
21         
22         classNames.forEach(className => {
23                 if (!this.hasClass(className))
24                         elementClassNames.push(className);
25         });
26         
27         this.className = elementClassNames.join(" ");
29 Element.prototype.removeClass = function(className) {
30         this.className = this.className.replace(new RegExp("(^|\\s+)" + className + "(\\s+|$)"), "$1").trim();
31         if (this.className == "") this.removeAttribute("class");
33 Element.prototype.removeClasses = function(classNames) {
34         classNames.forEach(className => {
35                 this.className = this.className.replace(new RegExp("(^|\\s+)" + className + "(\\s+|$)"), "$1").trim();
36         });
37         if (this.className == "") this.removeAttribute("class");
39 Element.prototype.hasClass = function(className) {
40         return (new RegExp("(^|\\s+)" + className + "(\\s+|$)")).test(this.className);
42 /*      True if the element has _all_ of the classes in the argument (which may be
43         a space-separated string or an array); false otherwise.
44  */
45 Element.prototype.hasClasses = function (classes) {
46         if (typeof classes == "string")
47                 classes = classes.split(" ");
49         for (let aClass of classes)
50                 if (false == this.hasClass(aClass))
51                         return false;
53         return true;
55 Element.prototype.toggleClass = function(className) {
56         if (this.hasClass(className))
57                 this.removeClass(className);
58         else
59                 this.addClass(className);
62 /*  Swap classes on the given element.
64     First argument is an array with two string elements (the classes).
65     Second argument is 0 or 1 (index of class to add; the other is removed).
67     Note that the first class in the array is always removed/added first, and 
68     then the second class in the array is added/removed; thus these two calls 
69     have different effects:
70     
71     anElement.swapClasses([ "foo", "bar" ], 1);
72     anElement.swapClasses([ "bar", "foo" ], 0);
74         The first call removes "foo" and then adds "bar"; the second call adds "bar"
75         and then removes "foo". (This can have different visual or other side 
76         effects in many circumstances. It also results in a different end state in 
77         the cases where the two classes are the same.)
78  */
79 Element.prototype.swapClasses = function (classes, whichToAdd) {
80         let op1 = whichToAdd ? "removeClass" : "addClass";
81         let op2 = whichToAdd ? "addClass" : "removeClass";
82         
83         this[op1](classes[0]);
84         this[op2](classes[1]);
87 /* DOM helpers */
89 function insertHeadHTML(html) {
90         document.head.insertAdjacentHTML("beforeend", html);
93 /********************/
94 /* QUERYING THE DOM */
95 /********************/
97 function queryAll(selector, context) {
98     context = context || document;
99     // Redirect simple selectors to the more performant function
100     if (/^(#?[\w-]+|\.[\w-.]+)$/.test(selector)) {
101         switch (selector.charAt(0)) {
102             case '#':
103                 // Handle ID-based selectors
104                 let element = document.getElementById(selector.substr(1));
105                 return element ? [ element ] : [ ];
106             case '.':
107                 // Handle class-based selectors
108                 // Query by multiple classes by converting the selector 
109                 // string into single spaced class names
110                 var classes = selector.substr(1).replace(/\./g, ' ');
111                 return [].slice.call(context.getElementsByClassName(classes));
112             default:
113                 // Handle tag-based selectors
114                 return [].slice.call(context.getElementsByTagName(selector));
115         }
116     }
117     // Default to `querySelectorAll`
118     return [].slice.call(context.querySelectorAll(selector));
120 function query(selector, context) {
121         let all = queryAll(selector, context);
122         return (all.length > 0) ? all[0] : null;
124 Object.prototype.queryAll = function (selector) {
125         return queryAll(selector, this);
127 Object.prototype.query = function (selector) {
128         return query(selector, this);
131 /***********************************/
132 /* CONTENT COLUMN WIDTH ADJUSTMENT */
133 /***********************************/
135 GW.widthOptions = [
136         ['normal', 'Narrow (fixed-width) content column', 'N'],
137         ['wide', 'Wide (fixed-width) content column', 'W'],
138         ['fluid', 'Full-width (fluid) content column', 'F']
141 function setContentWidth(widthOption) {
142         let currentWidth = localStorage.getItem("selected-width") || 'normal';
143         let head = query('head');
144         head.removeClasses(GW.widthOptions.map(wo => 'content-width-' + wo[0]));
145         head.addClass('content-width-' + (widthOption || 'normal'));
147 setContentWidth(localStorage.getItem('selected-width'));
149 /********************************************/
150 /* APPEARANCE CUSTOMIZATION (THEME TWEAKER) */
151 /********************************************/
153 Object.prototype.isEmpty = function () {
154     for (var prop in this) if (this.hasOwnProperty(prop)) return false;
155     return true;
157 Object.prototype.keys = function () {
158         return Object.keys(this);
160 Array.prototype.contains = function (element) {
161         return (this.indexOf(element) !== -1);
163 Array.prototype.clone = function() {
164         return JSON.parse(JSON.stringify(this));
167 GW.themeTweaker = { };
168 GW.themeTweaker.filtersExclusionPaths = { };
169 GW.themeTweaker.defaultFiltersExclusionTree = [ "#content", [ ] ];
171 function exclusionTreeFromExclusionPaths(paths) {
172         if (!paths) return null;
174         let tree = GW.themeTweaker.defaultFiltersExclusionTree.clone();
175         paths.keys().flatMap(key => paths[key]).forEach(path => {
176                 var currentNodeInTree = tree;
177                 path.split(" ").slice(1).forEach(step => {
178                         if (currentNodeInTree[1] == null)
179                                 currentNodeInTree[1] = [ ];
181                         var indexOfMatchingChild = currentNodeInTree[1].findIndex(child => { return child[0] == step; });
182                         if (indexOfMatchingChild == -1) {
183                                 currentNodeInTree[1].push([ step, [ ] ]);
184                                 indexOfMatchingChild = currentNodeInTree[1].length - 1;
185                         }
187                         currentNodeInTree = currentNodeInTree[1][indexOfMatchingChild];
188                 });
189         });
191         return tree;
193 function selectorFromExclusionTree(tree) {
194         var selectorParts = [
195                 "body::before, #ui-elements-container > div:not(#theme-tweaker-ui), #theme-tweaker-ui #theme-tweak-section-sample-text .sample-text-container"
196         ];
197         
198         function selectorFromExclusionTreeNode(node, path = [ ]) {
199                 let [ value, children ] = node;
201                 let newPath = path.clone();
202                 newPath.push(value);
204                 if (!children) {
205                         return value;
206                 } else if (children.length == 0) {
207                         return `${newPath.join(" > ")} > *, ${newPath.join(" > ")}::before, ${newPath.join(" > ")}::after`;
208                 } else {
209                         return `${newPath.join(" > ")} > *:not(${children.map(child => child[0]).join("):not(")}), ${newPath.join(" > ")}::before, ${newPath.join(" > ")}::after, ` + children.map(child => selectorFromExclusionTreeNode(child, newPath)).join(", ");
210                 }
211         }
212         
213         return selectorParts + ", " + selectorFromExclusionTreeNode(tree);
215 function filterStringFromFilters(filters) {
216         var filterString = "";
217         for (key of Object.keys(filters)) {
218                 let value = filters[key];
219                 filterString += ` ${key}(${value})`;
220         }
221         return filterString;
223 function applyFilters(filters) {
224         var fullStyleString = "";
225         
226         if (!filters.isEmpty()) {
227                 let filtersExclusionTree = exclusionTreeFromExclusionPaths(GW.themeTweaker.filtersExclusionPaths) || GW.themeTweaker.defaultFiltersExclusionTree;
228                 fullStyleString = `body::before { content: ""; } body > #content::before { z-index: 0; } ${selectorFromExclusionTree(filtersExclusionTree)} { filter: ${filterStringFromFilters(filters)}; }`;
229         }
230         
231         // Update the style tag (if it’s already been loaded).
232         (query("#theme-tweak")||{}).innerHTML = fullStyleString;
234 insertHeadHTML("<style id='theme-tweak'></style>");
235 GW.currentFilters = JSON.parse(localStorage.getItem("theme-tweaks") || "{ }");
236 applyFilters(GW.currentFilters);
238 /************************/
239 /* TEXT SIZE ADJUSTMENT */
240 /************************/
242 insertHeadHTML("<style id='text-zoom'></style>");
243 function setTextZoom(zoomFactor) {
244         if (!zoomFactor) return;
246         let minZoomFactor = 0.5;
247         let maxZoomFactor = 1.5;
248         
249         if (zoomFactor <= minZoomFactor) {
250                 zoomFactor = minZoomFactor;
251                 queryAll(".text-size-adjust-button.decrease").forEach(function (button) {
252                         button.disabled = true;
253                 });
254         } else if (zoomFactor >= maxZoomFactor) {
255                 zoomFactor = maxZoomFactor;
256                 queryAll(".text-size-adjust-button.increase").forEach(function (button) {
257                         button.disabled = true;
258                 });
259         } else {
260                 queryAll(".text-size-adjust-button").forEach(function (button) {
261                         button.disabled = false;
262                 });
263         }
265         let textZoomStyle = query("#text-zoom");
266         textZoomStyle.innerHTML = 
267                 `.post, .comment, .comment-controls {
268                         zoom: ${zoomFactor};
269                 }`;
271         if (window.generateImagesOverlay) setTimeout(generateImagesOverlay);
273 GW.currentTextZoom = localStorage.getItem('text-zoom');
274 setTextZoom(GW.currentTextZoom);
276 /**********/
277 /* THEMES */
278 /**********/
280 GW.themeOptions = [
281         ['default', 'Default theme (dark text on light background)', 'A'],
282         ['dark', 'Dark theme (light text on dark background)', 'B'],
283         ['grey', 'Grey theme (more subdued than default theme)', 'C'],
284         ['ultramodern', 'Ultramodern theme (very hip)', 'D'],
285         ['zero', 'Theme zero (plain and simple)', 'E'],
286         ['brutalist', 'Brutalist theme (the Motherland calls!)', 'F'],
287         ['rts', 'ReadTheSequences.com theme', 'G'],
288         ['classic', 'Classic Less Wrong theme', 'H'],
289         ['less', 'Less theme (serenity now)', 'I']
292 /*****************/
293 /* ANTI-KIBITZER */
294 /*****************/
296 // While everything's being loaded, hide the authors and karma values.
297 if (localStorage.getItem("antikibitzer") == "true") {
298         insertHeadHTML("<style id='antikibitzer-temp'>" +
299         `.author, .inline-author, .karma-value, .individual-thread-page > h1 { visibility: hidden; }` + 
300         "</style>");
302         if(document.location.pathname.match(new RegExp("/posts/.*/comment/"))) {
303                 insertHeadHTML("<"+"title class='fake-title'></title>");
304         }
307 /****************/
308 /* DEBUG OUTPUT */
309 /****************/
311 function GWLog (string) {
312         if (GW.loggingEnabled || localStorage.getItem("logging-enabled") == "true")
313                 console.log(string);
316 /****************/
317 /* MISC HELPERS */
318 /****************/
320 /*      Return the value of a GET (i.e., URL) parameter.
321         */
322 function getQueryVariable(variable) {
323         var query = window.location.search.substring(1);
324         var vars = query.split("&");
325         for (var i = 0; i < vars.length; i++) {
326                 var pair = vars[i].split("=");
327                 if (pair[0] == variable)
328                         return pair[1];
329         }
331         return false;
334 /*      Get the comment ID of the item (if it's a comment) or of its containing 
335         comment (if it's a child of a comment).
336         */
337 Element.prototype.getCommentId = function() {
338         let item = (this.className == "comment-item" ? this : this.closest(".comment-item"));
339         if (item) {
340                 return (/^comment-(.*)/.exec(item.id)||[])[1];
341         } else {
342                 return false;
343         }
346 /*******/
347 /* TOC */
348 /*******/
350 function setTOCCollapseState(collapsed = false) {
351         let TOC = query("nav.contents");
352         if (!TOC)
353                 return;
355         TOC.classList.toggle("collapsed", collapsed);
357         let button = TOC.query(".toc-collapse-toggle-button");
358         button.innerHTML = collapsed ? "&#xf0fe;" : "&#xf146;";
359         button.title = collapsed ? "Expand table of contents" : "Collapse table of contents";
362 function injectTOCCollapseToggleButton() {
363         let TOC = document.currentScript.parentElement;
364         if (!TOC)
365                 return;
367         TOC.insertAdjacentHTML("afterbegin", "<button type='button' class='toc-collapse-toggle-button'></button>");
369         let defaultTOCCollapseState = (window.innerWidth <= 520) ? "true" : "false";
370         setTOCCollapseState((localStorage.getItem("toc-collapsed") ?? defaultTOCCollapseState) == "true");
372         TOC.query(".toc-collapse-toggle-button").addActivateEvent(GW.tocCollapseToggleButtonClicked = (event) => {
373                 setTOCCollapseState(TOC.classList.contains("collapsed") == false);
374                 localStorage.setItem("toc-collapsed", TOC.classList.contains("collapsed"));
375         });
378 /***********************************/
379 /* COMMENT THREAD MINIMIZE BUTTONS */
380 /***********************************/
382 Element.prototype.setCommentThreadMaximized = function(toggle, userOriginated = true, force) {
383         GWLog("setCommentThreadMaximized");
384         let commentItem = this;
385         let storageName = "thread-minimized-" + commentItem.getCommentId();
386         let minimize_button = commentItem.query(".comment-minimize-button");
387         let maximize = force || (toggle ? /minimized/.test(minimize_button.className) : !(localStorage.getItem(storageName) || commentItem.hasClass("ignored")));
388         if (userOriginated) {
389                 if (maximize) {
390                         localStorage.removeItem(storageName);
391                 } else {
392                         localStorage.setItem(storageName, true);
393                 }
394         }
396         commentItem.style.height = maximize ? 'auto' : '38px';
397         commentItem.style.overflow = maximize ? 'visible' : 'hidden';
399         minimize_button.className = "comment-minimize-button " + (maximize ? "maximized" : "minimized");
400         minimize_button.innerHTML = maximize ? "&#xf146;" : "&#xf0fe;";
401         minimize_button.title = `${(maximize ? "Collapse" : "Expand")} comment`;
402         if (getQueryVariable("chrono") != "t") {
403                 minimize_button.title += ` thread (${minimize_button.dataset["childCount"]} child comments)`;
404         }
407 /*****************************/
408 /* MINIMIZED THREAD HANDLING */
409 /*****************************/
411 function expandAncestorsOf(comment) {
412         GWLog("expandAncestorsOf");
413         if (typeof comment == "string") {
414                 comment = /(?:comment-)?(.+)/.exec(comment)[1];
415                 comment = query("#comment-" + comment);
416         }
417         if (!comment) {
418                 GWLog("Comment with ID " + comment.id + " does not exist, so we can’t expand its ancestors.");
419                 return;
420         }
422         // Expand collapsed comment threads.
423         let parentOfContainingCollapseCheckbox = (comment.closest("label[for^='expand'] + .comment-thread")||{}).parentElement;
424         if (parentOfContainingCollapseCheckbox) parentOfContainingCollapseCheckbox.query("input[id^='expand']").checked = true;
426         // Expand collapsed comments.
427         let containingTopLevelCommentItem = comment.closest(".comments > ul > li");
428         if (containingTopLevelCommentItem) containingTopLevelCommentItem.setCommentThreadMaximized(true, false, true);
431 /********************/
432 /* COMMENT CONTROLS */
433 /********************/
435 /*      Adds an event listener to a button (or other clickable element), attaching 
436         it to both "click" and "keyup" events (for use with keyboard navigation).
437         Optionally also attaches the listener to the 'mousedown' event, making the 
438         element activate on mouse down instead of mouse up. */
439 Element.prototype.addActivateEvent = function(func, includeMouseDown) {
440         let ael = this.activateEventListener = (event) => { if (event.button === 0 || event.key === ' ') func(event) };
441         if (includeMouseDown) this.addEventListener("mousedown", ael);
442         this.addEventListener("click", ael);
443         this.addEventListener("keyup", ael);
446 Element.prototype.updateCommentControlButton = function() {
447         GWLog("updateCommentControlButton");
448         let retractFn = () => {
449                 if(this.closest(".comment-item").firstChild.hasClass("retracted"))
450                         return [ "unretract-button", "Un-retract", "Un-retract this comment" ];
451                 else
452                         return [ "retract-button", "Retract", "Retract this comment (without deleting)" ];
453         };
454         let classMap = {
455                 "delete-button": () => { return [ "delete-button", "Delete", "Delete this comment" ] },
456                 "retract-button": retractFn,
457                 "unretract-button": retractFn,
458                 "edit-button": () => { return [ "edit-button", "Edit", "Edit this comment" ] }
459         };
460         classMap.keys().forEach((testClass) => {
461                 if (this.hasClass(testClass)) {
462                         let [ buttonClass, buttonLabel, buttonAltText ] = classMap[testClass]();
463                         this.className = "";
464                         this.addClasses([ buttonClass, "action-button" ]);
465                         if (this.innerHTML || !this.dataset.label) this.innerHTML = buttonLabel;
466                         this.dataset.label = buttonLabel;
467                         this.title = buttonAltText;
468                         this.tabIndex = '-1';
469                         return;
470                 }
471         });
474 Element.prototype.constructCommentControls = function() {
475         GWLog("constructCommentControls");
476         let commentControls = this;
478         if(commentControls.parentElement.hasClass("comments") && !commentControls.parentElement.hasClass("replies-open")) {
479                 return;
480         }
481         
482         let commentType = commentControls.parentElement.id.replace(/s$/, "");
483         commentControls.innerHTML = "";
484         let replyButton = document.createElement("button");
485         if (commentControls.parentElement.hasClass("comments")) {
486                 replyButton.className = "new-comment-button action-button";
487                 replyButton.innerHTML = (commentType == "nomination" ? "Add nomination" : "Post new " + commentType);
488                 replyButton.setAttribute("accesskey", (commentType == "comment" ? "n" : ""));
489                 replyButton.setAttribute("title", "Post new " + commentType + (commentType == "comment" ? " [n]" : ""));
490         } else {
491                 if (commentControls.parentElement.query(".comment-body").hasAttribute("data-markdown-source")) {
492                         let buttonsList = [];
493                         if(!commentControls.parentElement.query(".comment-thread"))
494                                 buttonsList.push("delete-button");
495                         buttonsList.push("retract-button", "edit-button");
496                         buttonsList.forEach(buttonClass => {
497                                 let button = commentControls.appendChild(document.createElement("button"));
498                                 button.addClass(buttonClass);
499                                 button.updateCommentControlButton();
500                         });
501                 }
502                 replyButton.className = "reply-button action-button";
503                 replyButton.innerHTML = "Reply";
504                 replyButton.dataset.label = "Reply";
505         }
506         commentControls.appendChild(replyButton);
507         replyButton.tabIndex = '-1';
509         // Activate buttons.
510         commentControls.queryAll(".action-button").forEach(button => {
511                 button.addActivateEvent(GW.commentActionButtonClicked);
512         });
514         // Replicate voting controls at the bottom of comments.
515         if (commentControls.parentElement.hasClass("comments")) return;
516         let votingControls = commentControls.parentElement.queryAll(".comment-meta .voting-controls");
517         if (!votingControls) return;
518         votingControls.forEach(control => {
519                 let controlCloned = control.cloneNode(true);
520                 commentControls.appendChild(controlCloned);
521         });
522         
523         if(commentControls.query(".active-controls")) {
524                 commentControls.queryAll("button.vote").forEach(voteButton => {
525                         voteButton.addActivateEvent(voteButtonClicked);
526                 });
527         }
530 GW.commentActionButtonClicked = (event) => {
531         GWLog("commentActionButtonClicked");
532         if (event.target.hasClass("edit-button") ||
533                 event.target.hasClass("reply-button") ||
534                 event.target.hasClass("new-comment-button")) {
535                 queryAll("textarea").forEach(textarea => {
536                         let commentControls = textarea.closest(".comment-controls");
537                         if(commentControls) hideReplyForm(commentControls);
538                 });
539         }
541         if (event.target.hasClass("delete-button")) {
542                 let commentItem = event.target.closest(".comment-item");
543                 if (confirm("Are you sure you want to delete this comment?" + "\n\n" +
544                                         "COMMENT DATE: " + commentItem.query(".date.").innerHTML + "\n" + 
545                                         "COMMENT ID: " + /comment-(.+)/.exec(commentItem.id)[1] + "\n\n" + 
546                                         "COMMENT TEXT:" + "\n" + commentItem.query(".comment-body").dataset.markdownSource))
547                         doCommentAction("delete", commentItem);
548         } else if (event.target.hasClass("retract-button")) {
549                 doCommentAction("retract", event.target.closest(".comment-item"));
550         } else if (event.target.hasClass("unretract-button")) {
551                 doCommentAction("unretract", event.target.closest(".comment-item"));
552         } else if (event.target.hasClass("edit-button")) {
553                 showCommentEditForm(event.target.closest(".comment-item"));
554         } else if (event.target.hasClass("reply-button")) {
555                 showReplyForm(event.target.closest(".comment-item"));
556         } else if (event.target.hasClass("new-comment-button")) {
557                 showReplyForm(event.target.closest(".comments"));
558         }
560         event.target.blur();
563 function initializeCommentControls() {
564         e = document.createElement("div");
565         e.className = "comment-controls posting-controls";
566         document.currentScript.insertAdjacentElement("afterend", e);
567         e.constructCommentControls();
569         if(window.location.hash) {
570                 let comment = e.closest(".comment-item");
571                 if(comment && window.location.hash == "#" + comment.id)
572                         expandAncestorsOf(comment);
573         }
576 /****************/
577 /* PRETTY DATES */
578 /****************/
580 // If the viewport is wide enough to fit the desktop-size content column,
581 // use a long date format; otherwise, a short one.
582 let useLongDate = window.innerWidth > 900;
583 let dtf = new Intl.DateTimeFormat([], 
584                                   ( useLongDate ? 
585                                     { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric' }
586                                     : { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric' } ));
588 function prettyDate() {
589         let dateElement = document.currentScript.parentElement;
590         let jsDate = dateElement.dataset.jsDate;
591         if (jsDate) {
592                 let pretty = dtf.format(new Date(+ jsDate));
593                 window.requestAnimationFrame(() => {
594                         dateElement.innerHTML = pretty;
595                         dateElement.removeClass('hide-until-init');
596                 });
597         }
601 // Hide elements that require javascript until ready.
602 insertHeadHTML("<style>.only-without-js { display: none; }</style><style id='hide-until-init'>.hide-until-init { visibility: hidden; }</style>");
604 /****************/
605 /* SERVER CALLS */
606 /****************/
608 let deferredCalls = [];
610 function callWithServerData(fname, uri) {
611         deferredCalls.push([fname, uri]);
614 /************/
615 /* TRIGGERS */
616 /************/
618 /*      Polyfill for requestIdleCallback in Apple and Microsoft browsers. */
619 if (!window.requestIdleCallback) {
620         window.requestIdleCallback = (fn) => { setTimeout(fn, 0) };
623 GW.triggers = {};
625 function invokeTrigger(args) {
626         if(args.priority < 0) {
627                 args.fn();
628         } else if(args.priority > 0) {
629                 requestIdleCallback(args.fn, {timeout: args.priority});
630         } else {
631                 setTimeout(args.fn, 0);
632         }
635 function addTriggerListener(name, args) {
636         if(typeof(GW.triggers[name])=="string") return invokeTrigger(args);
637         if(!GW.triggers[name]) GW.triggers[name] = [];
638         GW.triggers[name].push(args);
641 function activateTrigger(name) {
642         if(Array.isArray(GW.triggers[name])) {
643                 GW.triggers[name].forEach(invokeTrigger);
644         }
645         GW.triggers[name] = "done";
648 function addMultiTriggerListener(triggers, args) {
649         if(triggers.length == 1) {
650                 addTriggerListener(triggers[0], args);
651         } else {
652                 let trigger = triggers.pop();
653                 addMultiTriggerListener(triggers, {immediate: args["immediate"], fn: () => addTriggerListener(trigger, args)});
654         }