Juick tweaks

Feature testing

  1. // ==UserScript==
  2. // @name Juick tweaks
  3. // @namespace ForJuickCom
  4. // @description Feature testing
  5. // @match *://juick.com/*
  6. // @author Killy
  7. // @version 2.21.2
  8. // @date 2016.09.02 - 2022.08.18
  9. // @license MIT
  10. // @run-at document-end
  11. // @grant GM_xmlhttpRequest
  12. // @grant GM_addStyle
  13. // @grant GM_getValue
  14. // @grant GM_setValue
  15. // @grant GM_deleteValue
  16. // @grant GM_listValues
  17. // @grant GM_info
  18. // @connect api.juick.com
  19. // @connect twitter.com
  20. // @connect bandcamp.com
  21. // @connect mixcloud.com
  22. // @connect flickr.com
  23. // @connect flic.kr
  24. // @connect deviantart.com
  25. // @connect slideshare.net
  26. // @connect gist.github.com
  27. // @connect codepen.io
  28. // @connect arxiv.org
  29. // @connect pixiv.net
  30. // @connect konachan.net
  31. // @connect yande.re
  32. // @connect gelbooru.com
  33. // @connect safebooru.org
  34. // @connect danbooru.donmai.us
  35. // @connect safebooru.donmai.us
  36. // @connect anime-pictures.net
  37. // @connect api.imgur.com
  38. // @connect tumblr.com
  39. // @connect reddit.com
  40. // @connect wordpress.com
  41. // @connect lenta.ru
  42. // @connect meduza.io
  43. // @connect rbc.ru
  44. // @connect tjournal.ru
  45. // @connect *.newsru.com
  46. // @connect *.itar-tass.com
  47. // @connect tass.ru
  48. // @connect rublacklist.net
  49. // @connect mk.ru
  50. // @connect gazeta.ru
  51. // @connect republic.ru
  52. // @connect bash.im
  53. // @connect ixbt.com
  54. // @connect techxplore.com
  55. // @connect medicalxpress.com
  56. // @connect phys.org
  57. // @connect techcrunch.com
  58. // @connect bbc.com
  59. // @connect nplus1.ru
  60. // @connect elementy.ru
  61. // @connect news.tut.by
  62. // @connect pikabu.ru
  63. // @connect imdb.com
  64. // @connect mastodon.social
  65. // @connect mastodonsocial.ru
  66. // @connect *
  67. // ==/UserScript==
  68.  
  69.  
  70. // #region === Pages and elements ===========================================================================
  71.  
  72. const content = document.getElementById('content');
  73. const isPost = content && content.hasAttribute('data-mid');
  74. const isFeed = document.querySelectorAll('#content article[data-mid]').length > 0;
  75. const isCommonFeed = /^(?:https?:)?\/\/[a-z0-9.:]+\/(?:$|tag|#post|\?.*show=(?:all|photos))/i.test(window.location.href);
  76. const isAll = /\bshow=all\b/i.test(window.location.search);
  77. const isNewPostPage = window.location.pathname.endsWith('/post') && document.querySelector('textarea.newmessage');
  78. const isTagsPage = window.location.pathname.endsWith('/tags');
  79. const isSettingsPage = window.location.pathname.endsWith('/settings');
  80. const isUserColumn = !!(document.querySelector('aside#column div#ustats'));
  81. const isUsersTable = !!(document.querySelector('#content > div.users'));
  82. const hasContentArticle = !!(document.querySelector('#content article'));
  83.  
  84. // #endregion
  85.  
  86.  
  87. // #region === Userscript features ==========================================================================
  88.  
  89. addStyle();
  90.  
  91. const userscriptFeatures = [
  92. {
  93. name: 'Форма нового сообщения в ленте (как старый /#post)',
  94. id: 'enable_post_sharp',
  95. enabledByDefault: true,
  96. pageMatch: (isFeed && isUserColumn) || isAll,
  97. fun: addPostSharpFormUser
  98. },
  99. {
  100. name: 'Сортировка и цветовое кодирование тегов на странице нового поста (/post)',
  101. id: 'enable_tags_on_new_post_page',
  102. enabledByDefault: true,
  103. pageMatch: isNewPostPage,
  104. fun: easyTagsUnderNewMessageForm
  105. },
  106. {
  107. name: 'Сортировка и цветовое кодирование тегов на странице /user/tags',
  108. id: 'enable_tags_page_coloring',
  109. enabledByDefault: true,
  110. pageMatch: isTagsPage,
  111. fun: sortTagsPage
  112. },
  113. {
  114. name: 'Min-width для тегов',
  115. id: 'enable_tags_min_width',
  116. enabledByDefault: true
  117. },
  118. {
  119. name: 'Копирование ссылок на посты/комментарии',
  120. id: 'enable_comment_share_menu',
  121. enabledByDefault: true,
  122. pageMatch: isPost,
  123. fun: addCommentShareMenu
  124. },
  125. {
  126. name: 'Ссылки для удаления комментариев',
  127. id: 'enable_comment_removal_links',
  128. enabledByDefault: true,
  129. pageMatch: isPost,
  130. fun: addCommentRemovalLinks
  131. },
  132. {
  133. name: 'Ссылка для редактирования тегов поста',
  134. id: 'enable_tags_editing_link',
  135. enabledByDefault: true,
  136. pageMatch: isPost,
  137. fun: addTagEditingLinkUnderPost
  138. },
  139. {
  140. name: 'Большая аватарка в левой колонке',
  141. id: 'enable_big_avatar',
  142. enabledByDefault: true,
  143. pageMatch: isUserColumn,
  144. fun: biggerAvatar
  145. },
  146. {
  147. name: 'Ссылки для перехода к постам пользователя за определённый год',
  148. id: 'enable_year_links',
  149. enabledByDefault: true,
  150. pageMatch: isUserColumn,
  151. fun: addYearLinks
  152. },
  153. {
  154. name: 'Сортировка подписок/подписчиков по дате последнего сообщения',
  155. id: 'enable_users_sorting',
  156. enabledByDefault: true,
  157. pageMatch: isUsersTable,
  158. fun: addUsersSortingButton
  159. },
  160. {
  161. name: 'Статистика рекомендаций',
  162. id: 'enable_irecommend',
  163. enabledByDefault: true,
  164. pageMatch: isUserColumn,
  165. fun: addIRecommendLink
  166. },
  167. {
  168. name: 'Упоминания (ссылка на поиск)',
  169. id: 'enable_mentions_search',
  170. enabledByDefault: true,
  171. pageMatch: isUserColumn,
  172. fun: addMentionsLink
  173. },
  174. {
  175. name: 'Посты и комментарии, на которые нельзя ответить, — более бледные',
  176. id: 'enable_unrepliable_styling',
  177. enabledByDefault: true,
  178. pageMatch: isPost || isFeed,
  179. fun: () => { if (isPost) { checkReplyPost(); } else { checkReplyArticles(); } }
  180. },
  181. {
  182. name: 'Для readonly поста отображать виртуальный тег (только на странице поста)',
  183. id: 'enable_mark_readonly_post',
  184. enabledByDefault: true,
  185. pageMatch: isPost,
  186. fun: markReadonlyPost
  187. },
  188. {
  189. name: 'Показывать комментарии при наведении на ссылку "в ответ на /x"',
  190. id: 'enable_move_comment_into_view',
  191. enabledByDefault: true,
  192. pageMatch: isPost,
  193. fun: bringCommentsIntoViewOnHover
  194. },
  195. {
  196. name: 'Стрелочки ("↓")',
  197. id: 'enable_arrows',
  198. enabledByDefault: true
  199. },
  200. {
  201. name: 'Take care of NSFW tagged posts in feed',
  202. id: 'enable_mark_nsfw_posts_in_feed',
  203. enabledByDefault: true,
  204. pageMatch: isFeed,
  205. fun: markNsfwPostsInFeed
  206. },
  207. {
  208. name: 'Сбросить стили для тега *code. Уменьшить шрифт взамен',
  209. id: 'unset_code_style',
  210. enabledByDefault: false
  211. },
  212. {
  213. name: 'Сворачивать длинные посты',
  214. id: 'enable_long_message_folding',
  215. enabledByDefault: true,
  216. pageMatch: isFeed,
  217. fun: limitArticlesHeight
  218. },
  219. {
  220. id: 'filter_comments_too', // not in the main feature list
  221. enabledByDefault: false,
  222. pageMatch: isPost,
  223. fun: filterPostComments
  224. },
  225. {
  226. pageMatch: isPost,
  227. fun: embedLinksToPost
  228. },
  229. {
  230. pageMatch: isFeed,
  231. fun: embedLinksToArticles
  232. },
  233. {
  234. pageMatch: isFeed && isCommonFeed,
  235. fun: filterArticles
  236. },
  237. {
  238. pageMatch: isSettingsPage,
  239. fun: addTweaksSettingsButton
  240. },
  241. {
  242. pageMatch: hasContentArticle,
  243. fun: addTweaksSettingsFooterLink
  244. }
  245. ];
  246.  
  247. userscriptFeatures.forEach(feature => {
  248. let runnable = feature.pageMatch && !!feature.fun;
  249. let enabled = !feature.id || GM_getValue(feature.id, feature.enabledByDefault);
  250. if (runnable && enabled) {
  251. try { feature.fun(); } catch (e) {
  252. console.warn(`Failed to run ${feature.fun.name}()`);
  253. console.warn(e);
  254. }
  255. }
  256. });
  257.  
  258. // #endregion
  259.  
  260.  
  261. // #region === Helpers ======================================================================================
  262.  
  263. Object.values = Object.values || (obj => Object.keys(obj).map(key => obj[key]));
  264.  
  265. String.prototype.count = function(s1) {
  266. return (this.length - this.replace(new RegExp(s1, 'g'), '').length) / s1.length;
  267. };
  268.  
  269. Number.prototype.pad = function(size=2) {
  270. let s = String(this);
  271. while (s.length < size) { s = '0' + s; }
  272. return s;
  273. };
  274.  
  275. function longest(arr) {
  276. return arr.reduce((a,b) => (!a) ? b : (!b || a.length > b.length) ? a : b);
  277. }
  278.  
  279. function intersect(a, b) {
  280. if (a.length > b.length) { [a, b] = [b, a]; } // filter shorter array
  281. return a.filter(item => (b.indexOf(item) !== -1));
  282. }
  283.  
  284. function fitToBounds(w, h, maxW, maxH) {
  285. let r = h / w;
  286. let w1 = ((h > maxH) ? maxH : h) / r;
  287. let w2 = ((w1 > maxW) ? maxW : w1);
  288. let h2 = w2 * r;
  289. return { w: w2, h: h2 };
  290. }
  291.  
  292. function insertAfter(newNode, referenceNode) {
  293. referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
  294. }
  295.  
  296. function setContent(containerNode, ...newNodes) {
  297. removeAllFrom(containerNode);
  298. newNodes.forEach(n => containerNode.appendChild(n));
  299. return containerNode;
  300. }
  301.  
  302. function removeAllFrom(fromNode) {
  303. for (let c; c = fromNode.lastChild; ) { fromNode.removeChild(c); }
  304. }
  305.  
  306. function parseRgbColor(colorStr, fallback=[0,0,0]){
  307. let [, r, g, b] = colorStr.replace(/ /g, '').match(/^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/i) || [, ...fallback];
  308. return [ +r, +g, +b ];
  309. }
  310.  
  311. function getAllMatchesAndCaptureGroups(re, str) {
  312. let results = [], result;
  313. while ((result = re.exec(str)) !== null) { results.push(Array.from(result)); }
  314. return results;
  315. }
  316.  
  317. function htmlDecode(str) {
  318. let doc = new DOMParser().parseFromString(str, 'text/html');
  319. return doc.documentElement.textContent;
  320. }
  321.  
  322. function htmlEscape(html) {
  323. let textarea = document.createElement('textarea');
  324. textarea.textContent = html;
  325. return textarea.innerHTML;
  326. }
  327.  
  328. function escapeRegExp(str) {
  329. return str.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
  330. }
  331.  
  332. function naiveEllipsis(str, len, ellStr='...') {
  333. let ellLen = ellStr.length;
  334. if (str.length <= len) { return str; }
  335. let half = Math.floor((len - ellLen) / 2);
  336. let left = str.substring(0, half);
  337. let right = str.substring(str.length - (len - half - ellLen));
  338. return '' + left + ellStr + right;
  339. }
  340.  
  341. function naiveEllipsisRight(str, len, ellStr='...') {
  342. let ellLen = ellStr.length;
  343. return (str.length <= len) ? str : str.substring(0, len - ellLen) + ellStr;
  344. }
  345.  
  346. function wrapIntoTag(node, tagName, className=undefined) {
  347. let tag = document.createElement(tagName);
  348. if (className) { tag.className = className; }
  349. tag.appendChild(node);
  350. return tag;
  351. }
  352.  
  353. function randomId() {
  354. return Math.random().toString(36).substr(2);
  355. }
  356.  
  357. function matchWildcard(str, wildcard) {
  358. let ww = wildcard.split('*');
  359. let startFrom = 0;
  360. for (let i = 0; i < ww.length; i++) {
  361. let w = ww[i];
  362. if (w == '') { continue; }
  363. let wloc = str.indexOf(w, startFrom);
  364. if (wloc == -1) { return false; }
  365. let wend = wloc + w.length;
  366. let headCondition = (i > 0) || (wloc == 0);
  367. let tailCondition = (i < ww.length - 1) || ((i > 0) ? str.endsWith(w) : (str.substr(wloc) == w));
  368. if (!headCondition || !tailCondition) { return false; }
  369. startFrom = wend;
  370. }
  371. return true;
  372. }
  373.  
  374. // rules :: [{pr: number, re: RegExp, with: string}]
  375. // rules :: [{pr: number, re: RegExp, with: Function}]
  376. // rules :: [{pr: number, re: RegExp, brackets: true, with: [string, string]}]
  377. // rules :: [{pr: number, re: RegExp, brackets: true, with: [string, string, Function]}]
  378. function formatText(txt, rules) {
  379. let idCounter = 0;
  380. function nextId() { return idCounter++; }
  381. function ft(txt, rules) {
  382. let matches = rules.map(r => { r.re.lastIndex = 0; return [r, r.re.exec(txt)]; })
  383. .filter(([,m]) => m !== null)
  384. .sort(([r1,m1],[r2,m2]) => (r1.pr - r2.pr) || (m1.index - m2.index));
  385. if (matches && matches.length > 0) {
  386. let [rule, match] = matches[0];
  387. let subsequentRules = rules.filter(r => r.pr >= rule.pr);
  388. let idStr = `<>(${nextId()})<>`;
  389. let outerStr = txt.substring(0, match.index) + idStr + txt.substring(rule.re.lastIndex);
  390. let innerStr = (rule.brackets)
  391. ? (() => { let [l ,r ,f] = rule.with; return l + ft((f ? f(match[1]) : match[1]), subsequentRules) + r; })()
  392. : match[0].replace(rule.re, rule.with);
  393. return ft(outerStr, subsequentRules).replace(idStr, innerStr);
  394. }
  395. return txt;
  396. }
  397. return ft(htmlEscape(txt), rules); // idStr above relies on the fact the text is escaped
  398. }
  399.  
  400. function getProto() {
  401. return (location.protocol == 'http:') ? 'http:' : 'https:';
  402. }
  403.  
  404. function setProto(url, proto) {
  405. return url.replace(
  406. /^(https?:)?(?=\/\/)/i,
  407. proto ? proto : getProto()
  408. );
  409. }
  410.  
  411. function unsetProto(url) {
  412. return url.replace(/^(https?:)?(?=\/\/)/i, '');
  413. }
  414.  
  415. function fixWwwLink(url) {
  416. return url.replace(/^(?!([a-z]+:)?\/\/)/i, '//');
  417. }
  418.  
  419. function waitAndRunAsync(test, count, tick=100, successCallback, failCallback) {
  420. return new Promise((resolve, reject) => {
  421. function r(c){
  422. if (test()) { resolve(successCallback()); } else {
  423. if (c && (c > 0)) {
  424. setTimeout(() => r(c-1), tick);
  425. } else {
  426. reject(failCallback());
  427. }
  428. }
  429. }
  430. r(count);
  431. });
  432. }
  433.  
  434. // predicates :: [{ msg: Response -> string, test: Response -> bool, permanent: Response -> bool }]
  435. function xhrGetAsync(url, timeout=3000, predicates=undefined, method='GET') {
  436. predicates = predicates || [
  437. {
  438. msg: response => (response.statusText ? `${response.status} - ${response.statusText}` : `${response.status}`),
  439. test: response => response.status != 200,
  440. permanent: response => !([408, 500, 503].includes(response.status))
  441. }
  442. ];
  443. return new Promise(function(resolve, reject) {
  444. GM_xmlhttpRequest({
  445. method: method,
  446. url: url,
  447. timeout: timeout,
  448. onload: function(response) {
  449. let match = predicates && predicates.find(p => p.test(response));
  450. if (!match) {
  451. resolve(response);
  452. } else {
  453. reject({
  454. reason: match.msg(response),
  455. response: response,
  456. permanent: (match.permanent) ? match.permanent(response) : false,
  457. url: url
  458. });
  459. }
  460. },
  461. ontimeout: function(response) { reject({ reason: 'timeout', response: response, permanent: false, url: url }); },
  462. onerror: function(response) { reject({ reason: 'unknown error', response: response, permanent: false, url: url }); }
  463. });
  464. });
  465. }
  466.  
  467. function xhrFirstResponse(urls, timeout) {
  468. return urls.reduce(
  469. (p, url) => p.catch(e => xhrGetAsync(url, timeout)),
  470. Promise.reject({reason: 'init'})
  471. );
  472. }
  473.  
  474. function computeStyle(newElement) {
  475. if (document.body.contains(newElement)) {
  476. return getComputedStyle(newElement);
  477. }
  478. document.body.appendChild(newElement);
  479. let style = getComputedStyle(newElement);
  480. setTimeout(function() {
  481. document.body.removeChild(newElement);
  482. }, 1); // let getComputedStyle to do the job
  483. return style;
  484. }
  485.  
  486. function autosize(el) {
  487. let offset = (!window.opera)
  488. ? (el.offsetHeight - el.clientHeight)
  489. : (el.offsetHeight + parseInt(window.getComputedStyle(el, null).getPropertyValue('border-top-width')));
  490. let resize = function (el) {
  491. el.style.height = 'auto';
  492. el.style.height = (el.scrollHeight + offset) + 'px';
  493. };
  494. el.addEventListener('input', () => resize(el));
  495. }
  496.  
  497. function selectAndCopyElementContents(el, deselect=false) {
  498. if (window.getSelection && document.createRange) {
  499. let range = document.createRange();
  500. range.selectNodeContents(el);
  501. let sel = window.getSelection();
  502. sel.removeAllRanges();
  503. sel.addRange(range);
  504. try {
  505. let successful = document.execCommand('copy');
  506. if (!successful) { console.log('Copy command is not available or not enabled.'); }
  507. if (deselect) { sel.removeAllRanges(); }
  508. return successful;
  509. } catch (err) {
  510. console.log(err);
  511. return false;
  512. }
  513. }
  514. return false;
  515. }
  516.  
  517. function keyboardClickable(el) {
  518. el.addEventListener('keydown', e => {
  519. if ((e.which === 13) || (e.which === 32)) { // 13 = Return, 32 = Space
  520. e.preventDefault();
  521. el.click();
  522. }
  523. });
  524. }
  525.  
  526. function onClickOutsideOnce(element, callback) {
  527. const outsideClickListener = event => {
  528. if (!element.contains(event.target)) {
  529. callback();
  530. removeClickListener();
  531. }
  532. };
  533. const removeClickListener = () => {
  534. document.removeEventListener('click', outsideClickListener);
  535. };
  536. document.addEventListener('click', outsideClickListener);
  537. }
  538.  
  539. // #endregion
  540.  
  541.  
  542. // #region === Function definitions =========================================================================
  543.  
  544. function svgIconHtml(name) {
  545. return /*html*/`<div class="icon icon--ei-${name} icon--s"><svg class="icon__cnt"><use xlink:href="/sprite.svg#ei-${name}-icon"></use></svg></div>`;
  546. }
  547.  
  548. function getMyAccountAsync() {
  549. if (getMyAccountAsync[0]) {
  550. return Promise.resolve(getMyAccountAsync[0]);
  551. } else {
  552. let hash = document.body.getAttribute('data-hash');
  553. if (!hash) { return Promise.reject('not logged in'); }
  554. return xhrGetAsync(setProto('//api.juick.com/me?hash=' + hash)).then(response => {
  555. let account = JSON.parse(response.responseText);
  556. getMyAccountAsync[0] = account;
  557. return account;
  558. });
  559. }
  560. }
  561.  
  562. function getMyUserNameAsync() {
  563. return getMyAccountAsync().then(account => account.uname);
  564. }
  565.  
  566. function getColumnUserName() {
  567. let columnUserIdLink = document.querySelector('#column #ctitle > a');
  568. if (columnUserIdLink) { return columnUserIdLink.textContent.trim(); }
  569. let headerUserIdLink = document.querySelector('#header #ctitle > a');
  570. if (headerUserIdLink) { return headerUserIdLink.textContent.trim(); }
  571. return null;
  572. }
  573.  
  574. function getColumnUid() {
  575. let columnAvatar = document.querySelector('#column #ctitle > a > img');
  576. if (columnAvatar) { return columnAvatar.src.match(/\/i\/a\/(\d+)-[0-9a-fA-F]+\./i)[1]; }
  577. let headerAvatar = document.querySelector('#header #ctitle > a > img');
  578. if (headerAvatar) { return headerAvatar.src.match(/\/i\/a\/(\d+)-[0-9a-fA-F]+\./i)[1]; }
  579. return null;
  580. }
  581.  
  582. function getPostUserName(element) {
  583. let avatar = element.querySelector('div.msg-avatar > a > img');
  584. return (avatar) ? avatar.alt : null;
  585. }
  586.  
  587. function getPostUid(element) {
  588. let avatar = element.querySelector('div.msg-avatar > a > img');
  589. return (avatar) ? avatar.src.match(/\/i\/a\/(\d+)-[0-9a-fA-F]+\./i)[1] : null;
  590. }
  591.  
  592. function markNsfwPostsInFeed() {
  593. [].forEach.call(document.querySelectorAll('#content article[data-mid]'), function(article) {
  594. let tagsDiv = article.querySelector('.msg-tags');
  595. let isNsfw = tagsDiv && Array.from(tagsDiv.children).some(t => t.textContent.toUpperCase() == 'NSFW');
  596. if (isNsfw) { article.classList.add('nsfw'); }
  597. });
  598. }
  599.  
  600. function addTagEditingLinkUnderPost() {
  601. let post = document.querySelector('#content .msgthread');
  602. let postToolbar = post.querySelector('nav.l');
  603. let canEdit = !!postToolbar.querySelector('a[href*="?body=D+"]');
  604. let mid = document.getElementById('content').getAttribute('data-mid');
  605. if (canEdit) {
  606. postToolbar.insertAdjacentHTML(
  607. 'beforeend',
  608. `<a href="/post?body=%23${mid}+%2ATag" class="msg-button">${svgIconHtml('tag')}<span>&nbsp;Tags</span></a>`
  609. );
  610. }
  611. }
  612.  
  613. function addCommentRemovalLinks() {
  614. getMyUserNameAsync().then(uname => {
  615. let commentsBlock = document.querySelector('ul#replies');
  616. if (commentsBlock && uname) {
  617. [].forEach.call(commentsBlock.children, linode => {
  618. let postUserAvatar = linode.querySelector('div.msg-avatar > a > img');
  619. if (postUserAvatar) {
  620. let postUserId = postUserAvatar.alt;
  621. if (postUserId == uname) {
  622. let linksBlock = linode.querySelector('div.msg-links');
  623. let commentLink = linode.querySelector('div.msg-ts > a');
  624. let mid = /\/(\d+)$/.exec(commentLink.pathname)[1];
  625. let rid = commentLink.hash.replace('#','');
  626.  
  627. linksBlock.insertAdjacentHTML('beforeend', /*html*/`
  628. <div class="clickPopup">
  629. <div class="clickTarget" tabindex="0">${svgIconHtml('close')}&nbsp;Delete</div>
  630. <div class="clickContainer">
  631. <p>Click&nbsp;to&nbsp;confirm:</p>
  632. <a href="#confirm_delete" class="confirmationItem">Confirm&nbsp;delete</a>
  633. </div>
  634. </div>
  635. `);
  636. let clickPopup = linksBlock.querySelector('.clickPopup');
  637. let clickTarget = linksBlock.querySelector('.clickTarget');
  638. let confirmationItem = linksBlock.querySelector('.confirmationItem');
  639. clickTarget.addEventListener('click', e => {
  640. clickPopup.classList.add('expanded');
  641. onClickOutsideOnce(clickPopup, () => clickPopup.classList.remove('expanded'));
  642. });
  643. clickPopup.addEventListener('blur', e => {
  644. if (!clickPopup.contains(e.relatedTarget)) { clickPopup.classList.remove('expanded'); }
  645. }, true);
  646. keyboardClickable(clickTarget);
  647.  
  648. confirmationItem.onclick = e => {
  649. e.preventDefault();
  650. let hash = document.body.getAttribute('data-hash');
  651. let apiUrl = setProto(`//api.juick.com/messages?mid=${mid}&rid=${rid}&hash=${hash}`);
  652. xhrGetAsync(apiUrl, 3000, [], 'DELETE').then(response => {
  653. if (response.status == 200) {
  654. linode.remove();
  655. console.log(`Removed reply /${rid} successfully.`);
  656. } else {
  657. console.warn('Unexpected result.');
  658. console.warn(response);
  659. linode.style.outline = '1px solid red';
  660. }
  661. });
  662. clickPopup.classList.remove('expanded');
  663. };
  664. }
  665. }
  666. });
  667. }
  668. }).catch(err => console.info(err));
  669. }
  670.  
  671. function insertHoverMenu(container, idText, urlText) {
  672. container.insertAdjacentHTML('beforeend', /*html*/`
  673. <div class="hoverPopup">
  674. <div class="hoverTarget" tabindex="0">${svgIconHtml('link')}&nbsp;Links</div>
  675. <div class="hoverContainer">
  676. <p>Click to copy:</p>
  677. <a href="#copy_id" class="copyItem">${idText}</a>
  678. <a href="#copy_url" class="copyItem">${urlText}</a>
  679. </div>
  680. </div>
  681. `);
  682. let hoverPopup = container.querySelector('.hoverPopup');
  683. let hoverTarget = container.querySelector('.hoverTarget');
  684. const copyAction = el => e => {
  685. e.preventDefault();
  686. selectAndCopyElementContents(el, true);
  687. el.classList.add('blinkOnce');
  688. setTimeout(() => {
  689. el.classList.remove('blinkOnce');
  690. hoverPopup.classList.remove('expanded');
  691. }, 700);
  692. };
  693. [].forEach.call(container.querySelectorAll('.copyItem'), copyItem => {
  694. copyItem.onclick = copyAction(copyItem);
  695. });
  696. hoverTarget.addEventListener('click', e => {
  697. hoverPopup.classList.add('expanded');
  698. onClickOutsideOnce(hoverPopup, () => hoverPopup.classList.remove('expanded'));
  699. });
  700. hoverTarget.addEventListener('mouseenter', e => hoverPopup.classList.add('expanded'));
  701. hoverPopup.addEventListener('mouseleave', e => hoverPopup.classList.remove('expanded'));
  702. hoverPopup.addEventListener('blur', e => {
  703. if (!hoverPopup.contains(e.relatedTarget)) { hoverPopup.classList.remove('expanded'); }
  704. }, true);
  705. keyboardClickable(hoverTarget);
  706. }
  707.  
  708. function addCommentShareMenu() {
  709. let messageActionsBlock = document.querySelector('.msgthread .msg-cont > nav.l');
  710. let messageLink = document.querySelector('.msgthread .msg-ts > a');
  711. let mid = /\/(\d+)$/.exec(messageLink.pathname)[1];
  712. insertHoverMenu(
  713. messageActionsBlock,
  714. `#${mid}`,
  715. `${messageLink.href}`
  716. );
  717.  
  718. let commentsBlock = document.querySelector('ul#replies');
  719. if (commentsBlock) {
  720. [].forEach.call(commentsBlock.children, linode => {
  721. let linksBlock = linode.querySelector('div.msg-links');
  722. let commentLink = linode.querySelector('div.msg-ts > a');
  723. if (!commentLink || !linksBlock) { return; }
  724. let mid = /\/(\d+)$/.exec(commentLink.pathname)[1];
  725. let rid = commentLink.hash.replace('#','');
  726. insertHoverMenu(
  727. linksBlock,
  728. `#${mid}/${rid}`,
  729. `${commentLink.href}`
  730. );
  731. });
  732. }
  733. }
  734.  
  735. function addYearLinks() {
  736. let userId = getColumnUserName();
  737. let asideColumn = document.querySelector('aside#column > div');
  738. let footer = asideColumn.querySelector('#footer');
  739. let linksContainer = document.createElement('p');
  740. let years = [
  741. {y: (new Date()).getFullYear(), b: ''},
  742. {y: 2021, b: '?before=3006723'},
  743. {y: 2020, b: '?before=2984375'},
  744. {y: 2019, b: '?before=2959522'},
  745. {y: 2018, b: '?before=2931524'},
  746. {y: 2017, b: '?before=2893675'},
  747. {y: 2016, b: '?before=2857956'},
  748. {y: 2015, b: '?before=2816362'},
  749. {y: 2014, b: '?before=2761245'},
  750. {y: 2013, b: '?before=2629477'},
  751. {y: 2012, b: '?before=2183986'},
  752. {y: 2011, b: '?before=1695443'},
  753. {y: 2010, b: '?before=1140357'},
  754. {y: 2009, b: '?before=453764'},
  755. {y: 2008, b: '?before=20106'}
  756. ];
  757. linksContainer.innerHTML = years.map(item => `<a href="/${userId}/${item.b}">${item.y}</a>`).join(' ');
  758. asideColumn.insertBefore(linksContainer, footer);
  759. }
  760.  
  761. function biggerAvatar() {
  762. let avatarImg = document.querySelector('#column #ctitle > a > img');
  763. if (avatarImg) {
  764. avatarImg.style.maxWidth = 'none';
  765. avatarImg.style.maxHeight = 'none';
  766. }
  767. }
  768.  
  769. function loadTagsAsync(uid) {
  770. let hash = document.body.getAttribute('data-hash');
  771. return xhrGetAsync(setProto(`//api.juick.com/tags?user_id=${uid}&hash=${hash}`), 1000).then(response => {
  772. return JSON.parse(response.responseText);
  773. });
  774. }
  775.  
  776. function makeTagsContainer(tags, numberLimit, sortBy='tag', uname, color=[0,0,0]) {
  777. const tagUrl = (uname)
  778. ? t => `/${uname}/?tag=${encodeURIComponent(t.tag)}`
  779. : t => `/tag/${encodeURIComponent(t.tag)}`;
  780.  
  781. let [r, g, b] = color;
  782. let p0 = 0.7; // 70% of color range is used for color coding
  783. let maxC = 0.1;
  784. tags.forEach(t => { maxC = (t.messages > maxC) ? t.messages : maxC; });
  785. maxC = Math.log(maxC);
  786.  
  787. if (numberLimit && (tags.length > numberLimit)) {
  788. tags = tags.sort((t1, t2) => t2.messages - t1.messages)
  789. .slice(0, numberLimit);
  790. }
  791. if (sortBy) {
  792. tags = tags.sort((t1, t2) => t1[sortBy].localeCompare(t2[sortBy]));
  793. }
  794. let aNodes = tags.map(t => {
  795. let p = (Math.log(t.messages) / maxC - 1) * p0 + 1; // normalize to [1-p0..1]
  796. return `<a title="${t.messages}" href="${tagUrl(t)}" style="color: rgba(${r},${g},${b},${p}) !important;">${t.tag}</a>`;
  797. });
  798. let tagsContainer = document.createElement('p');
  799. tagsContainer.classList.add('tagsContainer');
  800. tagsContainer.innerHTML = aNodes.join(' ');
  801. return tagsContainer;
  802. }
  803.  
  804. function easyTagsUnderNewMessageForm() {
  805. getMyAccountAsync().then(account => {
  806. return loadTagsAsync(account.uid).then(tags => [account, tags]);
  807. }).then(([account, tags]) => {
  808. let color = parseRgbColor(computeStyle(document.createElement('a')).color);
  809. return makeTagsContainer(tags, 300, 'tag', account.uname, color);
  810. }).then(tagsContainer => {
  811. Array.from(document.querySelectorAll('section#content > a')).forEach(a => a.remove());
  812. let content = document.querySelector('section#content');
  813. let messageBox = content.querySelector('textarea.newmessage');
  814. content.insertAdjacentElement('beforeend', tagsContainer);
  815. const addTag = (box, newTag) => {
  816. let re = new RegExp(`(^.* |^)(\\*${escapeRegExp(newTag)})($|\\s[\\s\\S]*$)`, 'g');
  817. if (re.test(box.value)) {
  818. box.value = box.value.replace(re, '$1$3').replace(/(^.*? )( +)/, '$1').replace(/^ /, '');
  819. } else {
  820. box.value = box.value.replace(/(^.*)([\s\S]*)/g, `$1 *${newTag}$2`).replace(/(^.*? )( +)/, '$1').replace(/^ /, '');
  821. }
  822. };
  823. Array.from(tagsContainer.children).forEach(t => {
  824. let newTag = t.textContent;
  825. t.href = '';
  826. t.onclick = (e => { e.preventDefault(); addTag(messageBox, newTag); });
  827. });
  828. return;
  829. }
  830. ).catch( err => console.warn(err) );
  831. }
  832.  
  833. function clearFileInput(inp) {
  834. try {
  835. inp.value = '';
  836. if (inp.value) {
  837. inp.type = '';
  838. inp.type = 'file';
  839. }
  840. } catch (e) {
  841. console.log(e);
  842. console.log('old browser having problems with cleaning file input');
  843. }
  844. }
  845.  
  846. function clearImageInput(mode=undefined) {
  847. let form = document.querySelector('#oldNewMessage');
  848. if (!mode || mode == 'url') { form.querySelector('#image_url').value = ''; }
  849. if (!mode || mode == 'file') { clearFileInput(form.querySelector('#image_upload')); }
  850. form.classList.remove('withImage');
  851. let image = document.querySelector('#imagePreview img');
  852. if (image) { image.remove(); }
  853. }
  854.  
  855. function showImagePreview(src) {
  856. let form = document.querySelector('#oldNewMessage');
  857. let preview = form.querySelector('#imagePreview');
  858. let image = preview.querySelector('img') || document.createElement('img');
  859. image.src = src;
  860. image.onerror = () => clearImageInput();
  861. preview.appendChild(image);
  862. form.classList.add('withImage');
  863. }
  864.  
  865. function updateImageUrlPreview(imageUrlInput) {
  866. let form = document.querySelector('#oldNewMessage');
  867. clearFileInput(form.querySelector('#image_upload')); // clear file input
  868. setTimeout(() => showImagePreview(imageUrlInput.value), 0);
  869. }
  870.  
  871. function updateImageFilePreview(imageInput) {
  872. let form = document.querySelector('#oldNewMessage');
  873. form.querySelector('#image_url').value = ''; // clear url input
  874. if (imageInput.files.length === 0) {
  875. clearImageInput();
  876. } else {
  877. let selFile = imageInput.files[0];
  878. let validTypes = ['image/jpeg', 'image/png'];
  879. if (validTypes.includes(selFile.type) && selFile.size < 10485760) {
  880. showImagePreview(window.URL.createObjectURL(selFile));
  881. } else {
  882. clearImageInput();
  883. }
  884. }
  885. }
  886.  
  887. function addPostSharpFormUser() {
  888. getMyUserNameAsync().then(uname => {
  889. if (getColumnUserName() == uname) {
  890. addPostSharpForm();
  891. }
  892. }).catch(err => console.info(err));
  893. }
  894.  
  895. function addPostSharpForm() {
  896. let content = document.querySelector('#content');
  897. let newMessageForm = /*html*/`
  898. <form id="oldNewMessage" action="/post" method="post" enctype="multipart/form-data">
  899. <textarea name="body" rows="1" placeholder="New message..."></textarea>
  900. <div id="charCounterBlock" class="empty"><div id="charCounter" style="width: 0%;"></div></div>
  901. <div id="bottomBlock">
  902. <div id="bottomLeftBlock">
  903. <input class="tags txt" name="tags" placeholder="Tags (space separated)">
  904. <div id="imgUploadBlock">
  905. <label for="image_upload" class="btn_like">Choose file</label>
  906. <input type="file" id="image_upload" name="attach" accept="image/jpeg,image/png">
  907. <input class="imgLink txt" id="image_url" name="img" placeholder="or paste link">
  908. <div class="info" title="JPG/PNG up to 10 MB">${svgIconHtml('question')}</div>
  909. </div>
  910. <div class="flexSpacer"></div>
  911. <input type="submit" class="subm" value="Send" disabled>
  912. </div>
  913. <div id="imagePreview">
  914. <a id="clear_button" href="javascript:void(0);" title="Remove attachment">${svgIconHtml('close')}</a>
  915. </div>
  916. </div>
  917. </form>`;
  918. content.insertAdjacentHTML('afterbegin', newMessageForm);
  919. let f = document.querySelector('#oldNewMessage');
  920. let ta = f.querySelector('textarea');
  921. let urlInput = f.querySelector('#image_url');
  922. let fileInput = f.querySelector('#image_upload');
  923. let clearButton = f.querySelector('#clear_button');
  924. let charCounter = f.querySelector('#charCounter');
  925. let charCounterBlock = f.querySelector('#charCounterBlock');
  926. let submitButton = f.querySelector('.subm');
  927.  
  928. urlInput.addEventListener('paste', () => updateImageUrlPreview(urlInput));
  929. urlInput.addEventListener('change', () => updateImageUrlPreview(urlInput));
  930. fileInput.addEventListener('change', () => updateImageFilePreview(fileInput));
  931. clearButton.addEventListener('click', () => clearImageInput());
  932. ta.addEventListener('focus', () => {
  933. ta.parentNode.classList.add('active');
  934. document.querySelector('#oldNewMessage textarea').rows = 2;
  935. });
  936. ta.addEventListener('input', () => {
  937. const maxLen = 4096;
  938. let len = ta.value.length;
  939. submitButton.disabled = (len < 1 || len > maxLen);
  940. charCounterBlock.classList.toggle('invalid', len > maxLen);
  941. if (len <= maxLen) {
  942. charCounter.style.width = '' + (100.0 * len / maxLen) + '%';
  943. charCounter.textContent = '';
  944. } else {
  945. charCounter.style.width = '';
  946. charCounter.textContent = '' + len;
  947. }
  948. });
  949. autosize(ta);
  950.  
  951. getMyAccountAsync().then(account => {
  952. return loadTagsAsync(account.uid).then(tags => [account, tags]);
  953. }).then(([account, tags]) => {
  954. let color = parseRgbColor(computeStyle(document.createElement('a')).color);
  955. return makeTagsContainer(tags, 60, 'tag', account.uname, color);
  956. }).then(tagsContainer => {
  957. let messageForm = document.getElementById('oldNewMessage');
  958. let tagsField = messageForm.querySelector('div > .tags');
  959. tagsField.parentNode.parentNode.parentNode.appendChild(tagsContainer);
  960. const addTag = (tagsField, newTag) => {
  961. let re = new RegExp(`(^|\\s)(${escapeRegExp(newTag)})(\\s|$)`, 'g');
  962. if (re.test(tagsField.value)) {
  963. tagsField.value = tagsField.value.replace(re, '$1$3').replace(/\s\s+/g, ' ').trim();
  964. } else {
  965. tagsField.value = (tagsField.value.trim() + ' ' + newTag).trim();
  966. }
  967. };
  968. Array.from(tagsContainer.children).forEach(t => {
  969. let newTag = t.textContent;
  970. t.href = '';
  971. t.onclick = (e => { e.preventDefault(); addTag(tagsField, newTag); });
  972. });
  973. return;
  974. }
  975. ).catch( err => console.warn(err) );
  976. }
  977.  
  978. function sortTagsPage() {
  979. let uid = getColumnUid();
  980. let uname = getColumnUserName();
  981. loadTagsAsync(uid).then(tags => {
  982. let color = parseRgbColor(computeStyle(document.createElement('a')).color);
  983. return makeTagsContainer(tags, undefined, 'tag', uname, color);
  984. }).then(tagsContainer => {
  985. let contentSection = document.querySelector('section#content');
  986. setContent(contentSection, tagsContainer);
  987. return;
  988. }).catch( err => console.warn(err) );
  989. }
  990.  
  991. function getLastArticleDate(html) {
  992. const re = /datetime\=\"([^\"]+) ([^\"]+)\"/;
  993. //const re = /\"timestamp\"\:\"([^\"]+) ([^\"]+)\"/;
  994. let [, dateStr, timeStr] = re.exec(html) || [];
  995. return (dateStr) ? new Date(`${dateStr}T${timeStr}`) : null;
  996. }
  997.  
  998. function processPageAsync(url, retrievalFunction, timeout=110) {
  999. return new Promise(function(resolve, reject) {
  1000. GM_xmlhttpRequest({
  1001. method: 'GET',
  1002. url: setProto(url),
  1003. onload: function(response) {
  1004. let result = null;
  1005. if (response.status != 200) {
  1006. console.log(`${url}: failed with ${response.status}, ${response.statusText}`);
  1007. } else {
  1008. result = retrievalFunction(response.responseText);
  1009. }
  1010. setTimeout(() => resolve(result), timeout);
  1011. }
  1012. });
  1013. });
  1014. }
  1015.  
  1016. function loadUserDatesAsync(unprocessedUsers, processedUsers=[]) {
  1017. return new Promise(function(resolve, reject) {
  1018. if (unprocessedUsers.length === 0) {
  1019. resolve(processedUsers);
  1020. } else {
  1021. let user = unprocessedUsers.splice(0,1)[0];
  1022. //let postsUrl = "http://api.juick.com/messages?uname=" + user.id;
  1023. let postsUrl = '//juick.com/' + user.id + '/';
  1024. let recsUrl = '//juick.com/' + user.id + '/?show=recomm';
  1025.  
  1026. processPageAsync(postsUrl, getLastArticleDate).then(lastPostDate => {
  1027. processPageAsync(recsUrl, getLastArticleDate).then(lastRecDate => {
  1028. let date = (lastPostDate > lastRecDate) ? lastPostDate : lastRecDate;
  1029. if (date) {
  1030. user.date = date;
  1031. user.a.appendChild(document.createTextNode (` (${date.getFullYear()}-${(date.getMonth()+1).pad(2)}-${date.getDate().pad(2)})` ));
  1032. } else {
  1033. console.log(`${user.id}: no posts or recommendations found`);
  1034. }
  1035. processedUsers.push(user);
  1036. loadUserDatesAsync(unprocessedUsers, processedUsers).then(rr => resolve(rr));
  1037. });
  1038. });
  1039. }
  1040. });
  1041. }
  1042.  
  1043. function sortUsers() {
  1044. let contentBlock = document.getElementById('content');
  1045. let button = document.getElementById('usersSortingButton');
  1046. button.parentNode.removeChild(button);
  1047. let usersTable = document.querySelector('div.users');
  1048. let unprocessedUsers = Array.from(usersTable.querySelectorAll('span > a')).map(anode => {
  1049. let userId = anode.pathname.replace(/\//g, '');
  1050. return {a: anode, id: userId, date: (new Date(1970, 1, 1))};
  1051. });
  1052. loadUserDatesAsync(unprocessedUsers).then(
  1053. processedUsers => {
  1054. processedUsers.sort((b, a) => (a.date > b.date) - (a.date < b.date));
  1055. usersTable.parentNode.removeChild(usersTable);
  1056. let ul = document.createElement('div');
  1057. ul.className = 'users sorted';
  1058. processedUsers.forEach(user => {
  1059. let li = document.createElement('span');
  1060. li.appendChild(user.a);
  1061. ul.appendChild(li);
  1062. });
  1063. contentBlock.appendChild(ul);
  1064. return;
  1065. }
  1066. ).catch( err => console.warn(err) );
  1067. }
  1068.  
  1069. function addUsersSortingButton() {
  1070. let contentBlock = document.getElementById('content');
  1071. let usersTable = document.querySelector('div.users');
  1072. let button = document.createElement('button');
  1073. button.id = 'usersSortingButton';
  1074. button.textContent = 'Sort by date';
  1075. button.onclick = sortUsers;
  1076. contentBlock.insertBefore(button, usersTable);
  1077. }
  1078.  
  1079. function turnIntoCts(node, makeNodeCallback) {
  1080. node.classList.add('cts');
  1081. node.onclick = function(e){
  1082. e.preventDefault();
  1083. makeNodeCallback();
  1084. node.onclick = '';
  1085. node.classList.remove('cts');
  1086. };
  1087. }
  1088.  
  1089. function makeCts(makeNodeCallback, titleHtml) {
  1090. let ctsNode = document.createElement('div');
  1091. let placeholder = document.createElement('div');
  1092. placeholder.className = 'placeholder';
  1093. placeholder.innerHTML = titleHtml;
  1094. ctsNode.appendChild(placeholder);
  1095. turnIntoCts(ctsNode, makeNodeCallback);
  1096. return ctsNode;
  1097. }
  1098.  
  1099. function makeTitle(embedType, reResult) {
  1100. return (embedType.makeTitle)
  1101. ? embedType.makeTitle(reResult)
  1102. : naiveEllipsis(reResult[0], 55);
  1103. }
  1104.  
  1105. function makeNewNode(embedType, aNode, reResult, alwaysCts) {
  1106. const withClasses = el => {
  1107. if (embedType.className) {
  1108. el.classList.add(...embedType.className.split(' '));
  1109. }
  1110. return el;
  1111. };
  1112. let isCts = alwaysCts
  1113. || GM_getValue('cts_' + embedType.id, embedType.ctsDefault)
  1114. || (embedType.ctsMatch && embedType.ctsMatch(aNode, reResult));
  1115. if (isCts) {
  1116. let div = makeCts(
  1117. () => embedType.makeNode(aNode, reResult, div),
  1118. 'Click to show: ' + htmlEscape(makeTitle(embedType, reResult))
  1119. );
  1120. return withClasses(div);
  1121. } else {
  1122. return embedType.makeNode(aNode, reResult, withClasses(document.createElement('div')));
  1123. }
  1124. }
  1125.  
  1126. function doFetchingEmbed(aNode, reResult, div, embedType, promiseCallback) {
  1127. return doFetchingEmbed2(
  1128. div,
  1129. makeTitle(embedType, reResult),
  1130. promiseCallback,
  1131. () => {
  1132. div.classList.add('loading');
  1133. div.classList.remove('failed');
  1134. embedType.makeNode(aNode, reResult, div);
  1135. }
  1136. );
  1137. }
  1138.  
  1139. function doFetchingEmbed2(div, title, promiseCallback, remakeCallback) {
  1140. div.innerHTML = `<span>loading ${htmlEscape(title)}</span>`;
  1141. div.classList.add('embed', 'loading');
  1142. promiseCallback()
  1143. .then(() => { div.classList.remove('loading'); div.classList.add('loaded'); })
  1144. .catch(e => {
  1145. let { reason, response, permanent } = e;
  1146. console.log(
  1147. (!!reason || !!response)
  1148. ? { reason: reason, response: response, permanent: permanent, div: div, title: title }
  1149. : e
  1150. );
  1151. if (permanent) {
  1152. div.textContent = reason;
  1153. } else {
  1154. div.textContent = `Failed to load (${reason})`;
  1155. div.classList.remove('loading');
  1156. div.classList.add('failed');
  1157. turnIntoCts(div, remakeCallback);
  1158. }
  1159. });
  1160. return div;
  1161. }
  1162.  
  1163. function makeIframe(src, w, h, scrolling='no') {
  1164. let iframe = document.createElement('iframe');
  1165. iframe.style.width = w;
  1166. iframe.style.height = h;
  1167. iframe.frameBorder = 0;
  1168. iframe.scrolling = scrolling;
  1169. iframe.setAttribute('allowFullScreen', '');
  1170. iframe.src = src;
  1171. iframe.innerHTML = 'Cannot show iframes.';
  1172. return iframe;
  1173. }
  1174.  
  1175. function makeResizableToRatio(element, ratio) {
  1176. element.dataset['ratio'] = ratio;
  1177. makeResizable(element, w => w * element.dataset['ratio']);
  1178. }
  1179.  
  1180. // calcHeight :: Number -> Number -- calculate element height for a given width
  1181. function makeResizable(element, calcHeight) {
  1182. const setHeight = el => {
  1183. if (document.body.contains(el) && (el.offsetWidth > 0)) {
  1184. el.style.height = (calcHeight(el.offsetWidth)).toFixed(2) + 'px';
  1185. }
  1186. };
  1187. window.addEventListener('resize', () => setHeight(element));
  1188. setHeight(element);
  1189. }
  1190.  
  1191. function makeIframeWithHtmlAndId(myHTML) {
  1192. let id = randomId();
  1193. let script = `(function(html){
  1194. var iframe = document.createElement('iframe');
  1195. iframe.id='${id}';
  1196. iframe.onload = function(){var d = iframe.contentWindow.document; d.open(); d.write(html); d.close();};
  1197. document.body.appendChild(iframe);
  1198. })(${JSON.stringify(myHTML)});`;
  1199. window.eval(script);
  1200. return id;
  1201. }
  1202.  
  1203. function makeIframeHtmlAsync(html, w, h, insertCallback, successCallback, failCallback) {
  1204. return new Promise((resolve, reject) => {
  1205. let iframeId = makeIframeWithHtmlAndId(html);
  1206. let iframe = document.getElementById(iframeId);
  1207. iframe.className = 'newIframe';
  1208. iframe.width = w;
  1209. iframe.height = h;
  1210. iframe.frameBorder = 0;
  1211. iframe.addEventListener('load', () => resolve(successCallback(iframe)), false);
  1212. iframe.onerror = er => reject(failCallback(er));
  1213. insertCallback(iframe);
  1214. });
  1215. }
  1216.  
  1217. function loadScript(url, async=false, callback, once=false) {
  1218. if (once && [].some.call(document.scripts, s => s.src == url)) {
  1219. if (typeof callback == 'function') { callback(); }
  1220. return;
  1221. }
  1222.  
  1223. let head = document.getElementsByTagName('head')[0];
  1224. let script = document.createElement('script');
  1225. script.type = 'text/javascript';
  1226. script.src = url;
  1227. if (async) { script.setAttribute('async', ''); }
  1228.  
  1229. if (typeof callback == 'function') {
  1230. script.onload = callback;
  1231. }
  1232.  
  1233. head.appendChild(script);
  1234. }
  1235.  
  1236. function addScript(scriptString, once=false) {
  1237. if (once && [].some.call(document.scripts, s => s.text == scriptString)) { return; }
  1238.  
  1239. let head = document.getElementsByTagName('head')[0];
  1240. let script = document.createElement('script');
  1241. script.type = 'text/javascript';
  1242. script.text = scriptString;
  1243. head.appendChild(script);
  1244. }
  1245.  
  1246. function splitScriptsFromHtml(html) {
  1247. const scriptRe = /<script.*?(?:src="(.+?)".*?)?>([\s\S]*?)<\/\s?script>/gmi;
  1248. let scripts = getAllMatchesAndCaptureGroups(scriptRe, html).map(m => {
  1249. let [, url, s] = m;
  1250. return (url)
  1251. ? { call: function(){ loadScript(url, true); } }
  1252. : { call: function(){ setTimeout(window.eval(s), 0); } };
  1253. });
  1254. let strippedHtml = html.replace(scriptRe, '');
  1255. return [strippedHtml, scripts];
  1256. }
  1257.  
  1258. function extractDomain(url) {
  1259. const domainRe = /^(?:https?:\/\/)?(?:[^@\/\n]+@)?(?:www\.)?([^:\/\n]+)/i;
  1260. return domainRe.exec(url)[1];
  1261. }
  1262.  
  1263. function isDefaultLinkText(aNode) {
  1264. return (aNode.textContent == extractDomain(aNode.href));
  1265. }
  1266.  
  1267. function urlReplace(match, p1, p2, p3) {
  1268. let isBrackets = (p1 !== undefined);
  1269. return (isBrackets)
  1270. ? `<a href="${fixWwwLink(p2 || p3)}">${p1}</a>`
  1271. : `<a href="${fixWwwLink(match)}">${extractDomain(match)}</a>`;
  1272. }
  1273.  
  1274. function urlReplaceInCode(match, p1, p2, p3) {
  1275. let isBrackets = (p1 !== undefined);
  1276. return (isBrackets)
  1277. ? `<a href="${fixWwwLink(p2 || p3)}">${match}</a>`
  1278. : `<a href="${fixWwwLink(match)}">${match}</a>`;
  1279. }
  1280.  
  1281. function messageReplyReplace(messageId) {
  1282. return function(match, mid, rid) {
  1283. let replyPart = (rid && rid != '0') ? '#' + rid : '';
  1284. return `<a href="/m/${mid || messageId}${replyPart}">${match}</a>`;
  1285. };
  1286. }
  1287.  
  1288. function juickFormat(txt, messageId, isCode) {
  1289. const urlRe = /(?:\[([^\]\[]+)\](?:\[([^\]]+)\]|\(((?:[a-z]+:\/\/|www\.|ftp\.)(?:\([-\w+*&@#/%=~|$?!:;,.]*\)|[-\w+*&@#/%=~|$?!:;,.])*(?:\([-\w+*&@#/%=~|$?!:;,.]*\)|[\w+*&@#/%=~|$]))\))|\b(?:[a-z]+:\/\/|www\.|ftp\.)(?:\([-\w+*&@#/%=~|$?!:;,.]*\)|[-\w+*&@#/%=~|$?!:;,.])*(?:\([-\w+*&@#/%=~|$?!:;,.]*\)|[\w+*&@#/%=~|$]))/gi;
  1290. const bqReplace = m => m.replace(/^(?:>|&gt;)\s?/gmi, '');
  1291. return (isCode)
  1292. ? formatText(txt, [
  1293. { pr: 1, re: urlRe, with: urlReplaceInCode },
  1294. { pr: 1, re: /\B(?:#(\d+))?(?:\/(\d+))?\b/g, with: messageReplyReplace(messageId) },
  1295. { pr: 1, re: /\B@([\w-]+)\b/gi, with: '<a href="/$1">@$1</a>' },
  1296. ])
  1297. : formatText(txt, [
  1298. { pr: 0, re: /((?:^(?:>|&gt;)\s?[\s\S]+?$\n?)+)/gmi, brackets: true, with: ['<q>', '</q>', bqReplace] },
  1299. { pr: 1, re: urlRe, with: urlReplace },
  1300. { pr: 1, re: /\B(?:#(\d+))?(?:\/(\d+))?\b/g, with: messageReplyReplace(messageId) },
  1301. { pr: 1, re: /\B@([\w-]+)\b/gi, with: '<a href="/$1">@$1</a>' },
  1302. { pr: 2, re: /\B\*([^\n]+?)\*((?=\s)|(?=$)|(?=[!\"#$%&'*+,\-./:;<=>?@[\]^_`{|}~()]+))/g, brackets: true, with: ['<b>', '</b>'] },
  1303. { pr: 2, re: /\B\/([^\n]+?)\/((?=\s)|(?=$)|(?=[!\"#$%&'*+,\-./:;<=>?@[\]^_`{|}~()]+))/g, brackets: true, with: ['<i>', '</i>'] },
  1304. { pr: 2, re: /\b\_([^\n]+?)\_((?=\s)|(?=$)|(?=[!\"#$%&'*+,\-./:;<=>?@[\]^_`{|}~()]+))/g, brackets: true, with: ['<span class="u">', '</span>'] },
  1305. { pr: 3, re: /\n/g, with: '<br/>' },
  1306. ]);
  1307. }
  1308.  
  1309. function juickPhotoLink(postId, ext) {
  1310. return `//i.juick.com/p/${postId}.${ext}`;
  1311. }
  1312.  
  1313. function juickId([, userId, postId, replyId]) {
  1314. let isReply = replyId && (replyId != '0');
  1315. return '#' + postId + (isReply ? '/' + replyId : '');
  1316. }
  1317.  
  1318. function getEmbeddableLinkTypes() {
  1319. return [
  1320. {
  1321. name: 'Juick',
  1322. id: 'embed_juick',
  1323. className: 'juickEmbed',
  1324. onByDefault: true,
  1325. ctsDefault: false,
  1326. re: /^(?:https?:)?\/\/juick\.com\/(?!tag\/)(?:m\/|([\w-]+)\/|)([\d]{6,}\b)(?:#(\d+))?/i,
  1327. ctsMatch: function(aNode, reResult) {
  1328. let [url, userId, msgId, replyId] = reResult;
  1329. let thisPageMsgMatch = /\/(\d+)$/.exec(window.location.pathname);
  1330. let isSameThread = thisPageMsgMatch && thisPageMsgMatch[1] == msgId;
  1331. return !isSameThread && replyId && (+replyId) > 150;
  1332. },
  1333. makeNode: function(aNode, reResult, div) {
  1334. let thisType = this;
  1335. let [url, userId, msgId, replyId] = reResult;
  1336. userId = userId || 'm';
  1337. let apiUrl = setProto('//api.juick.com/thread?mid=' + msgId);
  1338.  
  1339. let isReply = (replyId && replyId !== '0');
  1340. let mrid = (isReply) ? parseInt(replyId, 10) : 0;
  1341. let idStr = juickId(reResult);
  1342. let linkStr = '/' + userId + '/' + msgId + (isReply ? '#' + mrid : '');
  1343.  
  1344. if (GM_getValue('enable_move_into_view_on_same_page', true)) {
  1345. let thisPageMsgMatch = /\/(\d+)$/.exec(window.location.pathname);
  1346. let isSameThread = thisPageMsgMatch && thisPageMsgMatch[1] == msgId;
  1347. if (isSameThread) {
  1348. let linkedItem = Array.from(document.querySelectorAll('li.msg'))
  1349. .find(x => x.id == replyId || (mrid == 0 && x.id == 'msg-' + msgId));
  1350. if (linkedItem) {
  1351. let thisMsg = aNode.closest('li.msg > div.msg-cont');
  1352. let linkedMsg = linkedItem.querySelector('div.msg-cont');
  1353. setMoveIntoViewOnHover(aNode, thisMsg, linkedMsg, 5, 30);
  1354. return;
  1355. }
  1356. }
  1357. }
  1358.  
  1359. const callback = response => {
  1360. let threadInfo = JSON.parse(response.responseText);
  1361. let msg = (!isReply) ? threadInfo[0] : threadInfo.find(x => (x.rid == mrid));
  1362. if (!msg) {
  1363. throw { reason: `${idStr} does not exist`, response: response, permanent: true, url: apiUrl };
  1364. }
  1365.  
  1366. let withLikes = msg.likes && msg.likes > 0;
  1367. let isReplyToOp = isReply && (!msg.replyto || msg.replyto == 0);
  1368. let withReplies = msg.replies && msg.replies > 0;
  1369. let isNsfw = msg.tags && msg.tags.some(t => t.toUpperCase() == 'NSFW');
  1370. let isCode = msg.tags && msg.tags.some(t => t.toUpperCase() == 'CODE');
  1371.  
  1372. if (isCode) { div.classList.add('codePost'); }
  1373.  
  1374. let tagsStr = (msg.tags) ? '<div class="msg-tags">' + msg.tags.map(x => `<a href="/${msg.user.uname}/?tag=${encodeURIComponent(x)}">${x}</a>`).join('') + '</div>' : '';
  1375. let photoStr = (msg.photo) ? `<div><a href="${juickPhotoLink(msg.mid, msg.attach)}"><img ${(isNsfw ? 'class="nsfw" ' : '')}src="${unsetProto(msg.photo.small)}"/></a></div>` : '';
  1376. let replyStr = (isReply)
  1377. ? ` in reply to <a class="whiteRabbit" href="/${userId}/${msg.mid}${isReplyToOp ? '' : '#' + msg.replyto}">#${msg.mid}${isReplyToOp ? '' : '/' + msg.replyto}</a>`
  1378. : '';
  1379. let likesTitle = (!!msg.recommendations) ? `title="${msg.recommendations.join(', ')}"` : '';
  1380. let likesDiv = (withLikes) ? `<div class="likes" ${likesTitle}><a href="${linkStr}">${svgIconHtml('heart')}${msg.likes}</a></div>` : '';
  1381. let commentsDiv = (withReplies) ? `<div class="replies" title="${[...new Set(threadInfo.slice(1).map(x => x.user.uname))].join(', ')}"><a href="${linkStr}">${svgIconHtml('comment')}${msg.replies}</a></div>` : '';
  1382. div.innerHTML = /*html*/`
  1383. <div class="top">
  1384. <div class="msg-avatar"><a href="/${msg.user.uname}/"><img src="//i.juick.com/a/${msg.user.uid}.png" alt="${msg.user.uname}"></a></div>
  1385. <div class="top-right">
  1386. <div class="top-right-1st">
  1387. <div class="title"><a href="/${msg.user.uname}/">@${msg.user.uname}</a></div>
  1388. <div class="date"><a href="${linkStr}">${msg.timestamp}</a></div>
  1389. </div>
  1390. <div class="top-right-2nd">${tagsStr}</div>
  1391. </div>
  1392. </div>
  1393. <div class="desc">${juickFormat(msg.body, msgId, isCode)}</div>${photoStr}
  1394. <div class="bottom">
  1395. <div class="embedReply msg-links"><a href="${linkStr}">${idStr}</a>${replyStr}</div>
  1396. <div class="right">${likesDiv}${commentsDiv}</div>
  1397. </div>
  1398. `;
  1399.  
  1400. let allLinks = div.querySelectorAll('.desc a, .embedReply a.whiteRabbit');
  1401. let embedContainer = div.parentNode;
  1402. embedLinks(Array.from(allLinks).reverse(), embedContainer, true, div);
  1403. };
  1404.  
  1405. return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
  1406. },
  1407. makeTitle: function(reResult) {
  1408. return juickId(reResult);
  1409. },
  1410. linkTextUpdate: function(aNode, reResult) {
  1411. if (isDefaultLinkText(aNode)) {
  1412. //var isUser = (reResult[1]);
  1413. aNode.textContent = juickId(reResult); // + ((!isReply && isUser) ? ' (@' + reResult[1] + ')' : '');
  1414. }
  1415. }
  1416. },
  1417. {
  1418. name: 'Jpeg and png images',
  1419. id: 'embed_jpeg_and_png_images',
  1420. className: 'picture compact',
  1421. onByDefault: true,
  1422. ctsDefault: false,
  1423. re: /\.(jpe?g|png|svg)(:[a-zA-Z]+)?(?:\?[\w&;\?=]*)?$/i,
  1424. makeNode: function(aNode, reResult, div) {
  1425. div.innerHTML = `<a href="${aNode.href}"><img src="${aNode.href}"></a>`;
  1426. return div;
  1427. }
  1428. },
  1429. {
  1430. name: 'Gif images',
  1431. id: 'embed_gif_images',
  1432. className: 'picture compact',
  1433. onByDefault: true,
  1434. ctsDefault: true,
  1435. re: /\.gif(:[a-zA-Z]+)?(?:\?[\w&;\?=]*)?$/i,
  1436. makeNode: function(aNode, reResult, div) {
  1437. div.innerHTML = `<a href="${aNode.href}"><img src="${aNode.href}"></a>`;
  1438. return div;
  1439. }
  1440. },
  1441. {
  1442. name: 'Video (webm, mp4, ogv)',
  1443. id: 'embed_webm_and_mp4_videos',
  1444. className: 'video compact',
  1445. onByDefault: true,
  1446. ctsDefault: false,
  1447. re: /\.(webm|mp4|m4v|ogv)(?:\?[\w&;\?=]*)?$/i,
  1448. makeNode: function(aNode, reResult, div) {
  1449. div.innerHTML = `<video src="${aNode.href}" title="${aNode.href}" controls></video>`;
  1450. return div;
  1451. }
  1452. },
  1453. {
  1454. name: 'Audio (mp3, ogg, weba, opus, m4a, oga, wav)',
  1455. id: 'embed_sound_files',
  1456. className: 'audio singleColumn',
  1457. onByDefault: true,
  1458. ctsDefault: false,
  1459. re: /\.(mp3|ogg|weba|opus|m4a|oga|wav)(?:\?[\w&;\?=]*)?$/i,
  1460. makeNode: function(aNode, reResult, div) {
  1461. div.innerHTML = `<audio src="${aNode.href}" title="${aNode.href}" controls></audio>`;
  1462. return div;
  1463. }
  1464. },
  1465. {
  1466. name: 'YouTube videos (and playlists)',
  1467. id: 'embed_youtube_videos',
  1468. className: 'youtube resizableV singleColumn',
  1469. onByDefault: true,
  1470. ctsDefault: false,
  1471. re: /^(?:https?:)?\/\/(?:www\.|m\.|gaming\.)?(?:youtu(?:(?:\.be\/|be\.com\/(?:v|embed)\/)([-\w]+)|be\.com\/watch)((?:(?:\?|&(?:amp;)?)(?:\w+=[-\.\w]*[-\w]))*)|youtube\.com\/playlist\?list=([-\w]*)(&(amp;)?[-\w\?=]*)?)/i,
  1472. makeNode: function(aNode, reResult, div) {
  1473. let [url, v, args, plist] = reResult;
  1474. let iframeUrl;
  1475. if (plist) {
  1476. iframeUrl = '//www.youtube-nocookie.com/embed/videoseries?list=' + plist;
  1477. } else {
  1478. let pp = {}; args.replace(/^\?/, '')
  1479. .split('&')
  1480. .map(s => s.split('='))
  1481. .forEach(z => pp[z[0]] = z[1]);
  1482. let embedArgs = { rel: '0' };
  1483. if (pp.t) {
  1484. const tre = /^(?:(\d+)|(?:(\d+)h)?(?:(\d+)m)?(\d+)s|(?:(\d+)h)?(\d+)m|(\d+)h)$/i;
  1485. let [, t, h, m, s, h1, m1, h2] = tre.exec(pp.t);
  1486. embedArgs['start'] = (+t) || ((+(h || h1 || h2 || 0))*60*60 + (+(m || m1 || 0))*60 + (+(s || 0)));
  1487. }
  1488. if (pp.list) {
  1489. embedArgs['list'] = pp.list;
  1490. }
  1491. v = v || pp.v;
  1492. let argsStr = Object.keys(embedArgs)
  1493. .map(k => `${k}=${embedArgs[k]}`)
  1494. .join('&');
  1495. iframeUrl = `//www.youtube-nocookie.com/embed/${v}?${argsStr}`;
  1496. }
  1497. let iframe = makeIframe(iframeUrl, '100%', '360px');
  1498. iframe.onload = () => makeResizableToRatio(iframe, 9.0 / 16.0);
  1499. return setContent(div, iframe);
  1500. }
  1501. },
  1502. {
  1503. name: 'Vimeo videos',
  1504. id: 'embed_vimeo_videos',
  1505. className: 'vimeo resizableV',
  1506. onByDefault: true,
  1507. ctsDefault: false,
  1508. re: /^(?:https?:)?\/\/(?:www\.)?(?:player\.)?vimeo\.com\/(?:video\/|album\/[\d]+\/video\/)?([\d]+)/i,
  1509. makeNode: function(aNode, reResult, div) {
  1510. let iframe = makeIframe('//player.vimeo.com/video/' + reResult[1], '100%', '360px');
  1511. iframe.onload = () => makeResizableToRatio(iframe, 9.0 / 16.0);
  1512. return setContent(div, iframe);
  1513. }
  1514. },
  1515. {
  1516. name: 'Dailymotion videos',
  1517. id: 'embed_youtube_videos',
  1518. className: 'dailymotion resizableV',
  1519. onByDefault: true,
  1520. ctsDefault: false,
  1521. re: /^(?:https?:)?\/\/(?:www\.)?dailymotion\.com\/video\/([a-zA-Z\d]+)(?:_[-%\w]*)?/i,
  1522. makeNode: function(aNode, reResult, div) {
  1523. let iframe = makeIframe('//www.dailymotion.com/embed/video/' + reResult[1], '100%', '360px');
  1524. iframe.onload = () => makeResizableToRatio(iframe, 9.0 / 16.0);
  1525. return setContent(div, iframe);
  1526. }
  1527. },
  1528. {
  1529. name: 'Coub clips',
  1530. id: 'embed_coub_clips',
  1531. className: 'coub resizableV',
  1532. onByDefault: true,
  1533. ctsDefault: false,
  1534. re: /^(?:https?:)?\/\/(?:www\.)?coub\.com\/(?:view|embed)\/([a-zA-Z\d]+)/i,
  1535. makeNode: function(aNode, reResult, div) {
  1536. let embedUrl = '//coub.com/embed/' + reResult[1] + '?muted=false&autostart=false&originalSize=false&startWithHD=false';
  1537. let iframe = makeIframe(embedUrl, '100%', '360px');
  1538. iframe.onload = () => makeResizableToRatio(iframe, 9.0 / 16.0);
  1539. return setContent(div, iframe);
  1540. }
  1541. },
  1542. {
  1543. name: 'Twitch streams',
  1544. id: 'embed_twitch',
  1545. className: 'twitch resizableV',
  1546. onByDefault: true,
  1547. ctsDefault: false,
  1548. re: /^(?:https?:)?\/\/(?:www\.)?twitch\.tv\/(\w+)(?:\/v\/(\d+))?/i,
  1549. makeNode: function(aNode, reResult, div) {
  1550. let [, channel, video] = reResult;
  1551. let url = (video)
  1552. ? `https://player.twitch.tv/?video=v${video}&parent=juick.com&autoplay=false`
  1553. : `https://player.twitch.tv/?channel=${channel}&parent=juick.com&autoplay=false`;
  1554. let iframe = makeIframe(url, '100%', '378px');
  1555. iframe.onload = () => makeResizableToRatio(iframe, 9.0 / 16.0);
  1556. return setContent(div, iframe);
  1557. }
  1558. },
  1559. {
  1560. name: 'Steam games',
  1561. id: 'embed_steam',
  1562. className: 'steam singleColumn',
  1563. onByDefault: true,
  1564. ctsDefault: false,
  1565. re: /^(?:https?:)?\/\/store\.steampowered\.com\/app\/(\d+)/i,
  1566. makeNode: function(aNode, reResult, div) {
  1567. let iframe = makeIframe('//store.steampowered.com/widget/' + reResult[1] + '/', '100%', '190px');
  1568. return setContent(div, iframe);
  1569. }
  1570. },
  1571. {
  1572. name: 'Bandcamp music',
  1573. id: 'embed_bandcamp_music',
  1574. className: 'bandcamp',
  1575. onByDefault: true,
  1576. ctsDefault: false,
  1577. re: /^(?:https?:)?\/\/(\w+)\.bandcamp\.com\/(track|album)\/([-%\w]+)/i,
  1578. makeNode: function(aNode, reResult, div) {
  1579. let thisType = this;
  1580. let [url, band, pageType, pageName] = reResult;
  1581.  
  1582. const callback = response => {
  1583. let videoUrl, videoH;
  1584. const metaRe = /<\s*meta\s+(?:property|name)\s*=\s*\"([^\"]+)\"\s+content\s*=\s*\"([^\"]*)\"\s*>/gmi;
  1585. let matches = getAllMatchesAndCaptureGroups(metaRe, response.responseText);
  1586. matches.forEach(m => {
  1587. if (m[1] == 'og:video') { videoUrl = m[2]; }
  1588. if (m[1] == 'video_height') { videoH = parseInt(m[2], 10); }
  1589. });
  1590. let isAlbum = pageType == 'album';
  1591. if (isAlbum) { videoUrl = videoUrl.replace('/tracklist=false', '/tracklist=true'); }
  1592. videoUrl = videoUrl.replace('/artwork=small', '');
  1593. let iframe = makeIframe(videoUrl, '100%', '600px');
  1594. setContent(div, wrapIntoTag(iframe, 'div', 'bandcamp resizableV'));
  1595. let calcHeight = w => w + videoH + (isAlbum ? 162 : 0);
  1596. iframe.onload = () => makeResizable(iframe, calcHeight);
  1597. div.classList.remove('embed');
  1598. };
  1599.  
  1600. return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(url, 3000).then(callback));
  1601. }
  1602. },
  1603. {
  1604. name: 'SoundCloud music',
  1605. id: 'embed_soundcloud_music',
  1606. className: 'soundcloud',
  1607. onByDefault: true,
  1608. ctsDefault: false,
  1609. re: /^(?:https?:)?\/\/(?:www\.)?soundcloud\.com\/(([\w\-\_]*)\/(?:sets\/)?(?!tracks$)([-%\w]*))(?:\/)?/i,
  1610. makeNode: function(aNode, reResult, div) {
  1611. let embedUrl = '//w.soundcloud.com/player/?url=//soundcloud.com/' + reResult[1] + '&amp;auto_play=false&amp;hide_related=false&amp;show_comments=true&amp;show_user=true&amp;show_reposts=false&amp;visual=true';
  1612. return setContent(div, makeIframe(embedUrl, '100%', 450));
  1613. }
  1614. },
  1615. {
  1616. name: 'Mixcloud music',
  1617. id: 'embed_mixcloud_music',
  1618. className: 'mixcloud singleColumn',
  1619. onByDefault: true,
  1620. ctsDefault: false,
  1621. re: /^(?:https?:)?\/\/(?:www\.)?mixcloud\.com\/(?!discover\/)([\w]+)\/(?!playlists\/)([-%\w]+)\/?/i,
  1622. makeNode: function(aNode, reResult, div) {
  1623. let thisType = this;
  1624. let [url, author, mix] = reResult;
  1625. let apiUrl = 'https://www.mixcloud.com/oembed/?format=json&url=' + encodeURIComponent(url);
  1626.  
  1627. const callback = response => {
  1628. let json = JSON.parse(response.responseText);
  1629. div.innerHTML = json.html;
  1630. div.className = div.classList.remove('embed');
  1631. };
  1632.  
  1633. return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
  1634. }
  1635. },
  1636. {
  1637. name: 'Яндекс.Музыка',
  1638. id: 'embed_yandex_music',
  1639. className: 'yandexMusic singleColumn',
  1640. onByDefault: true,
  1641. ctsDefault: true,
  1642. re: /^(?:https?:)?\/\/music\.yandex\.ru(?!$|\/artist\/\d+$)(?:\/album\/(\d+))?(?:\/track\/(\d+))?/i,
  1643. makeNode: function(aNode, reResult, div) {
  1644. let [url, album, track] = reResult;
  1645. let isTrack = !!track;
  1646. let embedUrl = (isTrack)
  1647. ? `https://music.yandex.ru/iframe/#track/${track}/${album ? album + '/' : ''}`
  1648. : `https://music.yandex.ru/iframe/#album/${album}/`;
  1649. return setContent(div, makeIframe(embedUrl, '100%', isTrack ? '100px' : '420px'));
  1650. }
  1651. },
  1652. {
  1653. name: 'Flickr images',
  1654. id: 'embed_flickr_images',
  1655. className: 'flickr',
  1656. onByDefault: true,
  1657. ctsDefault: false,
  1658. re: /^(?:https?:)?\/\/(?:(?:www\.)?flickr\.com\/photos\/([\w@-]+)\/(\d+)|flic.kr\/p\/(\w+))(?:\/)?/i,
  1659. makeNode: function(aNode, reResult, div) {
  1660. let thisType = this;
  1661. let apiUrl = 'https://www.flickr.com/services/oembed?format=json&url=' + encodeURIComponent(reResult[0]);
  1662.  
  1663. const callback = response => {
  1664. let json = JSON.parse(response.responseText);
  1665. let imageUrl = (json.url) ? json.url : json.thumbnail_url; //.replace('_b.', '_z.');
  1666. let typeStr = (json.flickr_type == 'photo') ? '' : ` (${json.flickr_type})`;
  1667. div.innerHTML = /*html*/`
  1668. <div class="top">
  1669. <div class="title">
  1670. <a href="${json.web_page}">${json.title}</a>${typeStr} by <a href="${json.author_url}">${json.author_name}</a>
  1671. </div>
  1672. </div>
  1673. <a href="${aNode.href}"><img src="${imageUrl}"></a>`;
  1674. };
  1675.  
  1676. return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
  1677. }
  1678. },
  1679. {
  1680. name: 'DeviantArt images',
  1681. id: 'embed_deviantart_images',
  1682. className: 'deviantart',
  1683. onByDefault: true,
  1684. ctsDefault: false,
  1685. re: /^(?:https?:)?\/\/([\w-]+)\.deviantart\.com\/art\/([-%\w]+)/i,
  1686. makeNode: function(aNode, reResult, div) {
  1687. let thisType = this;
  1688. let [url, userId, workId] = reResult;
  1689. let apiUrl = 'https://backend.deviantart.com/oembed?format=json&url=' + encodeURIComponent(url);
  1690.  
  1691. const callback = response => {
  1692. let json = JSON.parse(response.responseText);
  1693. let date = new Date(json.pubdate);
  1694. let typeStr = (json.type == 'photo') ? '' : ` (${json.type})`;
  1695. div.innerHTML = /*html*/`
  1696. <div class="top">
  1697. <div class="title">
  1698. <a href="${url}">${json.title}</a>${typeStr} by <a href="${json.author_url}">${json.author_name}</a>
  1699. </div>
  1700. <div class="date">${date.toLocaleString('ru-RU')}</div>
  1701. </div>`;
  1702. if ((json.type == 'rich') && json.html) {
  1703. div.innerHTML += `<div class="desc">${json.html}...</div>`;
  1704. } else {
  1705. let imageClassStr = (json.safety == 'adult') ? 'class="rating_e"' : '';
  1706. let imageUrl = json.fullsize_url || json.url || json.thumbnail_url;
  1707. div.innerHTML += `<a href="${aNode.href}"><img ${imageClassStr} src="${imageUrl}"></a>`;
  1708. }
  1709. };
  1710.  
  1711. // consider adding note 'Failed to load (maybe this article can\'t be embedded)'
  1712. return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
  1713. }
  1714. },
  1715. {
  1716. name: 'Imgur gifv videos',
  1717. id: 'embed_imgur_gifv_videos',
  1718. className: 'video compact',
  1719. onByDefault: true,
  1720. ctsDefault: false,
  1721. re: /^(?:https?:)?\/\/(?:\w+\.)?imgur\.com\/([a-zA-Z\d]+)\.gifv/i,
  1722. makeNode: function(aNode, reResult, div) {
  1723. div.innerHTML = `<video src="//i.imgur.com/${reResult[1]}.mp4" title="${aNode.href}" controls loop></video>`;
  1724. return div;
  1725. }
  1726. },
  1727. {
  1728. name: 'Imgur indirect links',
  1729. id: 'embed_imgur_indirect_links',
  1730. className: 'imgur singleColumn',
  1731. onByDefault: true,
  1732. ctsDefault: false,
  1733. re: /^(?:https?:)?\/\/(?:\w+\.)?imgur\.com\/(?:(gallery|a)\/)?(?!gallery|jobs|about|blog|apps)([a-zA-Z\d]+)(?:#\d{1,2}$|#([a-zA-Z\d]+))?(\/\w+)?$/i,
  1734. makeNode: function(aNode, reResult, div) {
  1735. let thisType = this;
  1736. let [, albumType, contentId, albumImageId] = reResult;
  1737.  
  1738. let url = (albumType && albumImageId)
  1739. ? 'http://imgur.com/' + albumImageId
  1740. : 'http://imgur.com/' + (albumType ? albumType + '/' : '') + contentId;
  1741. let apiUrl = 'https://api.imgur.com/oembed.json?url=' + encodeURIComponent(url);
  1742.  
  1743. const callback = response => {
  1744. let json = JSON.parse(response.responseText);
  1745. let iframe = makeIframeHtmlAsync(
  1746. json.html,
  1747. '100%',
  1748. '24px',
  1749. iframe => div.appendChild(iframe),
  1750. iframe => [iframe, iframe.contentWindow.document],
  1751. e => ({ reason: e.message, permanent: false, url: apiUrl })
  1752. ).then(([iframe, doc]) => {
  1753. return waitAndRunAsync(
  1754. () => !!doc.querySelector('iframe'),
  1755. 50,
  1756. 100,
  1757. () => ([iframe, doc]),
  1758. () => ({ reason: 'timeout', permanent: false, url: apiUrl })
  1759. );
  1760. }).then(([iframe, doc]) => {
  1761. div.replaceChild(doc.querySelector('iframe'), iframe);
  1762. div.querySelector('span').remove();
  1763. div.classList.remove('embed');
  1764. });
  1765. };
  1766.  
  1767. return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
  1768. }
  1769. },
  1770. {
  1771. name: 'Gfycat indirect links',
  1772. id: 'embed_gfycat_indirect_links',
  1773. className: 'gfycat',
  1774. onByDefault: true,
  1775. ctsDefault: true,
  1776. re: /^(?:https?:)?\/\/(?:\w+\.)?gfycat\.com\/([a-zA-Z\d]+)$/i,
  1777. makeNode: function(aNode, reResult, div) {
  1778. return setContent(div, makeIframe('//gfycat.com/ifr/' + reResult[1], '100%', 480));
  1779. }
  1780. },
  1781. {
  1782. name: 'Twitter',
  1783. id: 'embed_twitter_status',
  1784. className: 'twi',
  1785. onByDefault: true,
  1786. ctsDefault: false,
  1787. re: /^(?:https?:)?\/\/(?:www\.)?(?:mobile\.)?twitter\.com\/([\w-]+)\/status(?:es)?\/([\d]+)/i,
  1788. makeNode: function(aNode, reResult, div) {
  1789. let thisType = this;
  1790. let [url, userId, postId] = reResult;
  1791. url = url.replace('mobile.','');
  1792.  
  1793. const predicates = [
  1794. {
  1795. msg: response => (response.statusText ? `${response.status} - ${response.statusText}` : `${response.status}`),
  1796. test: response => response.status != 200,
  1797. permanent: response => response.status != 503
  1798. },
  1799. {
  1800. msg: response => 'Account @' + userId + ' is suspended',
  1801. test: response => response.finalUrl.endsWith('account/suspended'),
  1802. permanent: response => true
  1803. },
  1804. {
  1805. msg: response => 'Account @' + userId + ' is protected',
  1806. test: response => response.finalUrl.indexOf('protected_redirect=true') != -1,
  1807. permanent: response => true
  1808. }
  1809. ];
  1810. const callback = response => {
  1811. let images = [];
  1812. let userGenImg = false;
  1813. let isVideo = false;
  1814. let videoUrl, videoW, videoH;
  1815. let title, description;
  1816. const metaRe = /<\s*meta\s+property\s*=\s*\"([^\"]+)\"\s+content\s*=\s*\"([^\"]*)\"\s*>/gmi;
  1817. let matches = getAllMatchesAndCaptureGroups(metaRe, response.responseText);
  1818. matches.forEach(m => {
  1819. if (m[1] == 'og:title') { title = m[2]; }
  1820. if (m[1] == 'og:description') {
  1821. description = htmlDecode(m[2])
  1822. .replace(/\n/g,'<br/>')
  1823. .replace(/\B@(\w{1,15})\b/gmi, '<a href="//twitter.com/$1">@$1</a>')
  1824. .replace(/#(\w+)/gmi, '<a href="//twitter.com/hashtag/$1">#$1</a>')
  1825. .replace(/(?:https?:)?\/\/t\.co\/([\w]+)/gmi, '<a href="$&">$&</a>');
  1826. }
  1827. if (m[1] == 'og:image') { images.push(m[2]); }
  1828. if (m[1] == 'og:image:user_generated') { userGenImg = true; }
  1829. if (m[1] == 'og:video:url') { videoUrl = m[2]; isVideo = true; }
  1830. if (m[1] == 'og:video:height') { videoH = +m[2]; }
  1831. if (m[1] == 'og:video:width') { videoW = +m[2]; }
  1832. });
  1833. const timestampMsRe = /\bdata-time-ms\s*=\s*\"([^\"]+)\"/gi;
  1834. let timestampMsResult = timestampMsRe.exec(response.responseText);
  1835. let dateDiv = (timestampMsResult) ? `<div class="date">${new Date(+timestampMsResult[1]).toLocaleString('ru-RU')}</div>` : '';
  1836. div.innerHTML = /*html*/`
  1837. <div class="top">
  1838. <div class="title">
  1839. ${title} (<a href="//twitter.com/${userId}">@${userId}</a>)
  1840. </div>
  1841. ${dateDiv}
  1842. </div>
  1843. <div class="desc">${description}</div>`;
  1844. if (userGenImg) { div.innerHTML += images.map(x => { return `<a href="${x}"><img src="${x}"></a>`; }).join(''); }
  1845. if (isVideo) {
  1846. let { w, h } = fitToBounds(videoW, videoH, 620, 720);
  1847. let ctsVideo = makeCts(
  1848. () => setContent(ctsVideo, makeIframe(videoUrl, w + 'px', h + 'px')),
  1849. `<img src="${images[0]}">${svgIconHtml('play')}`
  1850. );
  1851. div.appendChild(ctsVideo);
  1852. }
  1853. };
  1854.  
  1855. return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(url, 3000, predicates).then(callback));
  1856. }
  1857. },
  1858. {
  1859. name: 'Facebook',
  1860. id: 'embed_facebook',
  1861. className: 'fbEmbed singleColumn',
  1862. onByDefault: true,
  1863. ctsDefault: false,
  1864. re: /^(?:https?:)?\/\/(?:www\.|m\.)?facebook\.com\/(?:[\w.]+\/(?:posts|videos|photos)\/[\w:./]+(?:\?[\w=%&.]+)?|(?:photo|video)\.php\?[\w=%&.]+)/i,
  1865. makeNode: function(aNode, reResult, div) {
  1866. let thisType = this;
  1867.  
  1868. const promiseCallback = () => {
  1869. setTimeout(loadScript('https://connect.facebook.net/en_GB/sdk.js#xfbml=1&version=v2.3', false, undefined, true), 0);
  1870. div.insertAdjacentHTML('beforeend', `<div class="fb-post" data-href="${aNode.href}" data-width="640" />`);
  1871. return waitAndRunAsync(
  1872. () => !!div.querySelector('iframe[height]'),
  1873. 20,
  1874. 100,
  1875. () => {},
  1876. () => ({ reason: 'timeout', permanent: false })
  1877. ).then(() => {
  1878. div.querySelector('span').remove();
  1879. div.classList.remove('embed');
  1880. }).catch(e => {
  1881. console.log('Juick tweaks: time out on facebook embedding, applying workaround.');
  1882. let embedUrl = 'https://www.facebook.com/plugins/post.php?width=640&height=570&href=' + encodeURIComponent(reResult[0]);
  1883. div.innerHTML = '';
  1884. div.appendChild(makeIframe(embedUrl, '100%', 570));
  1885. div.classList.remove('embed');
  1886. div.classList.add('fallback');
  1887. });
  1888. };
  1889.  
  1890. return doFetchingEmbed(aNode, reResult, div, thisType, promiseCallback);
  1891. }
  1892. },
  1893. {
  1894. name: 'Telegram',
  1895. id: 'embed_telegram',
  1896. className: 'telegram singleColumn',
  1897. onByDefault: false,
  1898. ctsDefault: false,
  1899. re: /^(?:https?:)?\/\/t\.me\/(.+)\/(\d+)$/i,
  1900. makeNode: function(aNode, reResult, div) {
  1901. let thisType = this;
  1902. let [, channelId, postId] = reResult;
  1903.  
  1904. const promiseCallback = () => {
  1905. setTimeout(() => {
  1906. let script = document.createElement('script');
  1907. script.async = true;
  1908. script.type = 'text/javascript';
  1909. script.src = 'https://telegram.org/js/telegram-widget.js?4';
  1910. script.dataset.telegramPost = `${channelId}/${postId}`;
  1911. script.dataset.width = '100%';
  1912. div.appendChild(script);
  1913. }, 0);
  1914. return waitAndRunAsync(
  1915. () => !!div.querySelector('iframe'),
  1916. 30,
  1917. 100,
  1918. () => {},
  1919. () => ({ reason: 'timeout', permanent: false })
  1920. ).then(() => {
  1921. div.querySelector('span').remove();
  1922. div.classList.remove('embed');
  1923. });
  1924. };
  1925.  
  1926. return doFetchingEmbed(aNode, reResult, div, thisType, promiseCallback);
  1927. }
  1928. },
  1929. {
  1930. name: 'Tumblr',
  1931. id: 'embed_tumblr',
  1932. className: 'tumblr singleColumn',
  1933. onByDefault: true,
  1934. ctsDefault: true,
  1935. re: /^(?:https?:)?\/\/(?:([\w\-\_]+)\.)?tumblr\.com\/post\/([\d]*)(?:\/([-%\w]*))?/i,
  1936. makeNode: function(aNode, reResult, div) {
  1937. let thisType = this;
  1938. let apiUrl = 'https://www.tumblr.com/oembed/1.0?url=' + reResult[0];
  1939.  
  1940. const callback = response => {
  1941. let json = JSON.parse(response.responseText);
  1942. return makeIframeHtmlAsync(
  1943. json.html,
  1944. '100%',
  1945. '24px',
  1946. iframe => div.appendChild(iframe),
  1947. iframe => [iframe, iframe.contentWindow.document],
  1948. e => ({ reason: e.message, permanent: false, url: apiUrl })
  1949. ).then(([iframe, doc]) => {
  1950. return waitAndRunAsync(
  1951. () => !!doc.querySelector('iframe[height]'),
  1952. 50,
  1953. 100,
  1954. () => ([iframe, doc]),
  1955. () => ({ reason: 'timeout', permanent: false, url: apiUrl })
  1956. );
  1957. }).then(([iframe, doc]) => {
  1958. div.replaceChild(doc.querySelector('iframe[height]'), iframe);
  1959. div.querySelector('span').remove();
  1960. div.classList.remove('embed');
  1961. });
  1962. };
  1963.  
  1964. return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
  1965. }
  1966. },
  1967. {
  1968. name: 'Reddit',
  1969. id: 'embed_reddit',
  1970. className: 'reddit singleColumn',
  1971. onByDefault: true,
  1972. ctsDefault: false,
  1973. re: /^(?:https?:)?\/\/(?:www\.|np\.|m\.)?reddit\.com\/r\/([\w]+)\/comments\/(\w+)(?:\/(?:\w+(?:\/(\w+)?)?)?)?/i,
  1974. makeNode: function(aNode, reResult, div) {
  1975. let thisType = this;
  1976. let apiUrl = 'https://www.reddit.com/oembed?url=' + encodeURIComponent(reResult[0]);
  1977.  
  1978. const callback = response => {
  1979. let json = JSON.parse(response.responseText);
  1980. let [h, ss] = splitScriptsFromHtml(json.html);
  1981. div.innerHTML += h;
  1982. ss.forEach(s => s.call());
  1983. return waitAndRunAsync(
  1984. () => { let iframe = div.querySelector('iframe'); return (iframe && (parseInt(iframe.height) > 30)); },
  1985. 30,
  1986. 100,
  1987. () => {},
  1988. () => ({ reason: 'timeout', permanent: false, url: apiUrl })
  1989. ).then(iframe => {
  1990. div.querySelector('iframe').style.margin = '0px';
  1991. div.querySelector('span').remove();
  1992. div.classList.remove('embed');
  1993. });
  1994. };
  1995.  
  1996. // consider adding note 'Failed to load (maybe this article can\'t be embedded)'
  1997. return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
  1998. }
  1999. },
  2000. {
  2001. name: 'WordPress',
  2002. id: 'embed_wordpress',
  2003. className: 'wordpress singleColumn',
  2004. onByDefault: true,
  2005. ctsDefault: false,
  2006. re: /^(?:https?:)?\/\/(\w+)\.wordpress\.com\/(\d{4})\/(\d{2})\/(\d{2})\/([-\w%\u0400-\u04FF]+)(?:\/)?/i,
  2007. makeNode: function(aNode, reResult, div) {
  2008. let thisType = this;
  2009. let [url,site,year,month,day,slug] = reResult;
  2010. let apiUrl = `https://public-api.wordpress.com/rest/v1.1/sites/${site}.wordpress.com/posts/slug:${slug}`;
  2011.  
  2012. const callback = response => {
  2013. let json = JSON.parse(response.responseText);
  2014. div.innerHTML = /*html*/`
  2015. <div class="top">
  2016. <div class="title">
  2017. "<a href="${url}">${json.title}</a>" by <a href="${json.author.URL}">${json.author.name}</a>
  2018. </div>
  2019. <div class="date">${new Date(json.date).toLocaleString('ru-RU')}</div>
  2020. </div>
  2021. <hr/>
  2022. <div class="desc">${json.content}</div>`;
  2023. };
  2024.  
  2025. // consider adding note 'Failed to load (maybe this article can\'t be embedded)'
  2026. return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
  2027. }
  2028. },
  2029. {
  2030. name: 'SlideShare',
  2031. id: 'embed_slideshare',
  2032. className: 'slideshare singleColumn',
  2033. onByDefault: true,
  2034. ctsDefault: false,
  2035. re: /^(?:https?:)?\/\/(?:\w+\.)?slideshare\.net\/(\w+)\/([-%\w]+)/i,
  2036. makeNode: function(aNode, reResult, div) {
  2037. let thisType = this;
  2038. let [url, author, id] = reResult;
  2039. let apiUrl = 'http://www.slideshare.net/api/oembed/2?format=json&url=' + url;
  2040.  
  2041. const callback = response => {
  2042. let json = JSON.parse(response.responseText);
  2043. let baseSize = 640;
  2044. let newH = 1.0 * baseSize / json.width * json.height;
  2045. let iframeStr = json.html
  2046. .match(/<iframe[^>]+>[\s\S]*?<\/iframe>/i)[0]
  2047. .replace(/width="\d+"/i, `width="${baseSize}"`)
  2048. .replace(/height="\d+"/i, `height="${newH}"`);
  2049. div.innerHTML = iframeStr;
  2050. };
  2051.  
  2052. return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
  2053. }
  2054. },
  2055. {
  2056. name: 'Gist',
  2057. id: 'embed_gist',
  2058. className: 'gistEmbed singleColumn',
  2059. onByDefault: true,
  2060. ctsDefault: false,
  2061. re: /^(?:https?:)?\/\/gist\.github\.com\/(?:([\w-]+)\/)?([A-Fa-f0-9]+)\b/i,
  2062. makeNode: function(aNode, reResult, div) {
  2063. let thisType = this;
  2064. let [url, , id] = reResult;
  2065. let apiUrl = 'https://gist.github.com/' + id + '.json';
  2066.  
  2067. const callback = response => {
  2068. let json = JSON.parse(response.responseText);
  2069. let date = new Date(json.created_at).toLocaleDateString('ru-RU');
  2070. div.innerHTML = /*html*/`
  2071. <div class="top">
  2072. <div class="title">
  2073. "${json.description}" by <a href="https://gist.github.com/${json.owner}">${json.owner}</a>
  2074. </div>
  2075. <div class="date">${date}</div>
  2076. </div>
  2077. <link rel="stylesheet" href="${htmlEscape(json.stylesheet)}"></link>
  2078. ${json.div}`;
  2079. };
  2080.  
  2081. return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
  2082. }
  2083. },
  2084. {
  2085. name: 'JSFiddle',
  2086. id: 'embed_jsfiddle',
  2087. className: 'jsfiddle',
  2088. onByDefault: true,
  2089. ctsDefault: false,
  2090. re: /^(?:https?:)?(\/\/jsfiddle\.net\/(?:(?!embedded\b)[\w]+\/?)+)/i,
  2091. makeNode: function(aNode, reResult, div) {
  2092. let embedUrl = reResult[1].replace(/[^\/]$/, '$&/') + 'embedded/';
  2093. return setContent(div, makeIframe(embedUrl, '100%', 500));
  2094. }
  2095. },
  2096. {
  2097. name: 'Codepen',
  2098. id: 'embed_codepen',
  2099. className: 'codepen singleColumn',
  2100. onByDefault: true,
  2101. ctsDefault: false,
  2102. re: /^(?:https?:)?\/\/codepen\.io\/(\w+)\/(?:pen|full)\/(\w+)/i,
  2103. makeNode: function(aNode, reResult, div) {
  2104. let thisType = this;
  2105. let [url] = reResult;
  2106. let apiUrl = 'https://codepen.io/api/oembed?format=json&url=' + encodeURIComponent(url.replace('/full/', '/pen/'));
  2107.  
  2108. const callback = response => {
  2109. let json = JSON.parse(response.responseText);
  2110. div.innerHTML = /*html*/`
  2111. <div class="top">
  2112. <div class="title">"${json.title}" by <a href="${json.author_url}">${json.author_name}</a></div>
  2113. </div>
  2114. ${json.html}`;
  2115. };
  2116.  
  2117. return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
  2118. }
  2119. },
  2120. {
  2121. name: 'XKCD',
  2122. id: 'embed_xkcd',
  2123. className: 'xkcd singleColumn',
  2124. onByDefault: true,
  2125. ctsDefault: false,
  2126. re: /^(?:https?:)?\/\/xkcd\.com\/(\d+)/i,
  2127. makeNode: function(aNode, reResult, div) {
  2128. let thisType = this;
  2129. let [url, xkcdId] = reResult;
  2130.  
  2131. const callback = response => {
  2132. let [, title] = /<div id="ctitle">([\s\S]+?)<\/div>/.exec(response.responseText) || [];
  2133. let [, comic] = /<div id="comic">([\s\S]+?)<\/div>/.exec(response.responseText) || [];
  2134. div.innerHTML = /*html*/`
  2135. <div class="top">
  2136. <div class="title">${title}</div>
  2137. </div>
  2138. <a href="${url}" class="comic">${comic}</a>`;
  2139. };
  2140.  
  2141. return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(url, 3000).then(callback));
  2142. },
  2143. makeTitle: function(reResult) {
  2144. return 'xkcd.com/' + reResult[1] + '/';
  2145. },
  2146. linkTextUpdate: function(aNode, reResult) {
  2147. if (isDefaultLinkText(aNode)) {
  2148. aNode.textContent = 'xkcd.com/' + reResult[1] + '/';
  2149. }
  2150. }
  2151. },
  2152. {
  2153. name: 'lichess',
  2154. id: 'embed_lichess',
  2155. className: 'lichess singleColumn',
  2156. onByDefault: true,
  2157. ctsDefault: false,
  2158. re: /^(?:https?:)?\/\/lichess\.org\/(study|)(?:\/?embed)?\/?((?=[a-z]*[A-Z0-9])[A-Za-z0-9\/]{8})/,
  2159. makeNode: function(aNode, reResult, div) {
  2160. let [, mode, rest] = reResult;
  2161. let embedUrl = ['https://lichess.org', mode, 'embed', rest].filter(a => !!a).join('/');
  2162. let iframe = makeIframe(embedUrl, '100%', '400px');
  2163. iframe.onload = () => makeResizableToRatio(iframe, 397.0 / 600.0);
  2164. return setContent(div, iframe);
  2165. }
  2166. },
  2167. {
  2168. name: 'Wikipedia',
  2169. id: 'embed_wikipedia',
  2170. className: 'wikipedia singleColumn',
  2171. onByDefault: true,
  2172. ctsDefault: false,
  2173. re: /^(?:https?:)?\/\/([a-z]+)\.wikipedia\.org\/wiki\/([-A-Za-z0-9À-ž_+*&@#/%=~|$\(\),]+)$/,
  2174. makeNode: function(aNode, reResult, div) {
  2175. let thisType = this;
  2176. let [url, lang, entity] = reResult;
  2177. let embedUrl = `https://${lang}.wikipedia.org/api/rest_v1/page/summary/${entity}`;
  2178.  
  2179. const callback = response => {
  2180. let json = JSON.parse(response.responseText);
  2181. div.innerHTML = /*html*/`
  2182. <div class="top">
  2183. <div class="title"><a href="${url}">${json.displaytitle}</a></div>
  2184. <div class="lang">${json.lang}</div>
  2185. </div>
  2186. <div>
  2187. ${json.thumbnail ? `<img src="${json.thumbnail.source}" style="float: right;">` : ''}
  2188. <div class="extract">${json.extract_html}</div>
  2189. </div>`;
  2190. };
  2191.  
  2192. return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(embedUrl, 3000).then(callback));
  2193. },
  2194. makeTitle: function(reResult) {
  2195. return 'Wikipedia: ' + decodeURIComponent(reResult[2].replace(/_/g, ' '));
  2196. },
  2197. linkTextUpdate: function(aNode, reResult) {
  2198. if (isDefaultLinkText(aNode)) {
  2199. aNode.textContent = 'Wikipedia: ' + decodeURIComponent(reResult[2].replace(/_/g, ' '));
  2200. }
  2201. }
  2202. },
  2203. {
  2204. name: 'arXiv',
  2205. id: 'embed_arxiv',
  2206. className: 'arxiv singleColumn',
  2207. onByDefault: true,
  2208. ctsDefault: false,
  2209. re: /^(?:https?:)?\/\/(?:\w+\.)?arxiv\.org\/(?:abs|pdf)\/(\d+\.\d+)(v\d+)?/i,
  2210. makeNode: function(aNode, reResult, div) {
  2211. let thisType = this;
  2212. let [url, arxivId, rev] = reResult;
  2213. let absUrl = 'https://arxiv.org/abs/' + arxivId + (rev || '');
  2214. let pdfUrl = 'https://arxiv.org/pdf/' + arxivId + (rev || '');
  2215.  
  2216. const callback = response => {
  2217. const metaRe = /<\s*meta\s+name\s*=\s*\"([^\"]+)\"\s+content\s*=\s*\"([^\"]*)\"\s*\/?>/gmi;
  2218. let matches = getAllMatchesAndCaptureGroups(metaRe, response.responseText).map(m => ({ k: m[1].toLowerCase(), v: m[2] }));
  2219. let title = matches.find(x => x.k == 'citation_title').v;
  2220.  
  2221. let [, dateline] = /<div class="dateline">\s*([\s\S]+?)<\/div>/.exec(response.responseText) || [];
  2222. let [, abstract] = /<blockquote class="abstract\b.*?">\s*<span class="descriptor">[\s\S]*?<\/span>\s*([\s\S]+?)<\/blockquote>/.exec(response.responseText) || [];
  2223. let authors = getAllMatchesAndCaptureGroups(/<a href="(\/find.+?)">(.+?)<\/a>/gi, response.responseText).map(m => ({ url: m[1], name: m[2] }));
  2224. let authorsStr = authors.map(a => `<a href="${a.url}">${a.name}</a>`).join(', ');
  2225.  
  2226. div.innerHTML = /*html*/`
  2227. <div class="top">
  2228. <div class="title"><a href="${absUrl}">${title}</a> (<a href="${pdfUrl}">pdf</a>)</div>
  2229. <div class="date">${dateline}</div>
  2230. </div>
  2231. <div class="abstract">${abstract}</div>
  2232. <div class="bottom">
  2233. <div class="authors">${authorsStr}</div>
  2234. </div>`;
  2235. };
  2236.  
  2237. return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(absUrl, 3000).then(callback));
  2238. },
  2239. makeTitle: function(reResult) {
  2240. return 'arXiv:' + reResult[1] + (reResult[2] || '');
  2241. },
  2242. linkTextUpdate: function(aNode, reResult) {
  2243. if (isDefaultLinkText(aNode)) {
  2244. aNode.textContent = 'arXiv:' + reResult[1] + (reResult[2] || '');
  2245. }
  2246. }
  2247. },
  2248. {
  2249. name: 'Pixiv',
  2250. id: 'embed_pixiv',
  2251. className: 'pixiv',
  2252. onByDefault: true,
  2253. ctsDefault: false,
  2254. re: /^(?:https?:)?\/\/www\.pixiv\.net\/member_illust\.php\?((?:\w+=\w+&)*illust_id=(\d+)(?:&\w+=\w+)*)/i,
  2255. makeNode: function(aNode, reResult, div) {
  2256. let thisType = this;
  2257. let [url, , illustId] = reResult;
  2258.  
  2259. const predicates = [
  2260. {
  2261. msg: response => 'Private work',
  2262. test: response => (response.status != 200) && response.responseText.includes('work private'),
  2263. permanent: response => true
  2264. },
  2265. {
  2266. msg: response => (response.statusText ? `${response.status} - ${response.statusText}` : `${response.status}`),
  2267. test: response => response.status != 200,
  2268. permanent: response => response.status != 503
  2269. },
  2270. {
  2271. msg: response => 'Deleted work',
  2272. test: response => response.responseText.includes('This work was deleted'),
  2273. permanent: response => true
  2274. }
  2275. ];
  2276. const callback = response => {
  2277. let isMultipage = (url.includes('mode=manga') || response.responseText.includes('member_illust.php?mode=manga'));
  2278. const metaRe = /<\s*meta\s+(?:property|name)\s*=\s*\"([^\"]+)\"\s+content\s*=\s*\"([^\"]*)\"\s*\/?>/gmi;
  2279. let matches = getAllMatchesAndCaptureGroups(metaRe, response.responseText).map(m => ({ k: (m[1] || m[3]).toLowerCase(), v: m[2] }));
  2280. let meta = {}; [].forEach.call(matches, m => { meta[m.k] = m.v; });
  2281. let title = meta['twitter:title'] || meta['og:title'];
  2282. let image = /* meta['twitter:image'] || meta['og:image'] || */ '//embed.pixiv.net/decorate.php?illust_id=' + illustId;
  2283. let description = meta['twitter:description'] || meta['og:description'];
  2284.  
  2285. let [, dateStr] = /<span\s+class=\"date\">([^<]+)<\/span>/.exec(response.responseText) || [];
  2286. let [, authorId, authorName] = /<a\s+href="\/?member\.php\?id=(\d+)">\s*<img\s+src="[^"]+"\s+alt="[^"]+"\s+title="([^"]+)"\s\/?>/i.exec(response.responseText) || [];
  2287.  
  2288. let dateDiv = (dateStr) ? `<div class="date">${dateStr}</div>` : '';
  2289. let authorStr = (authorId) ? ` by <a href="//www.pixiv.net/member_illust.php?id=${authorId}">${authorName}</a>` : '';
  2290. div.innerHTML = /*html*/`
  2291. <div class="top">
  2292. <div class="title">
  2293. ${isMultipage ? '(multipage) ' : ''}<a href="${url}">${title}</a>${authorStr}
  2294. </div>
  2295. ${dateDiv}
  2296. </div>
  2297. <a href="${aNode.href}"><img src="${image}"></a>
  2298. ${description ? '<p>' + description + '</p>' : ''}`;
  2299. };
  2300.  
  2301. return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(url.replace(/mode=\w+/, 'mode=medium'), 3000, predicates).then(callback));
  2302. }
  2303. },
  2304. {
  2305. name: 'Gelbooru',
  2306. id: 'embed_gelbooru',
  2307. className: 'gelbooru booru',
  2308. onByDefault: true,
  2309. ctsDefault: false,
  2310. re: /^(?:https?:)?\/\/(?:www\.)?(gelbooru\.com|safebooru\.org)\/index\.php\?((?:\w+=\w+&)*id=(\d+)(?:&\w+=\w+)*)/i,
  2311. makeNode: function(aNode, reResult, div) {
  2312. let thisType = this;
  2313. let [url, domain, , illustId] = reResult;
  2314. let apiUrl = `https://${domain}/index.php?page=dapi&s=post&q=index&id=${illustId}&json=1`;
  2315.  
  2316. const callback = response => {
  2317. const json = JSON.parse(response.responseText);
  2318. if (json.count === 0) {
  2319. throw { reason: illustId + ' is not available', response: response, permanent: true, url: apiUrl };
  2320. }
  2321.  
  2322. const post = json.post[0];
  2323. const saucenaoUrl = `https://img3.saucenao.com/booru/${post.md5[0]}/${post.md5[1]}/${post.md5}_2.jpg`;
  2324. let createdDateStr = (new Date(post.created_at)).toLocaleDateString('ru-RU');
  2325. const changedDateStr = (new Date(1000 * parseInt(post.change, 10))).toLocaleDateString('ru-RU');
  2326. if (createdDateStr != changedDateStr) { createdDateStr += ` (${changedDateStr})`; }
  2327. const ratingStr = (post.rating == 's') ? '' : ` (${post.rating})`;
  2328. div.innerHTML = /*html*/`
  2329. <div class="top">
  2330. <div class="title">
  2331. <a href="${url}">${post.id}</a>${ratingStr}${post.has_notes ? ' (notes)' : ''}${post.has_comments ? ' (comments)' : ''}
  2332. </div>
  2333. <div class="date">${createdDateStr}</div>
  2334. </div>
  2335. <div class="bottom-right">
  2336. <div>
  2337. <a href="https://www.iqdb.org/?url=${post.preview_url}">IQDB</a>, <a href="https://saucenao.com/search.php?url=${post.preview_url}">SauceNAO</a>
  2338. </div>
  2339. </div>
  2340. <a href="${aNode.href}">
  2341. <img class="rating_${post.rating}" src="${post.preview_url}" onerror="this.onerror=null;this.src='${saucenaoUrl}';">
  2342. </a>
  2343. `;
  2344. };
  2345.  
  2346. return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
  2347. },
  2348. makeTitle: function([, domain, , illustId]) { return `${domain} (${illustId})`; },
  2349. linkTextUpdate: function(aNode, [, , , illustId]) { aNode.textContent += ` (${illustId})`; }
  2350. },
  2351. {
  2352. name: 'Danbooru',
  2353. id: 'embed_danbooru',
  2354. className: 'danbooru booru',
  2355. onByDefault: true,
  2356. ctsDefault: false,
  2357. re: /^(?:https?:)?\/\/(danbooru|safebooru)\.donmai\.us\/post(?:s|\/show)\/(\d+)/i,
  2358. makeNode: function(aNode, reResult, div) {
  2359. let thisType = this;
  2360. let [url, domain, id] = reResult;
  2361. url = url.replace('http:', 'https:');
  2362. let urls = (domain == 'safebooru')
  2363. ? [`https://${domain}.donmai.us/posts/${id}.json`]
  2364. : [`https://${domain}.donmai.us/posts/${id}.json`, `https://safebooru.donmai.us/posts/${id}.json`];
  2365.  
  2366. const callback = response => {
  2367. let [finalUrl, finalDomain, ] = thisType.re.exec(response.finalUrl);
  2368. let json = JSON.parse(response.responseText);
  2369. if (!json.preview_file_url) {
  2370. div.innerHTML = `<span>Can't show <a href="${finalUrl}">${id}</a> (<a href="${url}">${json.rating}</a>)</span>`;
  2371. return;
  2372. }
  2373.  
  2374. let finalPreviewUrl = `https://${finalDomain}.donmai.us${json.preview_file_url}`;
  2375. let saucenaoUrl = `https://img3.saucenao.com/booru/${json.md5[0]}/${json.md5[1]}/${json.md5}_2.jpg`;
  2376. let tagsStr = [json.tag_string_artist, json.tag_string_character, json.tag_string_copyright]
  2377. .filter(s => s != '')
  2378. .map(s => (s.count(' ') > 1) ? naiveEllipsisRight(s, 40) : `<a href="https://${finalDomain}.donmai.us/posts?tags=${encodeURIComponent(s)}">${s}</a>`)
  2379. .join('<br>');
  2380. let notesStr = (json.last_noted_at) ? ' (notes)' : '';
  2381. let commentsStr = (json.last_commented_at) ? ' (comments)' : '';
  2382. let ratingStr = (json.rating == 's') ? '' : ` (<a href="${url}">${json.rating}</a>)`;
  2383. let createdDateStr = (new Date(json.created_at)).toLocaleDateString('ru-RU');
  2384. let updatedDateStr = (new Date(json.updated_at)).toLocaleDateString('ru-RU');
  2385. if (createdDateStr != updatedDateStr) { createdDateStr += ` (${updatedDateStr})`; }
  2386. div.innerHTML = /*html*/`
  2387. <div class="top">
  2388. <div class="title"><a href="${finalUrl}">${id}</a>${ratingStr}${notesStr}${commentsStr}</div>
  2389. <div class="date">${createdDateStr}</div>
  2390. </div>
  2391. <div class="bottom-right">
  2392. <div class="booru-tags">${tagsStr}</div>
  2393. <div>
  2394. <a href="https://www.iqdb.org/?url=${finalPreviewUrl}">IQDB</a>, <a href="https://saucenao.com/search.php?url=${finalPreviewUrl}">SauceNAO</a>
  2395. </div>
  2396. </div>
  2397. <a href="${finalUrl}">
  2398. <img class="rating_${json.rating}" src="${finalPreviewUrl}" onerror="this.onerror=null;this.src='${saucenaoUrl}';">
  2399. </a>
  2400. `;
  2401. };
  2402.  
  2403. return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrFirstResponse(urls, 3000).then(callback));
  2404. },
  2405. makeTitle: function([, domain, id]) { return `${domain} (${id})`; },
  2406. linkTextUpdate: function(aNode, [, , id]) {
  2407. aNode.href = aNode.href.replace('http:', 'https:');
  2408. aNode.textContent += ` (${id})`;
  2409. }
  2410. },
  2411. {
  2412. name: 'Konachan',
  2413. id: 'embed_konachan',
  2414. className: 'konachan booru',
  2415. onByDefault: true,
  2416. ctsDefault: false,
  2417. re: /^(?:https?:)?\/\/konachan\.(com|net)\/post\/show\/(\d+)/i,
  2418. makeNode: function(aNode, reResult, div) {
  2419. let thisType = this;
  2420. let [url, domain, id] = reResult;
  2421. url = url.replace('.com/', '.net/');
  2422. let unsafeUrl = url.replace('.net/', '.com/');
  2423. let apiUrls = [ `https://konachan.net/post.json?tags=id:${id}`, `https://konachan.com/post.json?tags=id:${id}` ];
  2424.  
  2425. const callback = response => {
  2426. let json = (JSON.parse(response.responseText))[0];
  2427. if (!json || !json.preview_url) {
  2428. div.innerHTML = `<span>Can't show <a href="${url}">${id}</a> (<a href="${unsafeUrl}">${json.rating}</a>)</span>'`;
  2429. return;
  2430. }
  2431.  
  2432. let saucenaoUrl = `https://img3.saucenao.com/booru/${json.md5[0]}/${json.md5[1]}/${json.md5}_2.jpg`;
  2433. let createdDateStr = (new Date(1000 * parseInt(json.created_at, 10))).toLocaleDateString('ru-RU');
  2434. let ratingStr = (json.rating == 's') ? '' : ` (<a href="${unsafeUrl}">${json.rating}</a>)`;
  2435. div.innerHTML = /*html*/`
  2436. <div class="top">
  2437. <div class="title"><a href="${url}">${id}</a>${ratingStr}</div>
  2438. <div class="date">${createdDateStr}</div>
  2439. </div>
  2440. <div class="bottom-right">
  2441. <div>
  2442. <a href="https://www.iqdb.org/?url=${json.preview_url}">IQDB</a>, <a href="https://saucenao.com/search.php?url=${json.preview_url}">SauceNAO</a>
  2443. </div>
  2444. </div>
  2445. <a href="${url}"><img class="rating_${json.rating}" src="${json.preview_url}" onerror="this.onerror=null;this.src='${saucenaoUrl}';"></a>
  2446. `;
  2447. };
  2448.  
  2449. return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrFirstResponse(apiUrls, 3000).then(callback));
  2450. },
  2451. makeTitle: function([, , id]) { return `konachan (${id})`; },
  2452. linkTextUpdate: function(aNode, [, , id]) { aNode.textContent += ` (${id})`; }
  2453. },
  2454. {
  2455. name: 'yande.re',
  2456. id: 'embed_yandere',
  2457. className: 'yandere booru',
  2458. onByDefault: true,
  2459. ctsDefault: false,
  2460. re: /^(?:https?:)?\/\/yande\.re\/post\/show\/(\d+)/i,
  2461. makeNode: function(aNode, reResult, div) {
  2462. let thisType = this;
  2463. let [url, id] = reResult;
  2464. let apiUrl = 'https://yande.re/post.json?tags=id:' + id;
  2465.  
  2466. const callback = response => {
  2467. let json = (JSON.parse(response.responseText))[0];
  2468. if (!json || !json.preview_url) {
  2469. div.innerHTML = `<span>Can't show <a href="${url}">${id}</a> (${json.rating})</span>`;
  2470. return;
  2471. }
  2472.  
  2473. let ratingStr = (json.rating == 's') ? '' : ` (${json.rating})`;
  2474. let notesStr = (json.last_noted_at && json.last_noted_at !== 0) ? ' (notes)' : '';
  2475. let commentsStr = (json.last_commented_at && json.last_commented_at !== 0) ? ' (comments)' : '';
  2476. let createdDateStr = (new Date(1000 * json.created_at)).toLocaleDateString('ru-RU');
  2477. let updatedDateStr = (new Date(1000 * json.updated_at)).toLocaleDateString('ru-RU');
  2478. if (createdDateStr != updatedDateStr && json.updated_at != 0) { createdDateStr += ` (${updatedDateStr})`; }
  2479. div.innerHTML = /*html*/`
  2480. <div class="top">
  2481. <div class="title"><a href="${url}">${id}</a>${ratingStr}${notesStr}${commentsStr}</div>
  2482. <div class="date">${createdDateStr}</div>
  2483. </div>
  2484. <div class="bottom-right">
  2485. <div>
  2486. <a href="https://www.iqdb.org/?url=${json.preview_url}">IQDB</a>, <a href="https://saucenao.com/search.php?url=${json.preview_url}">SauceNAO</a>
  2487. </div>
  2488. </div>
  2489. <a href="${url}"><img class="rating_${json.rating}" src="${json.preview_url}"></a>`;
  2490. };
  2491.  
  2492. return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
  2493. },
  2494. makeTitle: function([, id]) { return `yande.re (${id})`; },
  2495. linkTextUpdate: function(aNode, [, id]) { aNode.textContent += ` (${id})`; }
  2496. },
  2497. {
  2498. name: 'anime-pictures.net',
  2499. id: 'embed_anime_pictures_net',
  2500. className: 'anime-pictures booru',
  2501. onByDefault: true,
  2502. ctsDefault: false,
  2503. re: /^(?:https?:)?\/\/anime-pictures\.net\/pictures\/view_post\/(\d+)/i,
  2504. makeNode: function(aNode, reResult, div) {
  2505. let thisType = this;
  2506. let [url, id] = reResult;
  2507.  
  2508. const predicates = [
  2509. {
  2510. msg: response => 'Click to show ' + thisType.makeTitle(reResult),
  2511. test: response => response.status == 503,
  2512. permanent: response => false
  2513. },
  2514. {
  2515. msg: response => (response.statusText ? `${response.status} - ${response.statusText}` : `${response.status}`),
  2516. test: response => response.status != 200,
  2517. permanent: response => true
  2518. },
  2519. {
  2520. msg: response => 'Login required',
  2521. test: response => response.responseText.includes('must be logged in'),
  2522. permanent: response => true
  2523. }
  2524. ];
  2525. const callback = response => {
  2526. const metaRe = /<\s*meta\s+(?:(?:property|name)\s*=\s*\"([^\"]+)\"\s+)?content\s*=\s*\"([^\"]*)\"(?:\s+(?:property|name)\s*=\s*\"([^\"]+)\")?\s*>/gmi;
  2527. let matches = getAllMatchesAndCaptureGroups(metaRe, response.responseText);
  2528. let imageUrl = matches.find(m => (m[1] || m[3]) == 'og:image')[2];
  2529.  
  2530. div.innerHTML = /*html*/`
  2531. <div class="top">
  2532. <div class="title">
  2533. <a href="${url}">${id}</a>
  2534. </div>
  2535. </div>
  2536. <div class="bottom-right">
  2537. <a href="https://www.iqdb.org/?url=${imageUrl}">IQDB</a>
  2538. <a href="https://saucenao.com/search.php?url=${imageUrl}">SauceNAO</a>
  2539. </div>
  2540. <a href="${aNode.href}"><img src="${imageUrl}"></a>`;
  2541. };
  2542.  
  2543. return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(url, 3000, predicates).then(callback));
  2544. },
  2545. makeTitle: function([, id]) { return `anime-pictures.net (${id})`; },
  2546. linkTextUpdate: function(aNode, [, id]) { aNode.textContent += ` (${id})`; }
  2547. },
  2548. {
  2549. name: 'derpibooru.org',
  2550. id: 'embed_derpibooru',
  2551. className: 'derpibooru booru',
  2552. onByDefault: false,
  2553. ctsDefault: false,
  2554. re: /^(?:https?:)?\/\/derpibooru\.org\/(?:images\/)?(\d+)/i,
  2555. makeNode: function(aNode, reResult, div) {
  2556. let thisType = this;
  2557. let [url, id] = reResult;
  2558. let apiUrl = 'https://derpibooru.org/api/v1/json/images/' + id;
  2559.  
  2560. const callback = response => {
  2561. let json = JSON.parse(response.responseText);
  2562. if (!json || !json.image) {
  2563. div.innerHTML = `<span>Can't show <a href="${url}">${id}</a></span>`;
  2564. console.log(response);
  2565. return;
  2566. }
  2567. json = json.image;
  2568.  
  2569. let createdDateStr = (new Date(json.created_at)).toLocaleDateString('ru-RU');
  2570. const tagEncode = t => t.replace(/ /g, '+').replace(/:/g, '-colon-');
  2571. let tagsStr = json.tags
  2572. .map(t => `<a href="https://derpibooru.org/tags/${tagEncode(t)}">${t}</a>`)
  2573. .join(', ');
  2574. div.innerHTML = /*html*/`
  2575. <div class="top">
  2576. <div class="title"><a href="${url}">${json.description}</a></div>
  2577. <div class="date">${createdDateStr}</div>
  2578. </div>
  2579. <a href="${url}"><img src="${json.representations.medium}"></a>
  2580. <div class="booru-tags">${tagsStr}</div>
  2581. <div class="bottom">
  2582. <div class="source">
  2583. <a href="${json.source_url}">Source</a>, uploaded by <a href="https://derpibooru.org/profiles/${json.uploader}">${json.uploader}</a>
  2584. </div>
  2585. <div class="right">
  2586. <div class="faves">${svgIconHtml('star')}${json.faves}</div>
  2587. <div class="votes">${svgIconHtml('heart')}${json.score} (${json.upvotes}↑ ${json.downvotes}↓)</div>
  2588. <div class="replies">${svgIconHtml('comment')}${json.comment_count}</div>
  2589. </div>
  2590. </div>`;
  2591. };
  2592.  
  2593. return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
  2594. },
  2595. makeTitle: function([, id]) { return `derpibooru (${id})`; },
  2596. linkTextUpdate: function(aNode, [, id]) { aNode.textContent += ` (${id})`; }
  2597. },
  2598. {
  2599. name: 'Яндекс.Фотки',
  2600. id: 'embed_yandex_fotki',
  2601. className: 'picture compact',
  2602. onByDefault: true,
  2603. ctsDefault: false,
  2604. re: /^(?:https?:)?\/\/img-fotki\.yandex\.ru\/get\/\d+\/[\w\.]+\/[\w]+$/i,
  2605. makeNode: function(aNode, reResult, div) {
  2606. div.innerHTML = `<a href="${aNode.href}"><img src="${aNode.href}"></a>`;
  2607. return div;
  2608. }
  2609. },
  2610. {
  2611. name: 'pikabu',
  2612. id: 'embed_pikabu',
  2613. className: 'pikabu',
  2614. onByDefault: true,
  2615. ctsDefault: false,
  2616. re: /^(?:https?:)?\/\/pikabu\.ru\/story\/([a-z0-9_]+)/i,
  2617. makeNode: function(aNode, reResult, div) {
  2618. let thisType = this;
  2619. let [url, ] = reResult;
  2620.  
  2621. const callback = response => {
  2622. const metaRe = /<\s*meta\s+(?:(?:property|name)\s*=\s*[\"']([^\"']+)[\"']\s+)?content\s*=\s*\"([^\"]*)\"(?:\s+(?:property|name)\s*=\s*\"([^\"]+)\")?(?:\s*(?:\w+=\"[^\"]*\"))*\s*\/?>/gmi;
  2623. let matches = getAllMatchesAndCaptureGroups(metaRe, response.responseText).map(m => ({ k: (m[1] || m[3]).toLowerCase(), v: m[2] }));
  2624. let meta = {}; [].forEach.call(matches, m => { meta[m.k] = m.v; });
  2625. let title = htmlDecode(meta['twitter:title'] || meta['og:title']);
  2626. let description = htmlDecode(longest([meta['og:description'], meta['twitter:description'], '']));
  2627. let image = meta['twitter:image:src'];
  2628. let imageStr = (image) ? `<a href="${url}"><img src="${image}" /></a>` : '';
  2629. div.innerHTML = /*html*/`
  2630. <div class="top">
  2631. <div class="title">
  2632. <a href="${url}">${title}</a>
  2633. </div>
  2634. </div>
  2635. ${imageStr}
  2636. <div class="desc">${description}</div>`;
  2637. };
  2638.  
  2639. return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(url, 3000).then(callback));
  2640. }
  2641. },
  2642. {
  2643. name: 'Use meta for other links (whitelist)',
  2644. id: 'embed_whitelisted_domains',
  2645. className: 'other',
  2646. onByDefault: true,
  2647. ctsDefault: false,
  2648. re: /^(?:https?:)?\/\/(?!juick\.com\b).*/i,
  2649. match: function(aNode, reResult) {
  2650. let domain = aNode.hostname.replace(/^(?:www|m)\./, '');
  2651. let domainsWhitelist = GM_getValue('domains_whitelist', getDefaultDomainWhitelist().join('\n')).split(/\r?\n/);
  2652. return domainsWhitelist.some(w => matchWildcard(domain, w));
  2653. },
  2654. makeNode: function(aNode, reResult, div) {
  2655. let thisType = this;
  2656. let [url] = reResult;
  2657. let domain = aNode.hostname;
  2658.  
  2659. const checkContentType = response => {
  2660. const headRe = /^([\w-]+): (.+)$/gmi;
  2661. let headerMatches = getAllMatchesAndCaptureGroups(headRe, response.responseHeaders);
  2662. let [, , contentType] = headerMatches.find(([, k, ]) => (k.toLowerCase() == 'content-type'));
  2663. if (contentType && contentType.match(/^text\/html\b/i)) {
  2664. return;
  2665. } else {
  2666. throw { reason: 'not text/html' };
  2667. }
  2668. };
  2669.  
  2670. const callback = response => {
  2671. const metaRe = /<\s*meta\s+(?:(?:property|name)\s*=\s*[\"']([^\"']+)[\"']\s+)?content\s*=\s*\"([^\"]*)\"(?:\s+(?:property|name)\s*=\s*\"([^\"]+)\")?(?:\s*(?:\w+=\"[^\"]*\"))*\s*\/?>/gmi;
  2672. const titleRe = /<title>([\s\S]+?)<\/title>/gmi;
  2673. let [, basicTitle] = titleRe.exec(response.responseText) || [];
  2674. let matches = getAllMatchesAndCaptureGroups(metaRe, response.responseText).map(m => ({ k: (m[1] || m[3]).toLowerCase(), v: m[2] }));
  2675. let meta = {}; [].forEach.call(matches, m => { meta[m.k] = m.v; });
  2676. let title = meta['twitter:title'] || meta['og:title'] || meta['title'] || basicTitle || meta['sailthru.title'];
  2677. let image = meta['twitter:image'] || meta['twitter:image:src'] || meta['og:image'] || meta['sailthru.image.full'];
  2678. let description = longest([meta['og:description'], meta['twitter:description'], meta['description'], meta['sailthru.description']]);
  2679. let isEnoughContent = title && description && (title.length > 0) && (description.length > 0);
  2680. if (!isEnoughContent) {
  2681. throw { reason: 'not enough meta content to embed' };
  2682. }
  2683. let imageStr = (image) ? `<a href="${url}"><img src="${image}" /></a>` : '';
  2684. description = htmlDecode(description).replace(/\n+/g,'<br/>');
  2685. div.innerHTML = /*html*/`
  2686. <div class="top">
  2687. <div class="title">
  2688. <a href="${url}">${title}</a>
  2689. </div>
  2690. </div>
  2691. ${imageStr}
  2692. <div class="desc">${description}</div>`;
  2693. div.classList.add(domain.replace(/\./g, '_'));
  2694. };
  2695.  
  2696. const unembed = e => {
  2697. if (e.reason) { console.log(`${e.reason} - ${url}`); }
  2698. div.remove();
  2699. aNode.className = '';
  2700. };
  2701.  
  2702. return doFetchingEmbed(aNode, reResult, div, thisType,
  2703. () => xhrGetAsync(url, 1000, undefined, 'HEAD')
  2704. .then(checkContentType)
  2705. .then(() => { return xhrGetAsync(url, 1500).then(callback); })
  2706. .catch(e => unembed(e))
  2707. );
  2708. }
  2709. }
  2710. ];
  2711. }
  2712.  
  2713. function getDefaultDomainWhitelist() {
  2714. return [
  2715. 'lenta.ru',
  2716. 'meduza.io',
  2717. 'rbc.ru',
  2718. 'tjournal.ru',
  2719. '*.newsru.com',
  2720. '*.itar-tass.com',
  2721. 'tass.ru',
  2722. 'rublacklist.net',
  2723. 'mk.ru',
  2724. 'gazeta.ru',
  2725. 'republic.ru',
  2726. 'bash.im',
  2727. 'ixbt.com',
  2728. 'techxplore.com',
  2729. 'medicalxpress.com',
  2730. 'phys.org',
  2731. 'techcrunch.com',
  2732. 'bbc.com',
  2733. 'nplus1.ru',
  2734. 'elementy.ru',
  2735. 'news.tut.by',
  2736. 'imdb.com',
  2737. 'mastodon.social',
  2738. 'mastodonsocial.ru'
  2739. ];
  2740. }
  2741.  
  2742. function embedLink(aNode, linkTypes, container, alwaysCts, afterNode) {
  2743. let anyEmbed = false;
  2744. let linkId = (aNode.href.replace(/^https?:/i, '').replace(/\'/gi,''));
  2745. let sameEmbed = container.querySelector(`*[data-linkid='${linkId}']`); // do not embed the same thing twice
  2746. if (sameEmbed) {
  2747. if (GM_getValue('enable_arrows', true)) { aNode.classList.add('arrow'); }
  2748. setHighlightOnHover(aNode, sameEmbed);
  2749. //setMoveIntoViewOnHover(aNode, aNode, newNode, 5, 30);
  2750. } else {
  2751. anyEmbed = [].some.call(linkTypes, function(linkType) {
  2752. if (GM_getValue(linkType.id, linkType.onByDefault)) {
  2753. let reResult = linkType.re.exec(aNode.href);
  2754. if (reResult) {
  2755. if (linkType.match && (linkType.match(aNode, reResult) === false)) { return false; }
  2756.  
  2757. let newNode = makeNewNode(linkType, aNode, reResult, alwaysCts);
  2758. if (!newNode) { return false; }
  2759.  
  2760. newNode.setAttribute('data-linkid', linkId);
  2761. if (afterNode) {
  2762. insertAfter(newNode, afterNode);
  2763. } else {
  2764. container.appendChild(newNode);
  2765. }
  2766.  
  2767. aNode.classList.add('embedLink');
  2768. if (GM_getValue('enable_arrows', true)) { aNode.classList.add('arrow'); }
  2769. if (GM_getValue('enable_link_text_update', true) && linkType.linkTextUpdate) { linkType.linkTextUpdate(aNode, reResult); }
  2770.  
  2771. setHighlightOnHover(aNode, newNode);
  2772. //setMoveIntoViewOnHover(aNode, aNode, newNode, 5, 30);
  2773. return true;
  2774. }
  2775. }
  2776. });
  2777. }
  2778. return anyEmbed;
  2779. }
  2780.  
  2781. function embedLinks(aNodes, container, alwaysCts, afterNode) {
  2782. let anyEmbed = false;
  2783. let embeddableLinkTypes = getEmbeddableLinkTypes();
  2784. Array.from(aNodes).forEach(aNode => {
  2785. let isEmbedded = embedLink(aNode, embeddableLinkTypes, container, alwaysCts, afterNode);
  2786. anyEmbed = anyEmbed || isEmbedded;
  2787. });
  2788. return anyEmbed;
  2789. }
  2790.  
  2791. function splitUsersAndTagsLists(str) {
  2792. let items = str.split(/[\s,]+/);
  2793. let users = items.filter(x => x.startsWith('@')).map(x => x.replace('@','').toLowerCase());
  2794. let tags = items.filter(x => x.startsWith('*')).map(x => x.replace('*','').toLowerCase());
  2795. return [users, tags];
  2796. }
  2797.  
  2798. function articleInfo(article) {
  2799. let tagNodes = article.querySelectorAll('.msg-tags > *');
  2800. let tags = Array.from(tagNodes).map(d => d.textContent.toLowerCase());
  2801. return { userId: getPostUserName(article), tags: tags };
  2802. }
  2803.  
  2804. function isFilteredX(x, filteredUsers, filteredTags) {
  2805. let {userId, tags} = articleInfo(x);
  2806. return (filteredUsers && userId && filteredUsers.indexOf(userId.toLowerCase()) !== -1)
  2807. || (intersect(tags, filteredTags).length > 0);
  2808. }
  2809.  
  2810. function embedLinksToX(x, beforeNodeSelector, allLinksSelector, ctsUsers, ctsTags) {
  2811. let isCtsPost = isFilteredX(x, ctsUsers, ctsTags);
  2812. let allLinks = x.querySelectorAll(allLinksSelector);
  2813.  
  2814. let existingContainer = x.querySelector('div.embedContainer');
  2815. if (existingContainer) {
  2816. embedLinks(allLinks, existingContainer, isCtsPost);
  2817. } else {
  2818. let embedContainer = document.createElement('div');
  2819. embedContainer.className = 'embedContainer';
  2820.  
  2821. let anyEmbed = embedLinks(allLinks, embedContainer, isCtsPost);
  2822. if (anyEmbed) {
  2823. let beforeNode = x.querySelector(beforeNodeSelector);
  2824. x.insertBefore(embedContainer, beforeNode);
  2825. }
  2826. }
  2827. }
  2828.  
  2829. function embedLinksToArticles() {
  2830. let [ctsUsers, ctsTags] = splitUsersAndTagsLists(GM_getValue('cts_users_and_tags', ''));
  2831. let beforeNodeSelector = 'nav.l';
  2832. let allLinksSelector = '.msg-txt > a, pre a';
  2833. setTimeout(function() {
  2834. Array.from(document.querySelectorAll('#content article[data-mid]')).forEach(article => {
  2835. embedLinksToX(article, beforeNodeSelector, allLinksSelector, ctsUsers, ctsTags);
  2836. });
  2837. }, 50);
  2838. }
  2839.  
  2840. function embedLinksToPost() {
  2841. let [ctsUsers, ctsTags] = splitUsersAndTagsLists(GM_getValue('cts_users_and_tags', ''));
  2842. let beforeNodeSelector = '.msg-txt + *';
  2843. let allLinksSelector = '.msg-txt > a, pre a';
  2844. setTimeout(function() {
  2845. Array.from(document.querySelectorAll('#content .msg-cont')).forEach(msg => {
  2846. embedLinksToX(msg, beforeNodeSelector, allLinksSelector, ctsUsers, ctsTags);
  2847. });
  2848. }, 50);
  2849. }
  2850.  
  2851. function filterArticles() {
  2852. let [filteredUsers, filteredTags] = splitUsersAndTagsLists(GM_getValue('filtered_users_and_tags', ''));
  2853. let keepHeader = GM_getValue('filtered_posts_keep_header', true);
  2854. Array.from(document.querySelectorAll('#content article[data-mid]'))
  2855. .filter(article => isFilteredX(article, filteredUsers, filteredTags))
  2856. .forEach(article => {
  2857. if (keepHeader) {
  2858. article.classList.add('filtered');
  2859. while (article.children.length > 1) { article.removeChild(article.lastChild); }
  2860. } else {
  2861. article.remove();
  2862. }
  2863. });
  2864. }
  2865.  
  2866. function filterPostComments() {
  2867. let [filteredUsers, filteredTags] = splitUsersAndTagsLists(GM_getValue('filtered_users_and_tags', ''));
  2868. let keepHeader = GM_getValue('filtered_posts_keep_header', true);
  2869. Array.from(document.querySelectorAll('#content #replies .msg-cont')).forEach(reply => {
  2870. let isFilteredComment = isFilteredX(reply, filteredUsers, filteredTags);
  2871. if (isFilteredComment) {
  2872. reply.classList.add('filteredComment');
  2873. reply.querySelector('.msg-txt').remove();
  2874. reply.querySelector('.embedContainer')?.remove();
  2875. reply.querySelector('.msg-media')?.remove();
  2876. reply.querySelector('.msg-comment-target').remove();
  2877. let linksDiv = reply.querySelector('.msg-links');
  2878. linksDiv.querySelector('.a-thread-comment').remove();
  2879. linksDiv.innerHTML = linksDiv.innerHTML.replace(' · ', '');
  2880. if (!keepHeader) {
  2881. reply.classList.add('headless');
  2882. reply.querySelector('.msg-header').remove();
  2883. }
  2884. }
  2885. });
  2886. }
  2887.  
  2888. function setHighlightOnHover(hoverTarget, highlightable) {
  2889. highlightable.classList.toggle('highlightable', true);
  2890. hoverTarget.addEventListener('mouseenter', e => highlightable.classList.toggle('hoverHighlight', true), false);
  2891. hoverTarget.addEventListener('mouseleave', e => highlightable.classList.toggle('hoverHighlight', false), false);
  2892. }
  2893.  
  2894. function setMoveIntoViewOnHover(hoverTarget, avoidTarget, movable, avoidMargin=0, threshold=0) {
  2895. if (!movable) { return; }
  2896.  
  2897. function checkFullyVisible(node, threshold=0) {
  2898. let rect = node.getBoundingClientRect();
  2899. let viewHeight = Math.max(document.documentElement.clientHeight, window.innerHeight);
  2900. let above = rect.top + threshold < 0;
  2901. let below = rect.bottom - threshold - viewHeight >= 0;
  2902. return !above && !below;
  2903. }
  2904.  
  2905. function resetMovementArtifacts(node) {
  2906. node.removeEventListener('transitionend', afterBackTransition, false);
  2907. node.classList.toggle('moved', false);
  2908. node.classList.toggle('hoverHighlight', false);
  2909. }
  2910.  
  2911. function moveNodeIntoView(node, avoidNode, avoidMargin=0, threshold=0) {
  2912. resetMovementArtifacts(node);
  2913. node.classList.toggle('hoverHighlight', true);
  2914. let onscreen = checkFullyVisible(node, threshold);
  2915. if (!onscreen) {
  2916. let parentNodeRect = node.parentNode.getBoundingClientRect();
  2917. let avoidNodeRect = avoidNode.getBoundingClientRect();
  2918. let [w, h] = [node.offsetWidth, node.offsetHeight];
  2919. let vtop = parentNodeRect.top;
  2920. let atop = avoidNodeRect.top - avoidMargin;
  2921. let ah = avoidNodeRect.height + 2*avoidMargin;
  2922. let wh = window.innerHeight;
  2923. let isAbove = (vtop < atop);
  2924. let moveAmount = isAbove ? (0-vtop-h+atop) : (0-vtop+atop+ah);
  2925. let availableSpace = isAbove ? (atop - avoidMargin) : (wh - atop - ah + avoidMargin);
  2926. if ((Math.abs(moveAmount) > threshold) && (availableSpace > threshold*2)) {
  2927. let s = getComputedStyle(node);
  2928. node.classList.toggle('moved', true);
  2929. node.style.marginTop = `${moveAmount}px`;
  2930. node.parentNode
  2931. .querySelector('.movable + .placeholder')
  2932. .setAttribute('style', `width: ${w}px; height: ${h}px; margin: ${s.margin};`);
  2933. }
  2934. }
  2935. }
  2936.  
  2937. function afterBackTransition(event) { resetMovementArtifacts(event.target); }
  2938.  
  2939. function moveNodeBack(node) {
  2940. const eventType = 'transitionend';
  2941. if (node.classList.contains('moved')) {
  2942. let parentNodeRect = node.parentNode.getBoundingClientRect();
  2943. let nodeRect = node.getBoundingClientRect();
  2944. if (Math.abs(parentNodeRect.top - nodeRect.top) > 1) {
  2945. node.addEventListener(eventType, afterBackTransition, false);
  2946. } else {
  2947. resetMovementArtifacts(node);
  2948. }
  2949. node.style.marginTop = '';
  2950. } else {
  2951. node.classList.toggle('hoverHighlight', false);
  2952. }
  2953. }
  2954.  
  2955. hoverTarget.addEventListener('mouseenter', e => { moveNodeIntoView(movable, avoidTarget, avoidMargin, threshold); }, false);
  2956. hoverTarget.addEventListener('mouseleave', e => { moveNodeBack(movable); }, false);
  2957. movable.parentNode.classList.toggle('movableContainer', true);
  2958. movable.classList.toggle('movable', true);
  2959. if (!movable.parentNode.querySelector('.movable + .placeholder')) {
  2960. let pldr = document.createElement('div');
  2961. pldr.className = 'placeholder';
  2962. insertAfter(pldr, movable);
  2963. }
  2964. }
  2965.  
  2966. function bringCommentsIntoViewOnHover() {
  2967. let replies = Array.from(document.querySelectorAll('#replies li'));
  2968. let nodes = {};
  2969. replies.forEach(r => { nodes[r.id] = r.querySelector('div.msg-cont'); });
  2970. replies.forEach(r => {
  2971. let replyToLink = Array.from(r.querySelectorAll('.msg-links a')).find(a => /\d+/.test(a.hash));
  2972. if (replyToLink) {
  2973. let rtid = replyToLink.hash.replace(/^#/, '');
  2974. setMoveIntoViewOnHover(replyToLink, nodes[r.id], nodes[rtid], 5, 30);
  2975. }
  2976. });
  2977. }
  2978.  
  2979. function checkReply(allPostsSelector, ...replySelectors) {
  2980. getMyUserNameAsync().then(uname => {
  2981. Array.from(document.querySelectorAll(allPostsSelector))
  2982. .filter(p => (getPostUserName(p) != uname) && (!replySelectors.some(s => p.querySelector(s))))
  2983. .forEach(p => p.classList.add('readonly'));
  2984. }).catch(err => console.info(err));
  2985. }
  2986.  
  2987. function checkReplyArticles() {
  2988. checkReply('#content article[data-mid]', 'nav.l > a.a-comment');
  2989. }
  2990.  
  2991. function checkReplyPost() {
  2992. checkReply('#content div.msg-cont', 'a.a-thread-comment', '.msg-comment');
  2993. }
  2994.  
  2995. function markReadonlyPost() {
  2996. if (document.title.match(/\B\*readonly\b/)) {
  2997. document.querySelector('#content .msg-cont .msg-tags')
  2998. .insertAdjacentHTML('beforeend', '<a class="virtualTag" href="#readonly">readonly</a>');
  2999. }
  3000. }
  3001.  
  3002. function makeSettingsCheckbox(caption, id, defaultState) {
  3003. let label = document.createElement('label');
  3004. let cb = document.createElement('input');
  3005. cb.type = 'checkbox';
  3006. cb.checked = GM_getValue(id, defaultState);
  3007. cb.onclick = (e => GM_setValue(id, cb.checked));
  3008. label.appendChild(cb);
  3009. label.appendChild(document.createTextNode(caption));
  3010. return label;
  3011. }
  3012.  
  3013. function makeSettingsTextbox(caption, id, defaultString, placeholder) {
  3014. let label = document.createElement('label');
  3015. let wrapper = document.createElement('div');
  3016. wrapper.className = 'ta-wrapper';
  3017. let textarea = document.createElement('textarea');
  3018. textarea.className = id;
  3019. textarea.placeholder = placeholder;
  3020. textarea.title = placeholder;
  3021. textarea.value = GM_getValue(id, defaultString);
  3022. textarea.oninput = (e => GM_setValue(id, textarea.value));
  3023. wrapper.appendChild(textarea);
  3024. label.appendChild(document.createTextNode('' + caption + ': '));
  3025. label.appendChild(wrapper);
  3026. return label;
  3027. }
  3028.  
  3029. function showUserscriptSettings() {
  3030. let h1 = document.createElement('h1');
  3031. h1.textContent = 'Tweaks';
  3032.  
  3033. let uiFieldset = document.createElement('fieldset');
  3034. { // UI
  3035. let uiLegend = document.createElement('legend');
  3036. uiLegend.textContent = 'UI';
  3037. uiFieldset.appendChild(uiLegend);
  3038.  
  3039. let list1 = document.createElement('ul');
  3040. userscriptFeatures
  3041. .filter(item => !!item.name && !!item.id)
  3042. .map(item => makeSettingsCheckbox(item.name, item.id, item.enabledByDefault))
  3043. .map(cb => wrapIntoTag(wrapIntoTag(cb, 'p'), 'li'))
  3044. .forEach(item => list1.appendChild(item));
  3045. uiFieldset.appendChild(list1);
  3046. }
  3047.  
  3048. let embeddingFieldset = document.createElement('fieldset');
  3049. { // Embedding
  3050. let embeddingLegend = document.createElement('legend');
  3051. embeddingLegend.textContent = 'Embedding';
  3052.  
  3053. let embeddingTable = document.createElement('table');
  3054. embeddingTable.style.width = '100%';
  3055. getEmbeddableLinkTypes().forEach(linkType => {
  3056. let row = document.createElement('tr');
  3057. row.appendChild(wrapIntoTag(makeSettingsCheckbox(linkType.name, linkType.id, linkType.onByDefault), 'td'));
  3058. row.appendChild(wrapIntoTag(makeSettingsCheckbox('Click to show', 'cts_' + linkType.id, linkType.ctsDefault), 'td'));
  3059. embeddingTable.appendChild(row);
  3060. });
  3061.  
  3062. let domainsWhitelist = makeSettingsTextbox('Domains whitelist ("*" wildcard is supported)', 'domains_whitelist', getDefaultDomainWhitelist().join('\n'), 'One domain per line. "*" wildcard is supported');
  3063.  
  3064. let moveIntoViewOnSamePageCheckbox = makeSettingsCheckbox('Ссылки на ту же страницу не встраивать, а показывать при наведении', 'enable_move_into_view_on_same_page', true);
  3065. let updateLinkTextCheckbox = makeSettingsCheckbox('Обновлять текст ссылок, если возможно (например, "juick.com" на #123456/7)', 'enable_link_text_update', true);
  3066. let ctsUsersAndTags = makeSettingsTextbox('Всегда использовать "Click to show" для этих юзеров и тегов в ленте', 'cts_users_and_tags', '', '@users and *tags separated with space or comma');
  3067. ctsUsersAndTags.style = 'display: flex; flex-direction: column; align-items: stretch;';
  3068.  
  3069. setContent(
  3070. embeddingFieldset,
  3071. embeddingLegend,
  3072. embeddingTable,
  3073. wrapIntoTag(domainsWhitelist, 'p'),
  3074. document.createElement('hr'),
  3075. wrapIntoTag(ctsUsersAndTags, 'p'),
  3076. wrapIntoTag(updateLinkTextCheckbox, 'p'),
  3077. wrapIntoTag(moveIntoViewOnSamePageCheckbox, 'p')
  3078. );
  3079. }
  3080.  
  3081. let filteringFieldset = document.createElement('fieldset');
  3082. { // Filtering
  3083. let filteringLegend = document.createElement('legend');
  3084. filteringLegend.textContent = 'Filtering';
  3085.  
  3086. let filteringUsersAndTags = makeSettingsTextbox('Убирать посты этих юзеров или с этими тегами из общей ленты', 'filtered_users_and_tags', '', '@users and *tags separated with space or comma');
  3087. filteringUsersAndTags.style = 'display: flex; flex-direction: column; align-items: stretch;';
  3088. let keepHeadersCheckbox = makeSettingsCheckbox('Оставлять заголовки постов', 'filtered_posts_keep_header', true);
  3089. let filterCommentsCheckbox = makeSettingsCheckbox('Также фильтровать комментарии этих юзеров', 'filter_comments_too', false);
  3090.  
  3091. setContent(
  3092. filteringFieldset,
  3093. filteringLegend,
  3094. wrapIntoTag(filteringUsersAndTags, 'p'),
  3095. wrapIntoTag(keepHeadersCheckbox, 'p'),
  3096. wrapIntoTag(filterCommentsCheckbox, 'p')
  3097. );
  3098. }
  3099.  
  3100. let resetButton = document.createElement('button');
  3101. { // Reset button
  3102. resetButton.textContent = 'Reset userscript settings to default';
  3103. resetButton.onclick = function(e){
  3104. if (!confirm('Are you sure you want to reset Tweaks settings to default?')) { return; }
  3105. GM_listValues().slice().forEach(key => GM_deleteValue(key));
  3106. showUserscriptSettings();
  3107. alert('Done!');
  3108. };
  3109. }
  3110.  
  3111. let versionInfoFieldset = document.createElement('fieldset');
  3112. { // Version info
  3113. let versionInfoLegend = document.createElement('legend');
  3114. versionInfoLegend.textContent = 'Version info';
  3115. let ver1 = document.createElement('p');
  3116. let ver2 = document.createElement('p');
  3117. ver1.textContent = 'Userscript version: ' + GM_info.script.version;
  3118. ver2.textContent = 'Greasemonkey (or your script runner) version: ' + GM_info.version;
  3119. setContent(versionInfoFieldset, versionInfoLegend, ver1, ver2);
  3120. }
  3121.  
  3122. let support = document.createElement('p');
  3123. support.innerHTML = 'Feedback and feature requests <a href="//juick.com/killy/?tag=userscript">here</a>.';
  3124.  
  3125. Array.from(document.querySelectorAll('#content article')).forEach(ar => ar.style.display = 'none');
  3126. let article = document.createElement('article');
  3127. let other = document.querySelector('#content article');
  3128. other.parentNode.insertBefore(article, other);
  3129. setContent(article, h1, uiFieldset, embeddingFieldset, filteringFieldset, resetButton, versionInfoFieldset, support);
  3130. article.className = 'tweaksSettings';
  3131. window.scrollTo(0, 0);
  3132. }
  3133.  
  3134. function addTweaksSettingsButton() {
  3135. let tabsList = document.querySelector('#pagetabs > div');
  3136. let aNode = document.createElement('a');
  3137. aNode.textContent = 'Tweaks';
  3138. aNode.href = '#tweaks';
  3139. aNode.onclick = (e => { e.preventDefault(); showUserscriptSettings(); });
  3140. tabsList.appendChild(aNode);
  3141. }
  3142.  
  3143. function addTweaksSettingsFooterLink() {
  3144. let footerLinks = document.querySelector('#footer-right');
  3145. let aNode = document.createElement('a');
  3146. aNode.textContent = 'Tweaks';
  3147. aNode.href = '#tweaks';
  3148. aNode.onclick = (e => { e.preventDefault(); showUserscriptSettings(); });
  3149. footerLinks.insertBefore(aNode, footerLinks.firstChild);
  3150. }
  3151.  
  3152. function updateUserRecommendationStats(userId, pagesPerCall) {
  3153. let article = document.createElement('article');
  3154. let userCounters = {};
  3155. let totalRecs = 0;
  3156.  
  3157. function recUpdate(depth, oldestMid, oldestDate) {
  3158. if (depth <= 0) { return; }
  3159.  
  3160. let beforeStr = (oldestMid) ? ('&before=' + oldestMid) : '';
  3161. let url = `//juick.com/${userId}/?show=recomm${beforeStr}`;
  3162. GM_xmlhttpRequest({
  3163. method: 'GET',
  3164. url: setProto(url),
  3165. onload: function(response) {
  3166. if (response.status != 200) {
  3167. console.log(`${userId}: failed with ${response.status}, ${response.statusText}`);
  3168. return;
  3169. }
  3170.  
  3171. const articleRe = /<article[\s\S]+?<\/article>/gmi;
  3172. let articles = response.responseText.match(articleRe);
  3173. if (!articles) {
  3174. console.log('no more articles in response');
  3175. return;
  3176. }
  3177.  
  3178. totalRecs = totalRecs + articles.length;
  3179. let hasMore = (articles.length > 15);
  3180. let oldestArticle = articles[articles.length - 1];
  3181.  
  3182. const midRe = /data-mid="(\d+)"/i;
  3183. const dateRe = /datetime\=\"([^\"]+) ([^\"]+)\"/i;
  3184. let [, oldestMid] = midRe.exec(oldestArticle);
  3185. let [, oldestDatePart, oldestTimePart] = dateRe.exec(oldestArticle);
  3186. oldestDate = new Date(`${oldestDatePart}T${oldestTimePart}`);
  3187.  
  3188. const userRe = /<span>([-\w]+)<\/span>/i;
  3189. const userAvatarRe = /<img src="\/i\/a\/(\d+)-[0-9a-fA-F]+\.png" alt="[^\"]+"\/?>/i;
  3190. let authors = articles.map(article => {
  3191. let postAuthorId = (userRe.exec(article))[1];
  3192. let postAuthorAvatar = (userAvatarRe.exec(article) || {0:''})[0];
  3193. return {id: postAuthorId, avatar: postAuthorAvatar};
  3194. });
  3195. for (let i in authors) {
  3196. let id = authors[i].id;
  3197. let avatar = authors[i].avatar;
  3198. if (id in userCounters) {
  3199. userCounters[id].recs = userCounters[id].recs + 1;
  3200. } else {
  3201. userCounters[id] = {id: id, avatar: avatar, recs: 1};
  3202. }
  3203. }
  3204.  
  3205. let sortedUsers = Object.values(userCounters).sort((a, b) => b.recs - a.recs);
  3206.  
  3207. removeAllFrom(article);
  3208.  
  3209. if (hasMore && (depth == 1)) {
  3210. let moreButton = document.createElement('button');
  3211. moreButton.style = 'float: right;';
  3212. moreButton.textContent = 'Check older recommendations';
  3213. moreButton.onclick = (e => recUpdate(pagesPerCall, oldestMid, oldestDate));
  3214. article.appendChild(moreButton);
  3215. }
  3216.  
  3217. let datePNode = document.createElement('p');
  3218. datePNode.textContent = `${totalRecs} recommendations since ${oldestDate.toLocaleDateString('ru-RU')}`;
  3219. article.appendChild(datePNode);
  3220.  
  3221. let avgPNode = document.createElement('p');
  3222. let now = new Date();
  3223. let days = ((now - oldestDate) / 1000 / 60 / 60 / 24);
  3224. let avg = totalRecs / days;
  3225. avgPNode.textContent = avg > 1.0
  3226. ? '' + avg.toFixed(3) + ' recommendations per day'
  3227. : 'one recommendation in ' + (1 / avg).toFixed(2) + ' days';
  3228. article.appendChild(avgPNode);
  3229.  
  3230. let userStrings = sortedUsers.map(x => `<li><a href="/${x.id}/">${x.avatar}${x.id}</a> / ${x.recs}</li>`);
  3231. let ulNode = document.createElement('ul');
  3232. ulNode.className = 'recUsers';
  3233. ulNode.innerHTML = userStrings.join('');
  3234. article.appendChild(ulNode);
  3235.  
  3236. if (hasMore) {
  3237. setTimeout(() => recUpdate(depth - 1, oldestMid, oldestDate), 100);
  3238. } else {
  3239. console.log('no more recommendations');
  3240. }
  3241. }
  3242. });
  3243.  
  3244. } // recUpdate
  3245.  
  3246. let contentBlock = document.querySelector('section#content');
  3247. setContent(contentBlock, article);
  3248. recUpdate(pagesPerCall, undefined, undefined);
  3249. }
  3250.  
  3251. function addIRecommendLink() {
  3252. let userId = getColumnUserName();
  3253. let asideColumn = document.querySelector('aside#column');
  3254. let ustatsList = asideColumn.querySelector('#ustats > ul');
  3255. let li2 = ustatsList.querySelector('li:nth-child(2)');
  3256. let liNode = document.createElement('li');
  3257. let aNode = document.createElement('a');
  3258. aNode.textContent = 'Я рекомендую';
  3259. aNode.href = '#irecommend';
  3260. aNode.onclick = (e => { e.preventDefault(); updateUserRecommendationStats(userId, 3); });
  3261. liNode.appendChild(aNode);
  3262. insertAfter(liNode, li2);
  3263. }
  3264.  
  3265. function addMentionsLink() {
  3266. let userId = getColumnUserName();
  3267. let asideColumn = document.querySelector('aside#column');
  3268. let ustatsList = asideColumn.querySelector('#ustats > ul');
  3269. let li2 = ustatsList.querySelector('li:nth-child(2)');
  3270. let liNode = document.createElement('li');
  3271. let aNode = document.createElement('a');
  3272. aNode.textContent = 'Упоминания';
  3273. aNode.href = '/?search=%40' + userId;
  3274. liNode.appendChild(aNode);
  3275. insertAfter(liNode, li2);
  3276. }
  3277.  
  3278. function makeElementExpandable(element) {
  3279. let aNode = document.createElement('a');
  3280. aNode.className = 'expandLink';
  3281. aNode.innerHTML = '<span>Expand</span>';
  3282. aNode.href = '#expand';
  3283. aNode.onclick = (e => {
  3284. e.preventDefault();
  3285. element.classList.remove('expandable');
  3286. element.removeChild(aNode);
  3287. });
  3288. element.appendChild(aNode);
  3289. element.classList.add('expandable');
  3290. }
  3291.  
  3292. function limitArticlesHeight () {
  3293. let maxHeight = window.innerHeight * 0.7;
  3294. Array.from(document.querySelectorAll('#content article[data-mid] > .msg-txt')).forEach(p => {
  3295. if (p.offsetHeight > maxHeight) {
  3296. makeElementExpandable(p);
  3297. }
  3298. });
  3299. }
  3300.  
  3301. function addStyle() {
  3302. let article = document.querySelector('#content article') || document.querySelector('#content .msg-cont');
  3303. let [br, bg, bb] = parseRgbColor(getComputedStyle(document.documentElement).backgroundColor, [255,255,255]);
  3304. let [tr, tg, tb] = parseRgbColor(getComputedStyle(document.body).color, [34,34,34]);
  3305. let [ar, ag, ab] = (article) ? parseRgbColor(getComputedStyle(article).backgroundColor, [br, bg, bb]) : [br, bg, bb];
  3306. const rgba = (r,g,b,a) => `rgba(${r},${g},${b},${a})`;
  3307. let bg10 = rgba(br, bg, bb, 1.0);
  3308. let abg10 = rgba(ar, ag, ab, 1.0);
  3309. let color10 = rgba(tr, tg, tb, 1.0);
  3310. let color07 = rgba(tr, tg, tb, 0.7);
  3311. let color03 = rgba(tr, tg, tb, 0.3);
  3312. let color02 = rgba(tr, tg, tb, 0.2);
  3313.  
  3314. if (GM_getValue('enable_tags_min_width', true)) {
  3315. GM_addStyle('.tagsContainer a { min-width: 25px; display: inline-block; text-align: center; }');
  3316. }
  3317. GM_addStyle(/*css*/`
  3318. .embedContainer { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; padding: 0; margin: 30px -3px 15px -3px; }
  3319. .embedContainer > * { box-sizing: border-box; flex-grow: 1; margin: 3px; min-width: 49%; }
  3320. .embedContainer > .compact { flex-grow: 0; }
  3321. .embedContainer > .singleColumn { min-width: 90%; }
  3322. .embedContainer > .codePost .desc { font-family: monospace; white-space: pre-wrap; font-size: 9pt; line-height: 120%; }
  3323. .embedContainer .picture img { display: block; }
  3324. .embedContainer img,
  3325. .embedContainer video { max-width: 100%; max-height: 80vh; }
  3326. .embedContainer audio { width: 100%; }
  3327. .embedContainer iframe { overflow:hidden; resize: vertical; display: block; }
  3328. .embedContainer > .embed { width: 100%; border: 1px solid ${color02}; padding: 8px; display: flex; flex-direction: column; }
  3329. .embedContainer > .embed.loading,
  3330. .embedContainer > .embed.failed { text-align: center; color: ${color07}; padding: 0; }
  3331. .embedContainer > .embed.failed { cursor: pointer; }
  3332. .embedContainer .embed .cts { margin: 0; }
  3333. .embed .top,
  3334. .embed .bottom { display: flex; flex-shrink: 0; justify-content: space-between; }
  3335. .embed .top { margin-bottom: 8px; }
  3336. .embed .lang,
  3337. .embed .date,
  3338. .embed .date > a,
  3339. .embed .likes > a,
  3340. .embed .replies > a,
  3341. .embed .title { color: ${color07}; }
  3342. .embed .date { font-size: small; text-align: right; }
  3343. .embed .likes,
  3344. .embed .faves,
  3345. .embed .votes,
  3346. .embed .replies { font-size: small; white-space:nowrap; margin-left: 12px; }
  3347. .embed .likes .icon,
  3348. .embed .faves .icon,
  3349. .embed .votes .icon,
  3350. .embed .replies .icon { width: 20px; height: 20px; }
  3351. .embed .desc { margin-bottom: 8px; max-height: 55vh; overflow-y: auto; }
  3352. .twi.embed > .cts > .placeholder { display: inline-block; }
  3353. .embedContainer > .embed.twi .cts > .placeholder { border: 0; }
  3354. .embedContainer > .bandcamp:not(.loading):not(.cts) { max-width: 480px; }
  3355. .juickEmbed > .top > .top-right { display: flex; flex-direction: column; flex: 1; }
  3356. .juickEmbed > .top > .top-right > .top-right-1st { display: flex; flex-direction: row; justify-content: space-between; }
  3357. .juickEmbed > .bottom > .right { margin-top: 5px; display: flex; flex: 0; }
  3358. .gistEmbed .gist-file .gist-data .blob-wrapper,
  3359. .gistEmbed .gist-file .gist-data article { max-height: 70vh; overflow-y: auto; }
  3360. .gistEmbed.embed.loaded { border-width: 0px; padding: 0; }
  3361. .wordpress .desc { max-height: 70vh; overflow-y: auto; line-height: 160%; }
  3362. .xkcd .comic { display: block; margin: 0 auto; }
  3363. .arxiv .top { flex-direction: column; }
  3364. .arxiv .date { text-align: left; }
  3365. .arxiv .bottom { margin-top: 8px; }
  3366. .tumblr { max-height: 86vh; overflow-y: auto; }
  3367. .tumblr.loading iframe { visibility: hidden; height: 0px; }
  3368. .reddit { max-height: 75vh; overflow-y: auto; }
  3369. .reddit iframe { resize: none; }
  3370. .reddit.loading > blockquote,
  3371. .reddit.loading > div { display: none; }
  3372. .fbEmbed:not(.fallback) iframe { resize: none; }
  3373. .fbEmbed.loading > div { visibility: hidden; height: 0px; }
  3374. .imgur iframe { border-width: 0px; }
  3375. .imgur.loading iframe { visibility: hidden; height: 0px; }
  3376. .embedContainer > .gelbooru.embed,
  3377. .embedContainer > .danbooru.embed,
  3378. .embedContainer > .konachan.embed,
  3379. .embedContainer > .yandere.embed { width: 49%; }
  3380. .embedContainer > .booru.embed { position: relative; }
  3381. .danbooru.embed.loaded { min-height: 130px; }
  3382. .danbooru.embed .booru-tags { display: none; }
  3383. .danbooru.embed:hover .booru-tags { display: block; }
  3384. .booru.embed .bottom-right { position:absolute; bottom: 8px; right: 8px; font-size: small; text-align: right; color: ${color07}; display: flex; flex-direction: column; }
  3385. .derpibooru.embed > .bottom { margin-top: 5px; }
  3386. .derpibooru.embed > .bottom > .right { display: flex; flex: 0; }
  3387. article.nsfw .embedContainer img,
  3388. article.nsfw .embedContainer iframe,
  3389. .embed .rating_e,
  3390. .embed img.nsfw { opacity: 0.1; }
  3391. article.nsfw .embedContainer img:hover,
  3392. article.nsfw .embedContainer iframe:hover,
  3393. article.nsfw .embedContainer .msg-avatar img,
  3394. .embed .rating_e:hover,
  3395. .embed img.nsfw:hover { opacity: 1.0; }
  3396. .embed.notEmbed { display: none; }
  3397. .embedLink.arrow:not(.notEmbed):after { content: '\\00a0↓' } /* &nbsp; */
  3398. .tweaksSettings * { box-sizing: border-box; }
  3399. .tweaksSettings table { border-collapse: collapse; }
  3400. .tweaksSettings tr { border-bottom: 1px solid transparent; }
  3401. .tweaksSettings tr:hover { background: rgba(127,127,127,.1) }
  3402. .tweaksSettings td > * { display: block; width: 100%; height: 100%; }
  3403. .tweaksSettings > button { margin-top: 25px; }
  3404. .tweaksSettings .ta-wrapper { width: 100%; height: 100%; }
  3405. .tweaksSettings .ta-wrapper > textarea { width: 100%; height: 100%; }
  3406. .tweaksSettings textarea.domains_whitelist { min-height: 72pt; }
  3407. .embedContainer > .cts { width: 100%; }
  3408. .embedContainer .cts > .placeholder { border: 1px dotted ${color03}; color: ${color07}; text-align: center; cursor: pointer; word-wrap: break-word; }
  3409. .cts > .placeholder { position: relative; }
  3410. .cts > .placeholder > .icon { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 10; color: ${bg10}; -webkit-filter: drop-shadow( 0 0 10px ${color10} ); filter: drop-shadow( 0 0 10px ${color10} ); }
  3411. .embed .cts .icon { display: flex; align-items: center; justify-content: center; }
  3412. .embed .cts .icon > svg { max-width: 100px; max-height: 100px; }
  3413. .filtered header { overflow: hidden; }
  3414. .filtered .msg-avatar { margin-bottom: 0px; }
  3415. .filteredComment.headless .msg-links { margin: 0px; }
  3416. article.readonly > .msg-txt,
  3417. div.readonly > .msg-txt { opacity: 0.55; }
  3418. .movable { transition: all 0.2s ease-out 0.2s; transition-property: margin, margin-top; }
  3419. .movable.moved { position: absolute; z-index: 10; }
  3420. .movable.hoverHighlight,
  3421. .highlightable.hoverHighlight { outline: 1px solid ${color10} !important; }
  3422. .movableContainer { position: relative; }
  3423. .movableContainer > .placeholder { display: none; }
  3424. .movableContainer .moved+.placeholder { display: block; }
  3425. .recUsers img { height: 32px; margin: 2px; margin-right: 6px; vertical-align: middle; width: 32px; }
  3426. .users.sorted > span { width: 300px; }
  3427. a.virtualTag { border: 1px dotted ${color07}; border-radius: 15px; }
  3428. .expandable { max-height: 50vh; overflow-y: hidden; position: relative; }
  3429. .expandable:before { content:''; pointer-events:none; position:absolute; left:0; top:0; width:100%; height:100%; background:linear-gradient(to top, ${abg10} 15px, transparent 120px); }
  3430. .expandable > a.expandLink { display: block; position:absolute; width: 100%; bottom: 2px; text-align: center; font-size: 10pt; color: ${color07}; }
  3431. .expandable > a.expandLink > span { border: 1px dotted ${color07}; border-radius: 15px; padding: 0 15px 2px; background: ${abg10}; }
  3432.  
  3433. #oldNewMessage { background: ${abg10}; margin-bottom: 20px; padding: 0; display: flex; flex-direction: column; }
  3434. #oldNewMessage * { box-sizing: border-box; }
  3435. #oldNewMessage > textarea { resize: vertical; padding: 12px 16px; }
  3436. #oldNewMessage.active { padding: 8px; }
  3437. #oldNewMessage #bottomBlock { display: flex; flex-direction: row; }
  3438. #oldNewMessage:not(.active) #bottomBlock,
  3439. #oldNewMessage:not(.active) #charCounterBlock,
  3440. #oldNewMessage:not(.active) .tagsContainer { display: none; }
  3441. #oldNewMessage #bottomLeftBlock { flex-grow: 1; display: flex; flex-direction: column; max-width: 100%; }
  3442. #oldNewMessage .tags,
  3443. #oldNewMessage .tagsContainer,
  3444. #oldNewMessage .subm,
  3445. #oldNewMessage #charCounterBlock,
  3446. #oldNewMessage #imgUploadBlock { margin-top: 6px; }
  3447. #oldNewMessage.active textarea,
  3448. #oldNewMessage .txt { padding: 2px 4px; }
  3449. #oldNewMessage textarea,
  3450. #oldNewMessage .imgLink,
  3451. #oldNewMessage .tags { border: 1px solid #ccc; }
  3452. #oldNewMessage .subm,
  3453. #oldNewMessage .btn_like { background: #eeeee5; border: 1px solid #ccc; color: black; padding: 2px 4px; width: 150px; cursor: pointer; text-align: center; }
  3454. #oldNewMessage .subm[disabled] { color: #999; }
  3455. #imgUploadBlock > * { display: inline-block; }
  3456. #imgUploadBlock .info { width: 25px; height: 25px; vertical-align: text-bottom; }
  3457. #imgUploadBlock #image_upload { visibility: hidden; width: 0; position: absolute; }
  3458. #imgUploadBlock .imgLink { width: 150px; }
  3459. #oldNewMessage:not(.withImage) #imagePreview { display: none; }
  3460. #oldNewMessage #imagePreview { position: relative; margin: 6px 0 0 6px; }
  3461. #oldNewMessage #imagePreview img { display: block; max-height: 120px; max-width: 150px; }
  3462. #clear_button { position: absolute; left: 0; top: 0; width: 100%; height: 100%; }
  3463. #clear_button > svg { position: absolute; right: 0%; top: 0%; width: 20%; height: 20%; }
  3464. #clear_button:hover { background: rgba(255, 255, 255, 0.25); }
  3465. .flexSpacer { flex-grow: 1; }
  3466. #charCounterBlock > div { height: 100; }
  3467. #charCounterBlock:not(.invalid) > div { background: #999; height: 1px; }
  3468. #charCounterBlock.invalid { text-align: right; }
  3469. #charCounterBlock.invalid > div { color: #c00; }
  3470. ul#replies li .clickPopup,
  3471. ul#replies li .hoverPopup { display: inline-block; float: right; }
  3472. .clickPopup,
  3473. .hoverPopup { position: relative; }
  3474. .clickPopup .clickContainer,
  3475. .hoverPopup .hoverContainer { visibility: hidden; position: absolute; z-index: 1; bottom: 100%; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; background: #fff; box-shadow: 0 0 3px rgba(0,0,0,.16); }
  3476. .clickPopup .clickContainer > *,
  3477. .hoverPopup .hoverContainer > * { margin: 2px 4px; }
  3478. .clickPopup.expanded .clickContainer,
  3479. .hoverPopup.expanded .hoverContainer,
  3480. .hoverPopup:hover .hoverContainer { visibility: visible; }
  3481. .clickPopup,
  3482. .hoverPopup { margin-left: 15px; }
  3483. .msgthread .hoverPopup { margin-left: 0; margin-right: 15px; color: #88958d; margin-top: 12px; }
  3484. .clickPopup:not(.expanded) { cursor: pointer; }
  3485. @keyframes highlight { 0% { outline-color: rgba(127, 127, 127 , 1.0); } 100% { outline-color: rgba(127, 127, 127 , 0.0); } }
  3486. .blinkOnce { outline-width: 1px; outline-style: solid; animation: highlight 1s; }
  3487. .confirmationItem { color: red; border: 1px solid red; padding: 2px 7px; }
  3488. .confirmationItem:hover { background: #ffeeee; }
  3489. `);
  3490. if (GM_getValue('unset_code_style', false)) {
  3491. GM_addStyle(`
  3492. pre { background: unset; color: unset; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; font-size: 9pt; line-height: 120%; }
  3493. `);
  3494. }
  3495. }
  3496.  
  3497. // #endregion