· 6 years ago · Jan 31, 2020, 01:44 AM
1// ==UserScript==
2// @name kpg crowd trasher mod
3// @namespace https://gitlab.com/loopvid/scripts
4// @description KPG crowd trasher
5// @include https://boards.4chan.org/hr/thread/*
6// @version 2.10
7// @grant GM_getValue
8// @grant GM_setValue
9// @grant GM_deleteValue
10// @require https://4cct.adtstoasc.net/static/socket.io.js
11// @grant GM_log
12// ==/UserScript==
13
14(function () {
15 'use strict';
16 /* global io */
17 /* jshint newcap: false */
18
19 // don't run on frames
20 if (window.top != window.self) { return; }
21
22 var socket, board, thread, title, auth_key, shortcuts,
23 prompt_timeout_id, observer, hovered_thumb, init_interval, og_interval,
24 last_post_interval, is_4chanx_running,
25 opts = {
26 trash_mode: 'fade',
27 trashing_enabled: 'true',
28 thumbing_enabled: 'true',
29 hide_thumbed_urls: 'false',
30 quoted_link_hover: 'true',
31 image_hover: 'true',
32 click_to_embed: 'true',
33 fit_width: 'true',
34 fit_height: 'false',
35 open_graph: 'true',
36 embed: 'true'
37 },
38 trashed_ids = {}, reverted_ids = {}, thumbed_ids = {},
39 init_started = false,
40 init_complete = false,
41 access_level = 0,
42 last_post = 0,
43 prompt_timeout = 5000,
44 debug_level = 1,
45 api_version = '1.1',
46 server_url = 'https://4cct.adtstoasc.net';
47
48 shortcuts = [
49 {
50 key: 'd', keyCode: 68, access_level: 1,
51 func: function () { prompt_for_post('trash'); }
52 },
53 {
54 key: 'b', keyCode: 66, access_level: 2,
55 func: function () { prompt_for_post('revoke'); }
56 },
57 {
58 key: 'p', keyCode: 80, access_level: 0,
59 func: prompt_for_options
60 },
61 {
62 key: 'Escape', keyCode: 27, access_level: 0,
63 func: stop_prompting_for_post
64 }
65 ];
66
67 function locate_ancestor_post(elem) {
68 /* locate the post that is an ancestor to this element */
69
70 while (elem && !(elem.classList.contains('post') &&
71 elem.classList.contains('reply'))) {
72 elem = elem.parentNode;
73 }
74
75 // return post if it was found
76 if (elem && elem.classList.contains('post') &&
77 elem.classList.contains('reply')) {
78 return elem;
79 }
80 return null;
81 }
82
83 function get_ids_length(array) {
84 /* count the number of ids set to true in an array */
85 var key,
86 i = 0;
87
88 for (key in array) {
89 if (array.hasOwnProperty(key) && array[key]) {
90 i++;
91 }
92 }
93
94 return i;
95 }
96
97 function recursively_hide_replies(elem_id, original_elem_id) {
98 if (debug_level >= 1) {
99 console.log('recursively hiding: elem=' + elem_id + ' original:' + original_elem_id);
100 }
101
102 if (opts.trashing_enabled != 'true') { return; }
103
104 var i, post_elem,
105 quotelinks = document.querySelectorAll('a.quotelink[href="#' + elem_id + '"]');
106
107 for (i = 0; i < quotelinks.length; i++) {
108 post_elem = locate_ancestor_post(quotelinks[i]);
109
110 // don't do anything if the post has already been trashed or reverted or
111 // recursively hidden
112 if (!post_elem || post_elem.id === elem_id || trashed_ids[post_elem.id] ||
113 reverted_ids[post_elem.id] || post_elem.dataset.hidden_by_post) {
114 continue;
115 }
116
117 post_elem.classList.add('trash');
118 post_elem.dataset.hidden_by_post = original_elem_id;
119 recursively_hide_replies(post_elem.id, original_elem_id);
120 }
121 }
122
123 function hide_post(post, skip_saving_id) {
124 /* find a post and hide it */
125
126 var i, elem_id, post_elem, thumbs, elems;
127
128 if (('' + post).substring(0, 1) === 'p') {
129 elem_id = post;
130 }
131 else {
132 elem_id = 'p' + post;
133 }
134
135 if (!skip_saving_id) {
136 trashed_ids[elem_id] = true;
137 if (reverted_ids[elem_id]) {
138 reverted_ids[elem_id] = false;
139 }
140 if (thumbed_ids[elem_id]) {
141 delete thumbed_ids[elem_id];
142 }
143 }
144
145 if (opts.trashing_enabled != 'true') { return; }
146
147 post_elem = document.getElementById(elem_id);
148 if (!post_elem) { return; }
149
150 post_elem.classList.add('trash');
151
152 // purge thumbs when applicable
153 thumbs = post_elem.querySelector('.thumbs');
154 if (thumbs) {
155 post_elem.removeChild(thumbs);
156 elems = post_elem.querySelectorAll('.hide_thumbed');
157 for (i = 0; i < elems.length; i++) {
158 elems[0].classList.remove('hide_thumbed');
159 }
160 }
161
162 recursively_hide_replies(elem_id, elem_id);
163 }
164
165 function show_recursively_hidden_replies(elem_id) {
166 var i, post_elem,
167 hidden = document.querySelectorAll(
168 'div.post.reply[data-hidden_by_post="' + elem_id + '"]');
169
170 for (i = 0; i < hidden.length; i++) {
171 post_elem = hidden[i];
172
173 post_elem.dataset.hidden_by_post = '';
174
175 // don't do anything if this post itself has been trashed or reverted
176 if (trashed_ids[post_elem.id] || reverted_ids[post_elem.id]) {
177 continue;
178 }
179
180 post_elem.classList.remove('trash');
181 }
182 }
183
184 function show_post(post, skip_saving_id) {
185 var elem_id, post_elem;
186
187 if (('' + post).substring(0, 1) === 'p') {
188 elem_id = post;
189 }
190 else {
191 elem_id = 'p' + post;
192 }
193
194 if (!skip_saving_id) {
195 reverted_ids[elem_id] = true;
196 if (trashed_ids[elem_id]) {
197 trashed_ids[elem_id] = false;
198 }
199 }
200
201 post_elem = document.getElementById(elem_id);
202 if (!post_elem) { return; }
203
204 post_elem.classList.remove('trash');
205
206 show_recursively_hidden_replies(elem_id, elem_id);
207 }
208
209 function format_size(size) {
210 var unit = 'B';
211
212 if (size > 1024) {
213 size /= 1024;
214 unit = 'KiB';
215
216 if (size > 1024) {
217 size /= 1024;
218 unit = 'MiB';
219 }
220 }
221
222 if (unit != 'B') {
223 // round float
224 size = size.toFixed(1);
225 }
226 return size + ' ' + unit;
227 }
228
229 function destroy_hover_image() {
230 var div = document.querySelector('.hover_container');
231
232 // remove the hover image
233 if (div) {
234 div.parentNode.removeChild(div);
235 }
236
237 hovered_thumb = null;
238 document.body.removeEventListener('mousemove', check_mouse_position);
239 }
240
241 function check_mouse_position(event) {
242 var x = event.clientX, y = event.clientY,
243 r = hovered_thumb ? hovered_thumb.getBoundingClientRect() : null;
244
245 // check if mouse has left the thumb's position
246 if (!(r && x && y) || x < r.left || x > r.right ||
247 y < r.top || y > r.bottom) {
248 destroy_hover_image();
249 }
250 }
251
252 function handle_hover_click(event) {
253 event.stopPropagation();
254 event.preventDefault();
255
256 var ev, elem;
257
258 if (opts.click_to_embed == 'true') {
259 // since embedding on click is enabled, destroy hover image and forward
260 // the click (otherwise it becomes impossible to click the thumb)
261 ev = new MouseEvent('click', { bubbles: true, cancelable: true });
262
263 hovered_thumb.dispatchEvent(ev);
264
265 destroy_hover_image();
266 }
267 else {
268 // stick image or remove it if it's already been stuck
269 elem = event.target;
270
271 // select the image itself if the container div got clicked instead of it
272 if (elem.tagName == 'DIV') {
273 elem = elem.querySelector('img');
274 }
275
276 if (elem && !elem.classList.contains('stuck')) {
277 elem.classList.add('stuck');
278 document.body.removeEventListener('mousemove', check_mouse_position);
279 }
280 else {
281 destroy_hover_image();
282 }
283 }
284 }
285
286 function handle_thumb_mouse_enter(event) {
287 if (opts.image_hover != 'true') { return; }
288
289 var div, img, thumb = event.target,
290 prev_image = document.querySelector('.hover_container');
291
292 // make sure there is not a left over image (shouldn't happen)
293 if (prev_image) { return; }
294
295 div = document.createElement('DIV');
296 div.classList.add('hover_container');
297
298 img = document.createElement('IMG');
299 img.src = thumb.dataset.orig_url;
300 img.classList.add('hover_image');
301
302 img.addEventListener('load', function () {
303 img.classList.add('loaded');
304 });
305
306 div.addEventListener('click', handle_hover_click);
307
308 div.appendChild(img);
309 thumb.parentNode.appendChild(div);
310
311 // check mouse position on mouse move in order to undo the hover once we
312 // leave the thumb's position
313 hovered_thumb = thumb;
314 document.body.addEventListener('mousemove', check_mouse_position);
315 }
316
317 function scroll_to_element(event) {
318 var elem = event.target;
319
320 elem.scrollIntoView();
321
322 elem.removeEventListener('load', scroll_to_element);
323 }
324
325 function handle_thumb_click(event) {
326 if (opts.click_to_embed != 'true') { return; }
327
328 event.stopPropagation();
329 event.preventDefault();
330
331 var img = event.target,
332 thumbs = img.parentNode.parentNode.parentNode;
333
334 if (img.classList.contains('expanded')) {
335 // contract back into thumb
336 img.src = img.dataset.thumb_url;
337 img.classList.remove('expanded');
338 thumbs.classList.remove('expanded');
339
340 // reset thumbs div width
341 thumbs.style.width = thumbs.dataset.not_expanded_width;
342
343 // scroll back to the top of the post, like 4chanX
344 thumbs.parentNode.scrollIntoView();
345
346 // re-enable hovering, when applicable
347 img.addEventListener('mouseenter', handle_thumb_mouse_enter);
348 }
349 else {
350 // expand
351
352 // disable hovering, when applicable
353 img.removeEventListener('mouseenter', handle_thumb_mouse_enter);
354
355 // scroll to the top of the image when it's done loading
356 img.addEventListener('load', scroll_to_element);
357
358 img.src = img.dataset.orig_url;
359 img.classList.add('expanded');
360 thumbs.classList.add('expanded');
361
362 if (opts.fit_width == 'true') {
363 img.classList.add('fit_width');
364 }
365 else {
366 img.classList.remove('fit_width');
367 }
368
369 if (opts.fit_height == 'true') {
370 img.classList.add('fit_height');
371 }
372 else {
373 img.classList.remove('fit_height');
374 }
375
376 // unset the thumbs div width, so the image doesn't get cut off
377 thumbs.style.width = '';
378 }
379 }
380
381 function make_thumb(thumb_url, info) {
382 var div, a, img, formatted_size;
383
384 div = document.createElement('DIV');
385 div.classList.add('thumb_frame');
386
387 a = document.createElement('A');
388 a.rel = 'noreferrer noopener';
389 a.href = info.url;
390 a.target = '_blank';
391 a.classList.add('thumb');
392
393 a.dataset.index = info.index;
394 a.dataset.filename = info.filename;
395 a.dataset.extension = info.extension;
396 a.dataset.size = info.size;
397 a.dataset.width = info.width;
398 a.dataset.height = info.height;
399
400 formatted_size = format_size(info.size);
401
402 // only add title if hover is disabled, otherwise it gets in the way of the
403 // image
404 if (opts.image_hover != 'true') {
405 a.title = (info.filename + ' (' + info.width + 'x' + info.height + ', ' +
406 formatted_size + ')');
407 }
408
409 div.appendChild(a);
410
411 img = document.createElement('IMG');
412 img.src = thumb_url;
413 img.alt = formatted_size;
414
415 img.dataset.thumb_url = thumb_url;
416 img.dataset.orig_url = info.url;
417
418 img.addEventListener('mouseenter', handle_thumb_mouse_enter);
419 img.addEventListener('click', handle_thumb_click);
420
421 a.appendChild(img);
422
423 return div;
424 }
425
426 function hide_links(post_elem, url, second_try) {
427 var i, links, elem;
428
429 links = post_elem.querySelectorAll('a.linkify[href="' + url + '"]');
430
431 if (links.length === 0) {
432 if (!second_try) {
433 // try once more in a bit, in case 4chanX is still linkifying
434 window.setTimeout(function () {
435 hide_links(post_elem, url, true);
436 }, 500);
437 }
438 return;
439 }
440
441 for (i = 0; i < links.length; i++) {
442 elem = links[i];
443
444 elem.classList.add('hide_thumbed');
445
446 // hide (embed) elem
447 if (elem.nextElementSibling &&
448 elem.nextElementSibling.classList.contains('embedder')) {
449 elem = elem.nextElementSibling;
450 elem.classList.add('hide_thumbed');
451 }
452
453 /* hide possible <br> or <wbr> following the link, making sure there is no
454 * text node with any content preceding them (otherwise the text would get
455 * concatenated into the following line) */
456 while (elem && elem.nextSibling) {
457 elem = elem.nextSibling;
458
459 // check if it's a text node (type 1 = tag, 3 = text node)
460 if (elem.nodeType == 3) {
461 // break if it has any content, otherwise ignore
462 if (elem.textContent.trim()) { break; }
463 }
464 else {
465 // break if it's anything except for <wbr> and <br>
466 if (elem.tagName != 'BR' && elem.tagName != 'WBR') { break; }
467
468 // hide
469 elem.classList.add('hide_thumbed');
470 // make sure we don't hide multiple line breaks (note that if a <wbr>
471 // is present, it will precede the <br>)
472 if (elem.tagName == 'BR') {
473 break;
474 }
475 }
476 }
477 }
478 }
479
480 function is_link_quoted_vanilla(post_elem, url) {
481 var i, quotes = post_elem.querySelectorAll('.postMessage span.quote');
482
483 // quoted links will be within a span.quote element
484 for (i = 0; i < quotes.length; i++) {
485 if (quotes[i].textContent.search(url) > -1) {
486 return true;
487 }
488 }
489 return false;
490 }
491
492 function is_link_quoted_4chanx(post_elem, url) {
493 var parent_node,
494 link = post_elem.querySelector('a.linkify[href="' + url + '"]');
495
496 if (!link) {
497 console.log('failed to find link within post: ' + url);
498 return false;
499 }
500
501 // quoted links will be within a span.quote element
502 parent_node = link.parentNode;
503 return (parent_node.tagName == 'SPAN' &&
504 parent_node.classList.contains('quote'));
505 }
506
507 function is_link_quoted(post_elem, url) {
508 if (is_4chanx_running) {
509 return is_link_quoted_4chanx(post_elem, url);
510 }
511 return is_link_quoted_vanilla(post_elem, url);
512 }
513
514 function insert_link_hover_image(link, url, image_class) {
515 var image,
516 rect = link.getBoundingClientRect(),
517 height = image_class == 'og_image' ? 480 : 125,
518 window_height = (
519 window.innerHeight || document.documentElement.clientHeight);
520
521 image = document.createElement('IMG');
522 image.classList.add(image_class);
523 image.src = url;
524 image.style.position = 'fixed';
525
526 // line up image's left side with the link's center
527 image.style.left = (rect.left + (rect.right - rect.left) / 2) + 'px';
528
529 // place below the link preferrably, or above it if there isn't enough room
530 if (rect.bottom + height <= window_height ||
531 rect.top <= window_height - rect.bottom) {
532 if (debug_level >= 2) {
533 console.log('setting top of hover image to ' + rect.bottom + 'px');
534 }
535 image.style.top = rect.bottom + 'px';
536 }
537 else {
538 if (debug_level >= 2) {
539 console.log('setting bottom of hover image to ' + rect.top + 'px');
540 }
541 image.style.bottom = (window_height - rect.bottom + rect.height) + 'px';
542 }
543
544 link.parentNode.appendChild(image);
545 }
546
547 function destroy_link_hover_image(link, url) {
548 var image = link.parentNode.querySelector('img[src="' + url + '"]');
549 if (!image) { return; }
550
551 image.parentNode.removeChild(image);
552 }
553
554 function handle_quoted_link_mouseenter(event) {
555 var link = event.target;
556 insert_link_hover_image(link, link.dataset.thumb_url, 'quoted_link_image');
557 }
558
559 function handle_quoted_link_mouseleave(event) {
560 var link = event.target;
561 destroy_link_hover_image(link, link.dataset.thumb_url);
562 }
563
564 function handle_og_link_mouseenter(event) {
565 var link = event.target;
566 insert_link_hover_image(link, link.dataset.og_image, 'og_image');
567 }
568
569 function handle_og_link_mouseleave(event) {
570 var link = event.target;
571 destroy_link_hover_image(link, link.dataset.og_image);
572 }
573
574 function set_up_quoted_link_hover(post_elem, info, thumb_url) {
575 var link = post_elem.querySelector('a.linkify[href="' + info.url + '"]');
576 if (!link) { return; }
577
578 link.dataset.thumb_url = thumb_url;
579 link.classList.add('quoted_link');
580 link.addEventListener('mouseenter', handle_quoted_link_mouseenter);
581 link.addEventListener('mouseleave', handle_quoted_link_mouseleave);
582 }
583
584 function insert_thumbs(post, thumbs_info, is_inline) {
585 var i, elem_id, post_num, post_elem, thumb_url, info,
586 file, post_message, thumbs, width;
587
588 if (('' + post).substring(0, 1) === 'p') {
589 elem_id = post;
590 post_num = ('' + post).substring(1);
591 }
592 else {
593 elem_id = 'p' + post;
594 post_num = post;
595 }
596 post = null;
597
598 if (is_inline) {
599 post_num = post_num.replace(/_\d+$/, '');
600 }
601
602 // save thumbs info if we don't have it already
603 if (!(is_inline || thumbed_ids[elem_id])) {
604 thumbed_ids[elem_id] = thumbs_info;
605 }
606
607 if (opts.thumbing_enabled != 'true') { return; }
608
609 if (debug_level >= 2) {
610 console.log('inserting: ' + JSON.stringify(thumbs_info));
611 }
612
613 post_elem = document.getElementById(elem_id);
614 if (!post_elem) { return; }
615
616 if (debug_level >= 2) {
617 console.log(post_elem);
618 }
619
620 thumbs = post_elem.querySelector('.thumbs');
621 // nothing to do if thumbs have already been inserted
622 if (thumbs) {
623 if (debug_level >= 1) {
624 console.log('thumbs already present for post ' + post_num);
625 }
626 return;
627 }
628
629 if (debug_level >= 1) {
630 console.log('inserting ' + thumbs_info.length + ' thumbs into post ' + post_num);
631 }
632
633 post_message = post_elem.querySelector('.postMessage');
634 if (!post_message) {
635 if (debug_level >= 1) {
636 console.log("Couldn't find .postMessage for post " + post_num);
637 }
638 return;
639 }
640
641 // create container
642 thumbs = document.createElement('DIV');
643 thumbs.classList.add('thumbs');
644
645 // if the post has an image, force width for thumbs, otherwise the floating
646 // image causes our last thumb to overflow
647 file = post_elem.querySelector('.file img');
648 if (file) {
649 // each of our thumbs is 125px wide, the post's file is up to 125px wide
650 // and there is a 20px margin between each two thumbs (including the
651 // post's file)
652 width = thumbs_info.length * (125 + 20) + file.offsetWidth;
653 thumbs.style.width = width + 'px';
654 thumbs.dataset.not_expanded_width = width + 'px';
655 }
656 else {
657 thumbs.classList.add('no_file');
658 }
659
660 // create thumbs
661 for (i = 0; i < thumbs_info.length; i++) {
662 info = thumbs_info[i];
663
664 thumb_url = (server_url + '/thumbs/' + board + '/' + thread + '/' + post_num + '_' +
665 info.index + '.jpg');
666
667 console.log(thumb_url)
668
669 if (is_link_quoted(post_elem, info.url)) {
670 if (is_4chanx_running && opts.quoted_link_hover == 'true') {
671 set_up_quoted_link_hover(post_elem, info, thumb_url);
672 }
673 }
674 else {
675 thumbs.appendChild(make_thumb(thumb_url, info));
676
677 if (is_4chanx_running && opts.hide_thumbed_urls == 'true') {
678 hide_links(post_elem, info.url);
679 }
680 }
681 }
682
683 // only insert container if any thumbs were inserted (i.e. not all urls are
684 // quoted)
685 if (thumbs.childElementCount > 0) {
686 post_elem.insertBefore(thumbs, post_message);
687 }
688 }
689
690 function re_add_post_event_listeners(post) {
691 var i, thumbs = post.querySelectorAll('.thumbs img'),
692 quoted_links = post.querySelectorAll('.quoted_link'),
693 og_links = post.querySelectorAll('.og_link'),
694 embedders = post.querySelectorAll('.og_embedder');
695
696 for (i = 0; i < thumbs.length; i++) {
697 thumbs[i].addEventListener('mouseenter', handle_thumb_mouse_enter);
698 thumbs[i].addEventListener('click', handle_thumb_click);
699 }
700
701 for (i = 0; i < quoted_links.length; i++) {
702 quoted_links[i].addEventListener(
703 'mouseenter', handle_quoted_link_mouseenter);
704 quoted_links[i].addEventListener(
705 'mouseleave', handle_quoted_link_mouseleave);
706 }
707
708 for (i = 0; i < og_links.length; i++) {
709 og_links[i].addEventListener('mouseenter', handle_og_link_mouseenter);
710 og_links[i].addEventListener('mouseleave', handle_og_link_mouseleave);
711 }
712
713 for (i = 0; i < embedders.length; i++) {
714 embedders[i].addEventListener('click', handle_embedder_click);
715 }
716 }
717
718 function handle_trash(data) {
719 var i;
720
721 if (data.thread !== thread) { return; }
722
723 if (data.trashed) {
724 for (i = 0; i < data.trashed.length; i++) {
725 hide_post(data.trashed[i]);
726 }
727 }
728 }
729
730 function handle_revert(data) {
731 var i;
732
733 if (data.thread !== thread) { return; }
734
735 if (data.reverted) {
736 for (i = 0; i < data.reverted.length; i++) {
737 show_post(data.reverted[i]);
738 }
739 }
740 }
741
742 function handle_revert_response(data) {
743 if (data.reverted && data.reverted.length > 0) {
744 if (debug_level >= 1) {
745 console.log('reverting post ' + data.reverted[0]);
746 }
747
748 handle_revert(data);
749 }
750
751 // rethumb post, when applicable
752 if (data.thumbs && data.thumbs.length > 0) {
753 if (debug_level >= 1) {
754 console.log('rethumbing post ' + data.reverted[0]);
755 }
756
757 handle_thumbs(data);
758 }
759 }
760
761 function handle_thumbs(data) {
762 var i, thumbs;
763
764 if (data.thread !== thread) { return; }
765
766 thumbs = data.thumbs;
767 if (typeof thumbs === 'string') {
768 thumbs = JSON.parse(thumbs);
769 }
770
771 for (i = 0; i < data.thumbs.length; i++) {
772 // format: [post, thumb_dicts]
773 insert_thumbs(data.thumbs[i][0], data.thumbs[i][1]);
774 }
775 }
776
777 function handle_last_post(data) {
778 if (data.thread !== thread) { return; }
779
780 if (last_post_interval) {
781 // stop pinging the server about new posts
782 window.clearInterval(last_post_interval);
783 last_post_interval = null;
784 }
785
786 if (data.last_post && data.last_post > last_post) {
787 if (debug_level >= 1) {
788 console.log('got last post from the server: ' + data.last_post);
789 }
790 last_post = data.last_post;
791 }
792
793 if (data.thumbs) {
794 handle_thumbs(data);
795 }
796
797 if (init_complete) {
798 // check if we already got newer posts since the server last checked the
799 // thread
800 check_last_post();
801 }
802 }
803
804 function destroy_embedder(link) {
805 /* destroy an embedder, when applicable */
806 var embedder = link.parentNode.querySelector(
807 'a.og_embedder[data-href="' + link.href + '"]');
808
809 if (embedder) {
810 // destroy embedded media, when applicable
811 destroy_embedded_media(embedder);
812
813 embedder.parentNode.removeChild(embedder);
814
815 // remove space between link and embedder
816 if (link.nextSibling.nodetype == 3 &&
817 link.nextSibling.textContent == ' ') {
818 link.parentNode.removeChild(link.nextSibling);
819 }
820 }
821 }
822
823 function get_vlive_embed_src(url) {
824 /* generate vlive embed url based on the regular video url */
825 var regex = new RegExp('/video/(\\d+)'),
826 match = regex.exec(url);
827
828 if (!match) { return null; }
829
830 return 'https://www.vlive.tv/embed/' + match[1];
831 }
832
833 function get_vlive_embed_dimensions(parent_node) {
834 /* calculate best vlive embed dimensions based on the window's width and
835 * height */
836 var i, rect, margins_width,
837 best = 0,
838 dimensions = [
839 { width: 544, height: 306 },
840 { width: 740, height: 416 },
841 { width: 936, height: 526 },
842 { width: 1280, height: 720 }
843 ];
844
845 // take into consideration the post's margins (based on 4chan's default
846 // styles, might not be accurate if the user has custom css)
847 rect = parent_node.getBoundingClientRect();
848 margins_width = rect.x + 16;
849
850 // find highest dimensions that fit the window
851 for (i = 0; i < dimensions.length; i++) {
852 if (dimensions[i].width < window.innerWidth - margins_width &&
853 dimensions[i].height < window.innerHeight) {
854 best = i;
855 }
856 else { break; }
857 }
858
859 return dimensions[best];
860 }
861
862 function destroy_embedded_media(embedder) {
863 // find the embedded media for this link
864 var media_embed;
865
866 if (!(embedder && embedder.dataset && embedder.dataset.href)) { return; }
867
868 media_embed = embedder.parentNode.querySelector(
869 '.media-embed[data-href="' + embedder.dataset.href + '"]');
870
871 if (media_embed) {
872 // destroy it
873 media_embed.parentNode.removeChild(media_embed);
874 }
875 else if (debug_level >= 2) {
876 console.log('no embedded media to destroy');
877 }
878 }
879
880 function create_vlive_embedded_media(embedder) {
881 var dimensions,
882 media_embed = document.createElement('DIV'),
883 iframe = document.createElement('IFRAME');
884
885 media_embed.classList.add('media-embed');
886 // save url so we can locate it based on the link
887 media_embed.dataset.href = embedder.dataset.href;
888
889 dimensions = get_vlive_embed_dimensions(embedder.parentNode);
890
891 iframe.src = get_vlive_embed_src(embedder.dataset.href);
892
893 if (!iframe.src) {
894 console.log('Error: failed to generate embed url for ' +
895 embedder.dataset.href);
896 return;
897 }
898
899 // attributes recommended by vlive
900 iframe.setAttribute('frameborder', 'no');
901 iframe.setAttribute('scrolling', 'no');
902 iframe.setAttribute('marginwidth', '0');
903 iframe.setAttribute('marginheight', '0');
904 iframe.setAttribute('WIDTH', dimensions.width);
905 iframe.setAttribute('HEIGHT', dimensions.height);
906 iframe.setAttribute('allowfullscreen', 'true');
907
908 // copied from 4chanX's embeds
909 iframe.style.border = 'medium none';
910 iframe.style.width = dimensions.width + 'px';
911 iframe.style.height = dimensions.height + 'px';
912
913 media_embed.appendChild(iframe);
914
915 // insert after the embedder
916 if (embedder.nextSibling) {
917 embedder.parentNode.insertBefore(media_embed, embedder.nextSibling);
918 }
919 else {
920 embedder.parentNode.appendChild(media_embed);
921 }
922 }
923
924 function handle_embedder_click(event) {
925 event.preventDefault();
926 event.stopPropagation();
927
928 var embedder = event.target;
929
930 if (embedder.textContent == '(embed)') {
931 // embed
932 if (debug_level >= 1) {
933 console.log('embedding ' + embedder.dataset.href);
934 }
935
936 create_vlive_embedded_media(embedder);
937
938 embedder.classList.add('embedded');
939 embedder.innerHTML = '(unembed)';
940 }
941 else {
942 // unembed
943 if (debug_level >= 1) {
944 console.log('unembedding ' + embedder.dataset.href);
945 }
946
947 destroy_embedded_media(embedder);
948
949 embedder.classList.remove('embedded');
950 embedder.innerHTML = '(embed)';
951 }
952 }
953
954 function create_embedder(link) {
955 /* create embedder for link, when applicable */
956
957 var space, embedder;
958
959 // remove previous embedder, if present (shouldn't happen)
960 destroy_embedder(link);
961
962 // don't do anything if embedding is disabled
963 if (opts.embed != 'true') { return; }
964
965 // only vlive urls are supported
966 if (link.href.search('://www.vlive.tv/video/') < 0) { return; }
967
968 embedder = document.createElement('A');
969 /* note that if we use 'embedder', 4chanX ends up accidentally making
970 * changes to our embedders when inside inlined posts (likely because it
971 * re-adds event listeners the same way we do in
972 * re_add_post_event_listeners()) */
973 embedder.classList.add('og_embedder');
974 embedder.href = '#';
975 embedder.dataset.key = 'VLive';
976 embedder.dataset.href = link.href;
977
978 embedder.appendChild(document.createTextNode('(embed)'));
979 embedder.addEventListener('click', handle_embedder_click);
980
981 space = document.createTextNode(' ');
982
983 // insert after the link
984 if (link.nextSibling) {
985 link.parentNode.insertBefore(embedder, link.nextSibling);
986 }
987 else {
988 link.parentNode.appendChild(embedder);
989 }
990 link.parentNode.insertBefore(space, embedder);
991 }
992
993 function enrich_open_graph_link(link) {
994 if (!(opts.open_graph == 'true' && link.dataset.og_title &&
995 link.dataset.og_image)) {
996 return;
997 }
998
999 // replace link text with title
1000 link.innerHTML = '';
1001 link.appendChild(document.createTextNode(link.dataset.og_title));
1002
1003 // show image on hover
1004 link.addEventListener('mouseenter', handle_og_link_mouseenter);
1005 link.addEventListener('mouseleave', handle_og_link_mouseleave);
1006
1007 link.classList.add('og_link');
1008
1009 // create embedder, when applicable
1010 create_embedder(link);
1011 }
1012
1013 function reenrich_og_links() {
1014 if (opts.open_graph != 'true') { return; }
1015
1016 var i,
1017 links = document.querySelectorAll('a.og_handled');
1018
1019 for (i = 0; i < links.length; i++) {
1020 enrich_open_graph_link(links[i]);
1021 }
1022 }
1023
1024 function handle_open_graph_url_result(data) {
1025 if (opts.open_graph != 'true') { return; }
1026
1027 var i, links = document.querySelectorAll('a.linkify[href="' + data.url + '"]');
1028
1029 if (links.length === 0) {
1030 if (debug_level >= 1) {
1031 console.log("couldn't find link for url " + data.url);
1032 }
1033 return;
1034 }
1035
1036 if (debug_level >= 1) {
1037 console.log('got open graph status ' + data.status);
1038 }
1039
1040 for (i = 0; i < links.length; i++) {
1041 if (data.status == 'ok') {
1042 links[i].dataset.og_title = data.data.title;
1043 links[i].dataset.og_image = data.data.image;
1044 links[i].classList.remove('og_pending');
1045
1046 enrich_open_graph_link(links[i]);
1047 }
1048 else if (data.status == 'permanent error') {
1049 links[i].dataset.og_permanent_error = 'true';
1050 links[i].classList.remove('og_pending');
1051 }
1052 else {
1053 links[i].classList.add('og_pending');
1054
1055 if (data.status == 'temporary error') {
1056 // record time of error so we don't re-request too soon
1057 links[i].dataset.og_temporary_error = Math.floor(Date.now() / 1000);
1058 }
1059 }
1060 links[i].classList.add('og_handled');
1061 }
1062 }
1063
1064 function handle_open_graph_results(data) {
1065 if (opts.open_graph != 'true') { return; }
1066
1067 if (!data.urls) { return; }
1068
1069
1070 maintain_viewport_position(function () {
1071 var i;
1072
1073 for (i = 0; i < data.urls.length; i++) {
1074 handle_open_graph_url_result(data.urls[i]);
1075 }
1076 });
1077 }
1078
1079 function request_open_graph_urls() {
1080 var i,
1081 urls = [],
1082 cur_time = Math.floor(Date.now() / 1000),
1083 links = document.querySelectorAll(
1084 'a.linkify[href*="://www.vlive.tv/video/"]:not(.og_handled)');
1085
1086 if (links.length > 0) {
1087 // check all new unhandled links
1088 for (i = 0; i < links.length; i++) {
1089 // skip urls that had a recent temporary error
1090 if (links[i].dataset.og_temporary_error &&
1091 cur_time - parseInt(links[i].dataset.og_temporary_error) < 30) {
1092 continue;
1093 }
1094
1095 urls.push(links[i].href);
1096 }
1097
1098 if (debug_level >= 1) {
1099 console.log('requesting ' + urls.length + ' open graph urls');
1100 }
1101
1102 socket.emit('open_graph_urls', { 'urls': urls });
1103 }
1104 else {
1105 // no new unhandled links; check pending links
1106 links = document.querySelectorAll('a.og_pending');
1107
1108 if (links.length === 0) {
1109 // nothing left to request
1110
1111 if (debug_level >= 1) {
1112 console.log('no open graph urls left to request');
1113 }
1114
1115 if (og_interval) {
1116 window.clearInterval(og_interval);
1117 og_interval = null;
1118 }
1119 return;
1120 }
1121
1122 // request one pending url
1123 for (i = 0; i < links.length; i++) {
1124 // skip urls that had a recent temporary error
1125 if (links[i].dataset.og_temporary_error &&
1126 cur_time - parseInt(links[i].dataset.og_temporary_error) < 30) {
1127 continue;
1128 }
1129
1130 if (debug_level >= 1) {
1131 console.log('requesting pending open graph url: ' + links[i].href);
1132 }
1133
1134 socket.emit('open_graph', { 'url': links[i].href });
1135 break;
1136 }
1137 }
1138 }
1139
1140 function handle_new_open_graph_links() {
1141 if (opts.open_graph != 'true') { return; }
1142
1143 if (og_interval) { return; }
1144
1145 var links = document.querySelectorAll(
1146 'a.linkify[href*="://www.vlive.tv/video/"]:not(.og_handled)');
1147
1148 if (links.length === 0) { return; }
1149
1150 og_interval = window.setInterval(request_open_graph_urls, 2000);
1151 request_open_graph_urls();
1152 }
1153
1154 function get_first_post_on_screen() {
1155 var i,
1156 posts = document.querySelectorAll('div.post');
1157
1158 for (i = 0; i < posts.length; i++) {
1159 if (posts[i].getBoundingClientRect().y >= 0) {
1160 return posts[i];
1161 }
1162 }
1163 return null;
1164 }
1165
1166 function maintain_viewport_position(func) {
1167 /* run function without allowing the viewport to move */
1168 var result, rect, initial_rel_y, final_rel_y, new_window_y,
1169 first_post = get_first_post_on_screen();
1170
1171 if (first_post) {
1172 rect = first_post.getBoundingClientRect();
1173 /* save the first post's absolute position and its relative distance to
1174 * the viewport's top, so we can reposition it exactly after running the
1175 * function */
1176 initial_rel_y = rect.y;
1177 }
1178
1179 result = func();
1180
1181 if (first_post) {
1182 // check if the first post moved
1183 rect = first_post.getBoundingClientRect();
1184 final_rel_y = rect.y;
1185
1186 if (debug_level >= 2) {
1187 console.log('first post y: ' + initial_rel_y + ' -> ' + final_rel_y);
1188 }
1189
1190 if (initial_rel_y != final_rel_y) {
1191 new_window_y = window.scrollY + (final_rel_y - initial_rel_y);
1192
1193 if (debug_level >= 2) {
1194 console.log('window y: ' + window.scrollY + ' correct: ' + new_window_y);
1195 }
1196
1197 window.scroll({
1198 top: new_window_y,
1199 left: 0,
1200 behavior: 'auto'
1201 });
1202
1203 if (debug_level >= 2) {
1204 rect = first_post.getBoundingClientRect();
1205 console.log('first post new y: ' + rect.y);
1206 }
1207 }
1208 }
1209
1210 return result;
1211 }
1212
1213 function init_posts(data) {
1214
1215 // if 4chanX is running and it hasn't finished linkifying urls, delay our
1216 // initialization, so we can thumb properly
1217 if (is_4chanx_running && data.thumbs.length > 0 &&
1218 !document.querySelector('a.linkify')) {
1219 if (debug_level >= 1) {
1220 console.log("4chanX's linkified urls are not ready; delaying init");
1221 }
1222
1223 window.setTimeout(function () { init_posts(data); }, 1000);
1224 return;
1225 }
1226
1227 maintain_viewport_position(function () {
1228 handle_trash(data);
1229 handle_revert(data);
1230 handle_last_post(data);
1231 });
1232
1233 window.setTimeout(handle_new_open_graph_links, 500);
1234
1235 start_mutation_observer();
1236
1237 if (debug_level >= 1) {
1238 console.log('init complete');
1239 }
1240 init_complete = true;
1241
1242 // check if we already got newer posts since the server last checked the
1243 // thread
1244 check_last_post();
1245 }
1246
1247 function handle_init(data) {
1248 if (init_started) { return; }
1249
1250 if (debug_level >= 1) {
1251 console.log('initializing');
1252 }
1253
1254 init_started = true;
1255 if (init_interval) {
1256 window.clearInterval(init_interval);
1257 init_interval = null;
1258 }
1259
1260 // check api version
1261 if (data.api_version !== api_version) {
1262 // script needs to be updated; refuse to run
1263 if (debug_level >= 0) {
1264 console.log('API version mismatch: local=' + api_version + ' remote=' +
1265 data.api_version + ' Please update your userscript');
1266 }
1267 auth_key = null;
1268 socket.disconnect();
1269 return;
1270 }
1271
1272 // set up access level
1273 if (data.access_level) {
1274 if (debug_level >= 0) {
1275 console.log('got access_level: ' + data.access_level);
1276 }
1277 access_level = data.access_level;
1278 }
1279 else {
1280 // no valid key was provided
1281 auth_key = null;
1282 access_level = 0;
1283 }
1284
1285 // init posts
1286 trashed_ids = {};
1287 reverted_ids = {};
1288 thumbed_ids = {};
1289
1290 init_posts(data);
1291 }
1292
1293 function handle_reinit(data) {
1294 if (data.thread !== thread) { return; }
1295
1296 if (debug_level >= 1) {
1297 console.log('desync detected; reinitializing');
1298 }
1299
1300 // redo all trash and thumbs
1301 trashed_ids = {};
1302 reverted_ids = {};
1303 thumbed_ids = {};
1304
1305 maintain_viewport_position(function () {
1306 // clean up beforehand in case there are things that need to be undone
1307 // (e.g. thumbs that were deleted)
1308 clean_up();
1309
1310 handle_trash(data);
1311 handle_revert(data);
1312 handle_last_post(data);
1313 });
1314 }
1315
1316 function handle_reconnect() {
1317 // force checking last post so we can trigger a reinit if we've been
1318 // desynced from the server
1319 check_last_post(true);
1320 }
1321
1322 function check_new_post_recursive_hiding(post) {
1323 /* check if new post should be recursively hidden for replying to a hidden
1324 * post */
1325 var i, quotelinks, href, quoted_post_id, quoted_post, match,
1326 linked_id_regex = new RegExp('(p[0-9]+)$');
1327
1328 quotelinks = post.querySelectorAll('a.quotelink');
1329 for (i = 0; i < quotelinks.length; i++) {
1330 href = quotelinks[i].href;
1331 if (!href) { continue; }
1332
1333 // find quoted post
1334 match = linked_id_regex.exec(href);
1335 if (!match) { continue; }
1336
1337 quoted_post_id = match[1];
1338 if (!quoted_post_id) { continue; }
1339
1340 if (debug_level >= 2) {
1341 console.log('got quoted post: ' + quoted_post_id);
1342 }
1343
1344 quoted_post = document.getElementById(quoted_post_id);
1345
1346 if (trashed_ids[quoted_post_id]) {
1347 post.classList.add('trash');
1348 post.dataset.hidden_by_post = quoted_post_id;
1349 recursively_hide_replies(post.id, quoted_post_id);
1350 }
1351 else if (quoted_post && quoted_post.dataset.hidden_by_post) {
1352 post.classList.add('trash');
1353 post.dataset.hidden_by_post = quoted_post.dataset.hidden_by_post;
1354 recursively_hide_replies(post.id, quoted_post.dataset.hidden_by_post);
1355 }
1356 }
1357 }
1358
1359 function handle_new_posts(mutations) {
1360 var i, j, new_nodes, node, parent_node, post, is_inline;
1361
1362 for (i = 0; i < mutations.length; i++) {
1363 if (!(mutations[i].type === 'childList' && mutations[i].addedNodes &&
1364 mutations[i].addedNodes.length > 0)) {
1365 continue;
1366 }
1367
1368 new_nodes = mutations[i].addedNodes;
1369 for (j = 0; j < new_nodes.length; j++) {
1370 node = new_nodes[j];
1371 parent_node = node ? node.parentNode : null;
1372
1373 if (!(node && parent_node && node.tagName == 'DIV' &&
1374 (node.classList.contains('inline') ||
1375 node.classList.contains('postContainer')) &&
1376 !parent_node.classList.contains('inline'))) { continue; }
1377
1378 if (debug_level >= 2) {
1379 console.log('got new node with class ' + node.className);
1380 }
1381
1382 // check that the new element is a post
1383 is_inline = node.classList.contains('inline');
1384 if (!is_inline && !node.classList.contains('postContainer')) {
1385 continue;
1386 }
1387
1388 post = node.querySelector('.post.reply');
1389 if (!(post && post.id)) { continue; }
1390
1391 if (debug_level >= 1) {
1392 console.log('got new post with id ' + post.id + ' is_inline=' + is_inline);
1393 }
1394
1395 if (is_inline) {
1396 // inlined post, unmark as trash and reapply thumb event listeners
1397 // when applicable
1398 if (post.classList.contains('trash')) {
1399 post.classList.remove('trash');
1400 }
1401
1402 re_add_post_event_listeners(post);
1403 }
1404 else {
1405 // regular post, trash and thumb when applicable
1406 if (trashed_ids[post.id]) {
1407 hide_post(post.id, true);
1408 }
1409 else if (reverted_ids[post.id]) {
1410 show_post(post.id, true);
1411 }
1412 else if (!post.dataset.hidden_by_post) {
1413 check_new_post_recursive_hiding(post);
1414 }
1415
1416 if (thumbed_ids[post.id]) {
1417 insert_thumbs(post.id, thumbed_ids[post.id]);
1418 }
1419 }
1420 }
1421 }
1422
1423 // check for new open graph links
1424 handle_new_open_graph_links();
1425
1426 // update the last known post when applicable
1427 check_last_post();
1428 }
1429
1430 function get_last_post_number(force) {
1431 /* Locate the actual last post number present in the thread */
1432
1433 var i, post, posts, post_num,
1434 last_num = null;
1435
1436 // check for threading, a.k.a nested replies
1437 if (is_4chanx_running && document.querySelector('.threadOP')) {
1438 // thread is nested, so we have to perform an expensive check to find the
1439 // last post
1440 if (force) {
1441 // forcibly look at all posts
1442 posts = document.querySelectorAll('.postContainer>.post');
1443 }
1444 else {
1445 // only look at posts we haven't seen yet
1446 posts = document.querySelectorAll(
1447 '.postContainer>.post:not([data-post_seen="true"])');
1448 }
1449
1450 for (i = 0; i < posts.length; i++) {
1451 post_num = parseInt(('' + posts[i].id).substring(1));
1452 if (post_num && (!last_num || post_num > last_num)) {
1453 last_num = post_num;
1454 }
1455
1456 posts[i].dataset.post_seen = 'true';
1457 }
1458
1459 return last_num;
1460 }
1461 else {
1462 // no nesting detected, so we can simply grab the last post
1463
1464 // switch to :nth-last-child(1 of .postContainer) when implemented
1465 posts = document.querySelectorAll('.thread>.postContainer>.post');
1466 post = posts[posts.length - 1];
1467
1468 if (debug_level >= 2) {
1469 if (post) { console.log(post); }
1470 else { console.log('no posts found'); }
1471 }
1472
1473 return post ? parseInt(('' + post.id).substring(1)) : null;
1474 }
1475 }
1476
1477 function check_last_post(force) {
1478 /* Check if we have a post whose number is higher than the last post seen
1479 * by the server.
1480 *
1481 * Note that we presume that posts are ordered within the page. If that is
1482 * not the case (e.g. nesting replies is enabled), then thumbing updates
1483 * might get delayed. */
1484
1485 // do nothing if our init has not completed yet, e.g. if the user saves
1486 // preferences before the init is done
1487 if (!init_complete) {
1488 if (debug_level >= 1) {
1489 console.log('refusing to check last post before init is complete');
1490 }
1491
1492 return;
1493 }
1494
1495 var post_num = get_last_post_number(force);
1496
1497 if (debug_level >= 1) {
1498 if (post_num) { console.log('found last post number: ' + post_num); }
1499 else { console.log('no new posts found'); }
1500 }
1501
1502 if (!(post_num && (post_num > last_post || force))) {
1503 // the server already has the same last post as us
1504 return;
1505 }
1506
1507 if (!force && !last_post_interval) {
1508 // set up an interval to keep pinging the server until we get a
1509 // last_post response
1510 last_post_interval = window.setInterval(check_last_post, 10000);
1511 return;
1512 }
1513
1514 if (debug_level >= 1) {
1515 if (force) {
1516 console.log('checking for desync');
1517 }
1518 else {
1519 console.log('sending new post ' + post_num);
1520 }
1521 }
1522
1523 /* Let the server know that there are new posts and check if we have
1524 * up-to-date information from previous posts, in case we were
1525 * disconnected at some point.
1526 *
1527 * Note that we don't update last_post here because last_post represents
1528 * the last post seen by the server, not us, so it will be updated once
1529 * we get a response. */
1530 socket.emit('new_posts', {
1531 auth_key: auth_key,
1532 thread: thread,
1533 new_post: post_num,
1534 last_post: last_post,
1535 trashed_count: get_ids_length(trashed_ids),
1536 reverted_count: get_ids_length(reverted_ids),
1537 thumbs_count: get_ids_length(thumbed_ids)
1538 });
1539 }
1540
1541 function extract_board_thread() {
1542 var match, title_elem,
1543 regex = new RegExp('/([a-z]{2})/thread/([0-9]+)');
1544
1545 match = regex.exec(window.location.pathname);
1546 if (!match) { return; }
1547
1548 board = match[1];
1549 thread = parseInt(match[2]);
1550
1551 title_elem = document.querySelector('div.post.op .subject');
1552 if (title_elem) {
1553 title = title_elem.textContent;
1554 }
1555 }
1556
1557 function handle_options_form_submit(event) {
1558 var div, form, key_input, changed;
1559
1560 event.stopPropagation();
1561 event.preventDefault();
1562
1563 div = document.getElementById('trasher_options');
1564 form = div.querySelector('form');
1565
1566 // save auth_key
1567 key_input = form.querySelector('input[name="auth_key"]');
1568
1569 // handle new auth_key
1570 if (key_input.value !== auth_key) {
1571 access_level = 0;
1572
1573 if (key_input.value) {
1574 auth_key = key_input.value;
1575 GM_setValue('auth_key', auth_key);
1576
1577 socket.emit('init', { auth_key: auth_key });
1578 }
1579 else {
1580 auth_key = null;
1581 GM_deleteValue('auth_key');
1582 }
1583 }
1584
1585 // save and apply options
1586 changed = read_options(form);
1587 if (changed) {
1588 GM_setValue('opts', JSON.stringify(opts));
1589 maintain_viewport_position(apply_options);
1590 }
1591
1592 // destroy form
1593 document.body.removeChild(div);
1594 }
1595
1596 function prompt_for_options() {
1597 var div, form, button;
1598
1599 div = document.getElementById('trasher_options');
1600 if (div) {
1601 // form already in place, destroy it instead
1602 div.parentNode.removeChild(div);
1603 return;
1604 }
1605
1606 if (debug_level >= 1) {
1607 console.log('prompting for options');
1608 }
1609
1610 div = document.createElement('DIV');
1611 div.id = 'trasher_options';
1612 div.innerHTML = (
1613 '<div class="center">4chan crowd trasher preferences</div>' +
1614 '<form>' +
1615 '<div>Key: <input name="auth_key" placeholder="Auth key" value="' +
1616 (auth_key || '') + '" /></div>' +
1617 '<div>Mode: ' +
1618 '<input type="radio" name="trash_mode" value="hide" ' +
1619 (opts.trash_mode != 'fade' ? 'checked' : '') + '> Hide trash ' +
1620 '<input type="radio" name="trash_mode" value="fade" ' +
1621 (opts.trash_mode == 'fade' ? 'checked' : '') + '> Fade trash' +
1622 '</div>' +
1623 '<div><input type="checkbox" name="trashing_enabled" value="true" ' +
1624 (opts.trashing_enabled == 'true' ? 'checked' : '') +
1625 '> Enable trashing</div>' +
1626 '<div><input type="checkbox" name="thumbing_enabled" value="true" ' +
1627 (opts.thumbing_enabled == 'true' ? 'checked' : '') +
1628 '> Enable thumbing</div>' +
1629 '<hr>' +
1630 '<div>4chanX only options</div>' +
1631 '<div><input type="checkbox" name="hide_thumbed_urls" value="true" ' +
1632 (opts.hide_thumbed_urls == 'true' ? 'checked' : '') +
1633 '> Hide thumbed urls</div>' +
1634 '<div><input type="checkbox" name="quoted_link_hover" value="true" ' +
1635 (opts.quoted_link_hover == 'true' ? 'checked' : '') +
1636 '> Quoted url thumb hover</div>' +
1637 '<div><input type="checkbox" name="open_graph" value="true" ' +
1638 (opts.open_graph == 'true' ? 'checked' : '') +
1639 '> Show title and thumbnail for supported links (e.g. vlive)' + '</div>' +
1640 '<div class="trasher_suboption_list">' +
1641 '<div><input type="checkbox" name="embed" value="true" ' +
1642 (opts.embed == 'true' ? 'checked' : '') +
1643 '> Add embed link</div>' +
1644 '</div>' +
1645 '<hr>' +
1646 '<div><input type="checkbox" name="image_hover" value="true" ' +
1647 (opts.image_hover == 'true' ? 'checked' : '') +
1648 '> Image hover</div>' +
1649 '<div><input type="checkbox" name="click_to_embed" value="true" ' +
1650 (opts.click_to_embed == 'true' ? 'checked' : '') +
1651 '> Click to expand</div>' +
1652 '<hr>' +
1653 '<div>When image is expanded:</div>' +
1654 '<div><input type="checkbox" name="fit_width" value="true" ' +
1655 (opts.fit_width == 'true' ? 'checked' : '') +
1656 '> Fit width</div>' +
1657 '<div><input type="checkbox" name="fit_height" value="true" ' +
1658 (opts.fit_height == 'true' ? 'checked' : '') +
1659 '> Fit height</div>' +
1660 '<div class="center"><input type="submit" value="Save" /></div>' +
1661 '</form>'
1662 );
1663
1664 document.body.appendChild(div);
1665
1666 form = div.querySelector('form');
1667 button = form.querySelector('input[type="submit"]');
1668
1669 form.addEventListener('submit', handle_options_form_submit);
1670 // note that we use mousedown instead of click because if another input has
1671 // been focused, the click event will only fire on the second click, which
1672 // is annoying
1673 button.addEventListener('mousedown', handle_options_form_submit);
1674 }
1675
1676 function do_action(action, post) {
1677 if (debug_level >= 2) {
1678 console.log('doing action ' + action + ' to post ' + post);
1679 }
1680
1681 var data = {
1682 action: action,
1683 auth_key: auth_key,
1684 thread: thread,
1685 post: post
1686 };
1687
1688 if (action === 'revoke') {
1689 if (!window.confirm('Are you sure you want to revoke the key ' +
1690 'that trashed this post?')) {
1691 return;
1692 }
1693 }
1694
1695 socket.emit('do_action', data);
1696 }
1697
1698 function do_action_on_ancestor(action, elem) {
1699 /* find post who is an ancestor of this element and trash or revert it */
1700 var post, match, id_regex = new RegExp('^p([0-9]+)');
1701
1702 elem = locate_ancestor_post(elem);
1703
1704 if (elem) {
1705 match = id_regex.exec(elem.id);
1706 if (!match) {
1707 if (debug_level >= 0) {
1708 console.log('failed to determine post number from id: ' + elem.id);
1709 }
1710 return;
1711 }
1712
1713 post = parseInt(match[1]);
1714
1715 if (action === 'trash' && trashed_ids[elem.id]) {
1716 action = 'revert';
1717 }
1718
1719 if ((action === 'trash' && trashed_ids[elem.id]) ||
1720 (action === 'revert' && reverted_ids[elem.id])) {
1721 // already trashed/reverted
1722 return;
1723 }
1724
1725 do_action(action, post);
1726
1727 return true;
1728 }
1729 else {
1730 if (debug_level >= 1) {
1731 console.log('failed to locate post element');
1732 }
1733 return;
1734 }
1735 }
1736
1737 function handle_post_selection(event) {
1738 if (debug_level >= 2) {
1739 console.log('got a click');
1740 }
1741
1742 var board_elem = document.querySelector('.board');
1743
1744 if (!board_elem.classList.contains('prompting_for_post')) {
1745 board_elem.removeEventListener('click', handle_post_selection);
1746 return;
1747 }
1748
1749 // try to perform the action
1750 if (!do_action_on_ancestor(board_elem.dataset.action, event.target)) {
1751 return;
1752 }
1753
1754 if (board_elem.dataset.prompt_until_escape !== 'true') {
1755 stop_prompting_for_post();
1756 }
1757 else {
1758 // we are in selection mode, so reset the prompt timeout (this way we
1759 // don't time out while the user is still performing actions)
1760 if (prompt_timeout_id) {
1761 window.clearTimeout(prompt_timeout_id);
1762 prompt_timeout_id = null;
1763 }
1764 prompt_timeout_id = window.setTimeout(
1765 stop_prompting_for_post, prompt_timeout);
1766 }
1767
1768 // prevent default since we handled the click correctly
1769 event.stopPropagation();
1770 event.preventDefault();
1771 }
1772
1773 function stop_prompting_for_post() {
1774 var board = document.querySelector('.board');
1775
1776 if (board.classList.contains('prompting_for_post')) {
1777 board.classList.remove('prompting_for_post');
1778 board.dataset.action = '';
1779 board.dataset.action_timestamp = '';
1780 board.dataset.prompt_until_escape = '';
1781
1782 board.removeEventListener('click', handle_post_selection);
1783
1784 return true;
1785 }
1786
1787 if (prompt_timeout_id) {
1788 window.clearTimeout(prompt_timeout_id);
1789 prompt_timeout_id = null;
1790 }
1791
1792 return false;
1793 }
1794
1795 function prompt_for_post(action) {
1796 if (debug_level >= 2) {
1797 console.log('prompting for post');
1798 }
1799
1800 var time_since_action, elem_under_cursor,
1801 board = document.querySelector('.board');
1802
1803 if (!board.classList.contains('prompting_for_post')) {
1804 board.classList.add('prompting_for_post');
1805 board.dataset.action = action;
1806 board.dataset.action_timestamp = Date.now();
1807 board.dataset.prompt_until_escape = 'false';
1808
1809 board.addEventListener('click', handle_post_selection);
1810 }
1811 else {
1812 // note that Date.now() returns milliseconds since the epoch
1813 time_since_action = Date.now() - parseFloat(board.dataset.action_timestamp);
1814
1815 /* if we're getting the same shortcut for the second time in a short
1816 * period of time (i.e. it's being double pressed), continue prompting
1817 * until the user presses Escape */
1818 if (board.dataset.prompt_until_escape !== 'true' &&
1819 board.dataset.action == action &&
1820 time_since_action <= 500) {
1821 board.dataset.prompt_until_escape = 'true';
1822
1823 if (debug_level >= 0) {
1824 console.log('Entering trashing mode. ' +
1825 'Please press Escape when you are done.');
1826 }
1827 }
1828 else if (board.dataset.prompt_until_escape === 'true' &&
1829 board.dataset.action == action &&
1830 time_since_action > 500) {
1831 /* already prompting until escape, so perform action for the element
1832 * under the mouse cursor each time the shortcut is pressed again */
1833 elem_under_cursor = board.querySelector(':hover');
1834
1835 if (!elem_under_cursor) {
1836 if (debug_level >= 0) {
1837 console.log("couldn't determine entry under the mouse cursor");
1838 }
1839 return false;
1840 }
1841
1842 do_action_on_ancestor(action, elem_under_cursor);
1843 }
1844 else {
1845 // cancel previous prompt
1846 stop_prompting_for_post();
1847 return false;
1848 }
1849 }
1850
1851 // time out prompt after a while
1852 if (prompt_timeout_id) {
1853 window.clearTimeout(prompt_timeout_id);
1854 prompt_timeout_id = null;
1855 }
1856 prompt_timeout_id = window.setTimeout(
1857 stop_prompting_for_post, prompt_timeout);
1858
1859 return true;
1860 }
1861
1862 function handle_keydown(event) {
1863 if (debug_level >= 2) {
1864 console.log('got key ' + event.key);
1865 }
1866
1867 // don't capture keys sent to an input
1868 if (event.target &&
1869 (event.target.tagName === 'INPUT' ||
1870 event.target.tagName === 'TEXTAREA')) {
1871 return;
1872 }
1873
1874 // only capture unmodified keys
1875 if (event.ctrlKey || event.altKey || event.shiftKey || event.metaKey) {
1876 return;
1877 }
1878
1879 var i;
1880
1881 for (i = 0; i < shortcuts.length; i++) {
1882 if (event.key === shortcuts[i].key || event.keyCode === shortcuts[i].keyCode) {
1883 if (access_level >= shortcuts[i].access_level) {
1884 // only prevent default if the shortcut function returns a success
1885 if (shortcuts[i].func(event)) {
1886 event.preventDefault();
1887 event.stopPropagation();
1888 }
1889 }
1890 break;
1891 }
1892 }
1893 }
1894
1895 function set_up_css() {
1896 var i, elem, rules;
1897
1898 rules = [
1899 // hide trash
1900 '.board:not(.fade_trash)>.thread>.postContainer ' +
1901 '.post.reply.trash>.postMessage,' +
1902 '.board:not(.fade_trash)>.thread>.postContainer ' +
1903 '.post.reply.trash>.file { display: none; }',
1904 '.board:not([data-prompt_until_escape="true"])>.thread>.postContainer ' +
1905 '.post.reply.trash:hover>.postMessage,' +
1906 '.board:not([data-prompt_until_escape="true"])>.thread>.postContainer ' +
1907 '.post.reply.trash:hover>.file ' +
1908 '{ display: block; }',
1909 // fade trash
1910 '.board.fade_trash>.thread>.postContainer .post.reply.trash { opacity: .3; }',
1911 '.board:not([data-prompt_until_escape="true"])>.thread>.postContainer ' +
1912 '.post.reply.trash:hover { opacity: 1; }',
1913 // prompt
1914 '.board.prompting_for_post { cursor: crosshair; }',
1915 // preferences window
1916 'div#trasher_options { position: fixed; top: 1em; left: 1em; ' +
1917 'background: #fff; color: #000; padding: 0.3em; line-height: 1.5em }',
1918 'div#trasher_options .center { text-align: center; }',
1919 'div#trasher_options form { margin-top: 0.3em; padding: 0.2em; ' +
1920 'border: 1px solid #aaa; }',
1921 '.trasher_suboption_list>div { position: relative; padding-left: 1.4em; }',
1922 // thumbs
1923 '.thumbs { margin: 3px 20px 5px; min-height: 130px; }',
1924 '.thumbs:not(.expanded) { max-width: calc(100vw - 90px); }',
1925 '.thumbs.no_file { float: left; }',
1926 '.thumb_frame { display: inline-block; min-width: 125px; ' +
1927 'min-height: 125px; vertical-align: top; margin-bottom: 5px; }',
1928 '.thumb_frame:not(:last-child) { margin-right: 20px; }',
1929 '.thumb>img:not(.expanded) { width: 125px; vertical-align: middle; }',
1930 '.thumb>img.expanded.fit_width { max-width: calc(100vw - 70px); }',
1931 '.thumb>img.expanded.fit_height { max-height: calc(100vh - 35px); }',
1932 '.hide_thumbed { display: none; }',
1933 '.hover_container { text-align: center; position: fixed; top: 0px; ' +
1934 'left: 0px; height: 100vh; width: 100vw; z-index: 1; display: flex; ' +
1935 'align-items: center; justify-content:center; cursor: initial; }',
1936 '.hover_image { max-height: 100vh; max-width: 100vw; margin: auto; }',
1937 '.hover_image.loaded { cursor: none; }',
1938 '.hover_image.loaded.stuck { cursor: initial; }',
1939 // open graph
1940 '.og_image { max-height: 480px; max-width: 480px; }',
1941 'a.linkify.og_link[href*="://www.vlive.tv/"]::before { ' +
1942 'content: ""; background: transparent url("data:image/png;base64,' +
1943 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAACL0lEQVQ4y6WTzUtUYRTG' +
1944 'f+fed+6dSW3yK4nCKSnMMMnEgZSQwD4oXEQuDBPrD2jVsk2blkHQqlUE0qY2bXJRkRBU' +
1945 'RCJiQZkfKdqENFmRjPfOve/boplxbrbrrN7F+T3vw3POEYD+nKkFLgPDQCNg8e/SwCJw' +
1946 'F7j1ICFZKcA3gAuAjRRwU2i3ACm8TUkoBEaAK6rw82ARNt9/Erx7j7W9Dnt3I+GHGcza' +
1947 'GnZrC1KxpShiF5hPChgCFACez/qde4Qzc4htY7e2EEy+BW1Q6cPEhwdApOhCAUMWkII/' +
1948 'Ns2vNfT8AvFLg9j79+E/fESspxv3XB/h9EeM7/+dScoq2AEDUlmBVG9DZ7PYh1qRqipU' +
1949 'Wws6k4FQg+cVu4tlR9OOO9jNewknprDq61BdneDG0StZ0IbczdsEL8dB6xISFTBgPA/9' +
1950 'ZYVgfBKroZ78s+dYDfU4Z46D77M+ch+99LlERgXyAXpxiVhvD07fKVS6A3XwALgO5tsq' +
1951 '7sBZJJHA5APKk9xU4sTA98k/HiOcX8BuSqFXfxAuZ5C4i7WjobQTUQdKIckk4eIyUpfE' +
1952 'Od2Lc+IYsaNdqPY2wokpnL6TSEUiIhCWJxLrThO8mSA/9gqpqUEdSSNVleRfvEZ1tqM6' +
1953 '2iIbKf05Mws0bWx7iD/6FH/0CZLcijgO+msWe0+K+MXzSG11ucCc9OfMNeBqNA+DXsoQ' +
1954 'Ts9iPA9r105UcxO4bjkcANc3H1P5gGVjvOhIWqVjkv8959+rd84DbwWx+AAAAABJRU5E' +
1955 'rkJggg==") center left no-repeat; padding-left: 18px; }'
1956 ];
1957
1958 elem = document.createElement('style');
1959 document.head.appendChild(elem);
1960
1961 for (i = 0; i < rules.length; i++) {
1962 elem.sheet.insertRule(rules[i], i);
1963 }
1964 }
1965
1966 function set_up_options() {
1967 var saved_opts, key, old_value;
1968
1969 auth_key = GM_getValue('auth_key', null);
1970
1971 saved_opts = JSON.parse(GM_getValue('opts', '{}'));
1972
1973 for (key in saved_opts) {
1974 if (opts.hasOwnProperty(key)) {
1975 opts[key] = saved_opts[key];
1976 }
1977 }
1978
1979 // check for old stored value and convert it
1980 old_value = GM_getValue('trash_mode', null);
1981 if (old_value) {
1982 opts.trash_mode = old_value;
1983 GM_setValue('opts', JSON.stringify(opts));
1984 GM_deleteValue('trash_mode');
1985 }
1986
1987 apply_options(true);
1988 }
1989
1990 function read_options(form) {
1991 var elem, key, value,
1992 changed = false,
1993 non_bool_opts = ['trash_mode'];
1994
1995 // trash mode
1996 elem = form.querySelector('input[name="trash_mode"]:checked');
1997 if (elem && ['hide', 'fade'].includes(elem.value) &&
1998 elem.value != opts.trash_mode) {
1999 opts.trash_mode = elem.value;
2000 changed = true;
2001 }
2002
2003 // checkboxes
2004 for (key in opts) {
2005 if (non_bool_opts.includes(key)) { continue; }
2006
2007 elem = form.querySelector('input[name="' + key + '"]');
2008 if (!elem) { continue; }
2009
2010 value = elem.checked ? 'true' : 'false';
2011
2012 if (opts[key] != value) {
2013 opts[key] = value;
2014 changed = true;
2015 }
2016 }
2017
2018 return changed;
2019 }
2020
2021 function clean_up() {
2022 if (debug_level >= 1) {
2023 console.log('cleaning up');
2024 }
2025
2026 var i, elem, elems;
2027
2028 // trash
2029 elems = document.querySelectorAll('.post.trash');
2030 for (i = 0; i < elems.length; i++) {
2031 elems[i].classList.remove('trash');
2032 }
2033
2034 elems = document.querySelectorAll(
2035 '.post[data-hidden_by_post]:not([data-hidden_by_post=""])');
2036 for (i = 0; i < elems.length; i++) {
2037 elems[i].dataset.hidden_by_post = '';
2038 }
2039
2040 // thumbs
2041 elems = document.querySelectorAll('.thumbs');
2042 for (i = 0; i < elems.length; i++) {
2043 elems[i].parentNode.removeChild(elems[i]);
2044 }
2045
2046 elems = document.querySelectorAll('.hide_thumbed');
2047 for (i = 0; i < elems.length; i++) {
2048 elems[i].classList.remove('hide_thumbed');
2049 }
2050
2051 // quoted links
2052 elems = document.querySelectorAll('.quoted_link');
2053 for (i = 0; i < elems.length; i++) {
2054 elems[i].removeAttribute('data-thumb_url');
2055 elems[i].removeAttribute('title');
2056 elems[i].classList.remove('quoted_link');
2057 elems[i].removeEventListener('mouseenter', handle_quoted_link_mouseenter);
2058 elems[i].removeEventListener('mouseleave', handle_quoted_link_mouseleave);
2059 }
2060
2061 // open graph links
2062 elems = document.querySelectorAll('.og_link');
2063 for (i = 0; i < elems.length; i++) {
2064 elems[i].innerHTML = '';
2065 elems[i].appendChild(document.createTextNode(elems[i].href));
2066 elems[i].removeEventListener('mouseenter', handle_og_link_mouseenter);
2067 elems[i].removeEventListener('mouseleave', handle_og_link_mouseleave);
2068 elems[i].classList.remove('og_link');
2069
2070 // destroy embedder, when applicable
2071 destroy_embedder(elems[i]);
2072 }
2073
2074 // board element
2075 elem = document.querySelector('.board');
2076 elem.classList.remove('prompting_for_post');
2077 elem.dataset.action = '';
2078 elem.dataset.action_timestamp = '';
2079 elem.dataset.prompt_until_escape = '';
2080 }
2081
2082 function reinsert_inline_posts_thumbs() {
2083 var i, inline_posts, post_id, thumbs_info;
2084
2085 // check for inlined posts that should be thumbed and thumb them
2086 inline_posts = document.querySelectorAll('.inline .post.reply');
2087 for (i = 0; i < inline_posts.length; i++) {
2088 post_id = inline_posts[i].id;
2089
2090 // inline posts' ids use the format "<original post id>_<i>"
2091 // so we strip the "_<i>" to check if the post has thumbs
2092 thumbs_info = thumbed_ids[post_id.replace(/_\d+$/, '')];
2093
2094 if (post_id && thumbs_info) {
2095 insert_thumbs(post_id, thumbs_info, true);
2096 }
2097 }
2098 }
2099
2100 function apply_options(first_run) {
2101 if (debug_level >= 1) {
2102 console.log((first_run ? '' : 're') + 'applying options');
2103 }
2104
2105 if (!first_run) {
2106 // clean up whatever we might have done to the page beforehand
2107 clean_up();
2108 }
2109
2110 var data, post_id;
2111
2112 // trash mode
2113 if (opts.trash_mode == 'fade') {
2114 document.querySelector('.board').classList.add('fade_trash');
2115 }
2116 else {
2117 document.querySelector('.board').classList.remove('fade_trash');
2118 }
2119
2120 if (!first_run) {
2121 if (debug_level >= 1) {
2122 console.log('reapplying trash and thumbs');
2123 }
2124
2125 // reapply trash and thumbs
2126 data = {
2127 thread: thread,
2128 trashed: [],
2129 thumbs: []
2130 };
2131
2132 for (post_id in trashed_ids) {
2133 if (trashed_ids[post_id]) {
2134 data.trashed.push(post_id);
2135 }
2136 }
2137
2138 for (post_id in thumbed_ids) {
2139 data.thumbs.push([post_id, thumbed_ids[post_id]]);
2140 }
2141
2142 handle_trash(data);
2143 handle_thumbs(data);
2144
2145 // check for inlined posts that should be thumbed
2146 reinsert_inline_posts_thumbs();
2147
2148 // check for open graph links that should be enriched again
2149 reenrich_og_links();
2150
2151 // take the opportunity to check the last post
2152 check_last_post();
2153 }
2154 }
2155
2156 function do_init() {
2157 console.log("do_init", auth_key)
2158 if (init_started) {
2159 if (init_interval) {
2160 window.clearInterval(init_interval);
2161 init_interval = null;
2162 }
2163 return;
2164 }
2165
2166 socket.emit('init', { thread: thread, auth_key: auth_key });
2167 }
2168
2169 function set_up_socket() {
2170 if (!(board && thread)) { return; }
2171
2172 if (debug_level >= 1) {
2173 console.log('connecting');
2174 }
2175
2176 // connecting socket
2177 socket = io(server_url + '/' + board);
2178
2179 socket.on('init', handle_init);
2180 socket.on('reinit', handle_reinit);
2181 socket.on('reconnect', handle_reconnect);
2182 socket.on('trash', handle_trash);
2183 socket.on('revert', handle_revert_response);
2184 // note that new thumbs are transmitted in last_post responses and
2185 // therefore are handled by handle_last_post()
2186 socket.on('last_post', handle_last_post);
2187 socket.on('open_graph_result', handle_open_graph_url_result);
2188 socket.on('open_graph_results', handle_open_graph_results);
2189
2190 socket.on('connect', function () {
2191 if (init_started) { return; }
2192
2193 if (debug_level >= 1) {
2194 console.log('requesting init data');
2195 }
2196 init_interval = window.setInterval(do_init, 100000);
2197 do_init();
2198 });
2199 }
2200
2201 function set_up_shortcuts() {
2202 document.addEventListener('keydown', handle_keydown);
2203 }
2204
2205 function start_mutation_observer() {
2206 if (!observer) { return; }
2207
2208 if (debug_level >= 2) {
2209 console.log('starting mutation observer');
2210 }
2211
2212 var thread_elem = document.querySelector('.board>.thread');
2213 observer.observe(thread_elem, { childList: true, subtree: true });
2214 }
2215
2216 /*
2217 function stop_mutation_observer() {
2218 if (!observer) { return; }
2219 observer.disconnect();
2220 }
2221 */
2222
2223 function set_up_mutation_observer() {
2224 observer = new MutationObserver(handle_new_posts);
2225 // note that the observer will only start observing after the init is done
2226 }
2227
2228 function initial_setup() {
2229 var kpg_regex = /k.?pop general/i;
2230
2231 extract_board_thread();
2232
2233 if (!(title && kpg_regex.test(title))) {
2234 if (debug_level >= 1) {
2235 console.log('thread is not a KPG; got title ' + title);
2236 }
2237 }
2238
2239 // check if 4chanX is running
2240 is_4chanx_running = false;
2241 if (document.getElementById('header-bar')) {
2242 is_4chanx_running = true;
2243 }
2244
2245 console.log("got this far")
2246
2247 set_up_css();
2248 set_up_options();
2249 set_up_socket();
2250 set_up_shortcuts();
2251 set_up_mutation_observer();
2252
2253 if (debug_level >= 1) {
2254 console.log('set up done');
2255 }
2256 }
2257
2258 window.addEventListener('load', initial_setup);
2259 if (debug_level >= 1) {
2260 console.log('script loaded');
2261 }
2262
2263}());