diff --git a/4chan_x.user.js b/4chan_x.user.js index 4ab6593d7..a5e756c94 100644 --- a/4chan_x.user.js +++ b/4chan_x.user.js @@ -43,7 +43,7 @@ */ (function() { - var $, $$, Anonymize, ArchiveLink, Board, Build, Clone, Conf, Config, CustomCSS, DeleteLink, DownloadLink, ExpandComment, ExpandThread, Favicon, FileInfo, Filter, Fourchan, Get, Header, ImageExpand, ImageHover, ImageReplace, Keybinds, Main, Menu, Misc, Nav, Notification, Polyfill, Post, QR, QuoteBacklink, QuoteCT, QuoteInline, QuoteOP, QuotePreview, QuoteStrikeThrough, QuoteYou, Quotify, Recursive, Redirect, RelativeDates, ReplyHiding, Report, ReportLink, RevealSpoilers, Sauce, Settings, Thread, ThreadExcerpt, ThreadHiding, ThreadStats, ThreadUpdater, ThreadWatcher, Time, UI, Unread, c, d, doc, g, + var $, $$, Anonymize, ArchiveLink, Board, Build, Clone, Conf, Config, CustomCSS, DeleteLink, DownloadLink, ExpandComment, ExpandThread, Favicon, FileInfo, Filter, Fourchan, Get, Header, ImageExpand, ImageHover, ImageReplace, Keybinds, Linkify, Main, Menu, Misc, Nav, Notification, Polyfill, Post, QR, QuoteBacklink, QuoteCT, QuoteInline, QuoteOP, QuotePreview, QuoteStrikeThrough, QuoteYou, Quotify, Recursive, Redirect, RelativeDates, ReplyHiding, Report, ReportLink, RevealSpoilers, Sauce, Settings, Thread, ThreadExcerpt, ThreadHiding, ThreadStats, ThreadUpdater, ThreadWatcher, Time, UI, Unread, c, d, doc, g, __slice = [].slice, __hasProp = {}.hasOwnProperty, __extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }; @@ -64,6 +64,11 @@ 'Custom CSS': [false, 'Apply custom CSS to 4chan.'], 'Check for Updates': [true, 'Check for updated versions of 4chan X Beta.'] }, + 'Linkification': { + 'Linkify': [true, 'Convert text into links where applicable.'], + 'Embedding': [true, 'Embed supported services.'], + 'Link Title': [true, 'Replace the link of a supported site with its actual title. Currently Supported: YouTube, Vimeo, SoundCloud'] + }, 'Filtering': { 'Anonymize': [false, 'Turn everyone Anonymous.'], 'Filter': [true, 'Self-moderation placebo.'], @@ -820,11 +825,13 @@ return style; }, x: function(path, root) { - if (root == null) { - root = d.body; - } + root || (root = d.body); return d.evaluate(path, root, null, 8, null).singleNodeValue; }, + X: function(path, root) { + root || (root = d.body); + return d.evaluate(path, root, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null); + }, addClass: function(el, className) { return el.classList.add(className); }, @@ -840,12 +847,15 @@ tn: function(s) { return d.createTextNode(s); }, + frag: function() { + return d.createDocumentFragment(); + }, nodes: function(nodes) { var frag, node, _i, _len; if (!(nodes instanceof Array)) { return nodes; } - frag = d.createDocumentFragment(); + frag = $.frag(); for (_i = 0, _len = nodes.length; _i < _len; _i++) { node = nodes[_i]; frag.appendChild(node); @@ -5973,6 +5983,262 @@ } }; + Linkify = { + init: function() { + if (g.VIEW === 'catalog' || !Conf['Linkify']) { + return; + } + return Post.prototype.callbacks.push({ + name: 'Linkify', + cb: this.node + }); + }, + regString: /(\b([a-z]+:\/\/|[a-z]{3,}\.[-a-z0-9]+\.[a-z]+|[-a-z0-9]+\.[a-z]|[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+|[a-z]{3,}:[a-z0-9?]|[a-z0-9._%+-:]+@[a-z0-9.-]+\.[a-z0-9])[^\s'"]+)/gi, + cypher: $.el('div'), + node: function() { + var a, child, cypher, cypherText, data, embedder, i, index, len, link, links, lookahead, name, next, node, nodes, snapshot, spoiler, text, _i, _j, _k, _l, _len, _len1, _len2, _len3, _ref, _ref1, _ref2, _results; + if (this.isClone && Conf['Embedding']) { + _ref = $$('.embedder', this.nodes.comment); + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + embedder = _ref[_i]; + $.on(embedder, Linkify.toggle); + } + return; + } + snapshot = $.X('.//text()', this.nodes.comment); + cypher = Linkify.cypher; + i = -1; + len = snapshot.snapshotLength; + _results = []; + while (++i < len) { + nodes = $.frag(); + node = snapshot.snapshotItem(i); + data = node.data; + if (!(node.parentNode && Linkify.regString.test(data))) { + continue; + } + Linkify.regString.lastIndex = 0; + cypherText = []; + if (next = node.nextSibling) { + cypher.textContent = node.textContent; + cypherText[0] = cypher.innerHTML; + while ((next.nodeName.toLowerCase() === 'wbr' || next.nodeName.toLowerCase() === 's') && (lookahead = next.nextSibling) && ((name = lookahead.nodeName) === "#text" || name.toLowerCase() === 'br')) { + cypher.textContent = lookahead.textContent; + cypherText.push((spoiler = next.innerHTML) ? "" + (spoiler.replace(/" : ''); + cypherText.push(cypher.innerHTML); + $.rm(next); + next = lookahead.nextSibling; + if (lookahead.nodeName === "#text") { + $.rm(lookahead); + } + if (!next) { + break; + } + } + } + if (cypherText.length) { + data = cypherText.join(''); + } + links = data.match(Linkify.regString); + for (_j = 0, _len1 = links.length; _j < _len1; _j++) { + link = links[_j]; + index = data.indexOf(link); + if (text = data.slice(0, index)) { + cypher.innerHTML = text; + _ref1 = __slice.call(cypher.childNodes); + for (_k = 0, _len2 = _ref1.length; _k < _len2; _k++) { + child = _ref1[_k]; + $.add(nodes, child); + } + } + cypher.innerHTML = (link.indexOf(':') < 0 ? (link.indexOf('@') > 0 ? 'mailto:' + link : 'http://' + link) : link).replace(/<(wbr|s|\/s)>/g, ''); + a = $.el('a', { + innerHTML: link, + className: 'linkify', + rel: 'nofollow noreferrer', + target: '_blank', + href: cypher.textContent + }); + $.add(nodes, Linkify.embedder(a)); + data = data.slice(index + link.length); + } + if (data) { + cypher.innerHTML = data; + _ref2 = __slice.call(cypher.childNodes); + for (_l = 0, _len3 = _ref2.length; _l < _len3; _l++) { + child = _ref2[_l]; + $.add(nodes, child); + } + } + _results.push($.replace(node, nodes)); + } + return _results; + }, + toggle: function() { + var el, embed, style, type, url; + embed = this.previousElementSibling; + if (this.className.contains("embedded")) { + el = $.el('a', { + rel: 'nofollow noreferrer', + target: 'blank', + className: 'linkify', + href: url = this.getAttribute("data-originalURL"), + textContent: this.getAttribute("data-title") || url + }); + this.textContent = '(embed)'; + } else { + el = (type = Linkify.types[this.getAttribute("data-service")]).el.call(this); + el.style.cssText = (style = type.style) ? style : "border: 0; width: " + ($.get('embedWidth', Config['embedWidth'])) + "px; height: " + ($.get('embedHeight', Config['embedHeight'])) + "px"; + this.textContent = '(unembed)'; + } + $.replace(embed, el); + return $.toggleClass(this, 'embedded'); + }, + types: { + YouTube: { + regExp: /.*(?:youtu.be\/|youtube.*v=|youtube.*\/embed\/|youtube.*\/v\/|youtube.*videos\/)([^#\&\?]*).*/, + el: function() { + return $.el('iframe', { + src: "//www.youtube.com/embed/" + this.name + }); + }, + title: { + api: function() { + return "https://gdata.youtube.com/feeds/api/videos/" + this.name + "?alt=json&fields=title/text(),yt:noembed,app:control/yt:state/@reasonCode"; + }, + text: function() { + return JSON.parse(this.responseText).entry.title.$t; + } + } + }, + Vocaroo: { + regExp: /.*(?:vocaroo.com\/)([^#\&\?]*).*/, + style: 'border: 0; width: 150px; height: 45px;', + el: function() { + return $.el('object', { + innerHTML: "" + }); + } + }, + Vimeo: { + regExp: /.*(?:vimeo.com\/)([^#\&\?]*).*/, + el: function() { + return $.el('iframe', { + src: "//player.vimeo.com/video/" + this.name + }); + }, + title: { + api: function() { + return "https://vimeo.com/api/oembed.json?url=http://vimeo.com/" + this.name; + }, + text: function() { + return JSON.parse(this.responseText).title; + } + } + }, + LiveLeak: { + regExp: /.*(?:liveleak.com\/view.+i=)([0-9a-z_]+)/, + el: function() { + return $.el('iframe', { + src: "http://www.liveleak.com/e/" + this.name + "?autostart=true" + }); + } + }, + audio: { + regExp: /(.*\.(mp3|ogg|wav))$/, + el: function() { + return $.el('audio', { + controls: 'controls', + preload: 'auto', + src: this.name + }); + } + }, + SoundCloud: { + regExp: /.*(?:soundcloud.com\/|snd.sc\/)([^#\&\?]*).*/, + el: function() { + var div; + div = $.el('div', { + className: "soundcloud", + name: "soundcloud" + }); + return $.ajax("//soundcloud.com/oembed?show_artwork=false&&maxwidth=500px&show_comments=false&format=json&url=" + (this.getAttribute('data-originalURL')) + "&color=" + (Style.colorToHex(Themes[Conf['theme']]['Background Color'])), { + div: div, + onloadend: function() { + return this.div.innerHTML = JSON.parse(this.responseText).html; + } + }, false); + } + }, + pastebin: { + regExp: /.*(?:pastebin.com\/)([^#\&\?]*).*/, + el: function() { + var div; + return div = $.el('iframe', { + src: "http://pastebin.com/embed_iframe.php?i=" + this.name + }); + } + } + }, + embedder: function(a) { + var callbacks, embed, key, match, service, title, titles, type, _ref; + if (!Conf['Embedding']) { + return [a]; + } + callbacks = function() { + var title; + return a.textContent = (function() { + switch (this.status) { + case 200: + case 304: + title = "[" + (embed.getAttribute('data-service')) + "] " + (service.text.call(this)); + embed.setAttribute('data-title', title); + titles[embed.name] = [title, Date.now()]; + $.set('CachedTitles', titles); + return title; + case 404: + return "[" + key + "] Not Found"; + case 403: + return "[" + key + "] Forbidden or Private"; + default: + return "[" + key + "] " + this.status + "'d"; + } + }).call(this); + }; + _ref = Linkify.types; + for (key in _ref) { + type = _ref[key]; + if (!(match = a.href.match(type.regExp))) { + continue; + } + embed = $.el('a', { + name: (a.name = match[1]), + className: 'embedder', + href: 'javascript:;', + textContent: '(embed)' + }); + embed.setAttribute('data-service', key); + embed.setAttribute('data-originalURL', a.href); + $.on(embed, 'click', Linkify.toggle); + if (Conf['Link Title'] && (service = type.title)) { + titles = $.get('CachedTitles', {}); + if (title = titles[match[1]]) { + a.textContent = title[0]; + embed.setAttribute('data-title', title[0]); + } else { + try { + $.cache(service.api.call(a), callbacks); + } catch (err) { + a.innerHTML = "[" + key + "] Title Link Blocked (are you using NoScript?)"; + } + } + } + return [a, $.tn(' '), embed]; + } + return [a]; + } + }; + QR = { init: function() { if (g.VIEW === 'catalog' || !Conf['Quick Reply']) { @@ -7611,6 +7877,7 @@ initFeature('Settings', Settings); initFeature('Fourchan thingies', Fourchan); initFeature('Custom CSS', CustomCSS); + initFeature('Linkify', Linkify); initFeature('Resurrect Quotes', Quotify); initFeature('Filter', Filter); initFeature('Thread Hiding', ThreadHiding); diff --git a/lib/$.coffee b/lib/$.coffee index 0180d39a1..4f3748c57 100644 --- a/lib/$.coffee +++ b/lib/$.coffee @@ -127,9 +127,13 @@ $.extend $, $.asap (-> d.head), -> $.add d.head, style style - x: (path, root=d.body) -> + x: (path, root) -> + root or= d.body # XPathResult.ANY_UNORDERED_NODE_TYPE === 8 d.evaluate(path, root, null, 8, null).singleNodeValue + X: (path, root) -> + root or= d.body + d.evaluate path, root, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null addClass: (el, className) -> el.classList.add className rmClass: (el, className) -> @@ -140,10 +144,12 @@ $.extend $, el.parentNode.removeChild el tn: (s) -> d.createTextNode s + frag: -> + d.createDocumentFragment() nodes: (nodes) -> unless nodes instanceof Array return nodes - frag = d.createDocumentFragment() + frag = $.frag() for node in nodes frag.appendChild node frag diff --git a/src/config.coffee b/src/config.coffee index 620c7c308..4d292c224 100644 --- a/src/config.coffee +++ b/src/config.coffee @@ -50,6 +50,20 @@ Config = 'Check for updated versions of <%= meta.name %>.' ] + 'Linkification': + 'Linkify': [ + true + 'Convert text into links where applicable.' + ] + 'Embedding': [ + true + 'Embed supported services.' + ] + 'Link Title': [ + true + 'Replace the link of a supported site with its actual title. Currently Supported: YouTube, Vimeo, SoundCloud' + ] + 'Filtering': 'Anonymize': [ false diff --git a/src/features.coffee b/src/features.coffee index 712769761..ef56dfa66 100644 --- a/src/features.coffee +++ b/src/features.coffee @@ -4096,3 +4096,244 @@ ThreadWatcher = textContent: Get.threadExcerpt thread ThreadWatcher.refresh watched $.set 'WatchedThreads', watched + +Linkify = + init: -> + return if g.VIEW is 'catalog' or not Conf['Linkify'] + + Post::callbacks.push + name: 'Linkify' + cb: @node + + regString: ///( + \b( + [a-z]+:// + | + [a-z]{3,}\.[-a-z0-9]+\.[a-z]+ + | + [-a-z0-9]+\.[a-z] + | + [0-9]+\.[0-9]+\.[0-9]+\.[0-9]+ + | + [a-z]{3,}:[a-z0-9?] + | + [a-z0-9._%+-:]+@[a-z0-9.-]+\.[a-z0-9] + ) + [^\s'"]+ + )///gi + + cypher: $.el 'div' + + node: -> + if @isClone and Conf['Embedding'] + for embedder in $$ '.embedder', @nodes.comment + $.on embedder, Linkify.toggle + return + snapshot = $.X './/text()', @nodes.comment + cypher = Linkify.cypher + i = -1 + len = snapshot.snapshotLength + + while ++i < len + nodes = $.frag() + node = snapshot.snapshotItem i + data = node.data + + # Test for valid links + continue unless node.parentNode and Linkify.regString.test data + + Linkify.regString.lastIndex = 0 + + cypherText = [] + + if next = node.nextSibling + cypher.textContent = node.textContent + cypherText[0] = cypher.innerHTML + + while (next.nodeName.toLowerCase() is 'wbr' or next.nodeName.toLowerCase() is 's') and (lookahead = next.nextSibling) and ((name = lookahead.nodeName) is "#text" or name.toLowerCase() is 'br') + cypher.textContent = lookahead.textContent + + cypherText.push if spoiler = next.innerHTML then "#{spoiler.replace /" else '' + cypherText.push cypher.innerHTML + + $.rm next + next = lookahead.nextSibling + $.rm lookahead if lookahead.nodeName is "#text" + + unless next + break + + if cypherText.length + data = cypherText.join '' + + links = data.match Linkify.regString + + for link in links + index = data.indexOf link + + if text = data[...index] + # press button get bacon + cypher.innerHTML = text + for child in [cypher.childNodes...] + $.add nodes, child + + cypher.innerHTML = (if link.indexOf(':') < 0 then (if link.indexOf('@') > 0 then 'mailto:' + link else 'http://' + link) else link).replace /<(wbr|s|\/s)>/g, '' + + a = $.el 'a', + innerHTML: link + className: 'linkify' + rel: 'nofollow noreferrer' + target: '_blank' + href: cypher.textContent + + $.add nodes, Linkify.embedder a + + data = data[index + link.length..] + + if data + # Potential text after the last valid link. + cypher.innerHTML = data + + # Convert into elements + for child in [cypher.childNodes...] + $.add nodes, child + + $.replace node, nodes + + toggle: -> + # We setup the link to be replaced by the embedded video + embed = @previousElementSibling + + # Unembed. + if @className.contains "embedded" + # Recreate the original link. + el = $.el 'a', + rel: 'nofollow noreferrer' + target: 'blank' + className: 'linkify' + href: url = @getAttribute("data-originalURL") + textContent: @getAttribute("data-title") or url + + @textContent = '(embed)' + + # Embed + else + # We create an element to embed + el = (type = Linkify.types[@getAttribute("data-service")]).el.call @ + + # Set style values. + el.style.cssText = if style = type.style + style + else + "border: 0; width: #{$.get 'embedWidth', Config['embedWidth']}px; height: #{$.get 'embedHeight', Config['embedHeight']}px" + + @textContent = '(unembed)' + + $.replace embed, el + $.toggleClass @, 'embedded' + + types: + YouTube: + regExp: /.*(?:youtu.be\/|youtube.*v=|youtube.*\/embed\/|youtube.*\/v\/|youtube.*videos\/)([^#\&\?]*).*/ + el: -> + $.el 'iframe', + src: "//www.youtube.com/embed/#{@name}" + title: + api: -> "https://gdata.youtube.com/feeds/api/videos/#{@name}?alt=json&fields=title/text(),yt:noembed,app:control/yt:state/@reasonCode" + text: -> JSON.parse(@responseText).entry.title.$t + + Vocaroo: + regExp: /.*(?:vocaroo.com\/)([^#\&\?]*).*/ + style: 'border: 0; width: 150px; height: 45px;' + el: -> + $.el 'object', + innerHTML: "" + + Vimeo: + regExp: /.*(?:vimeo.com\/)([^#\&\?]*).*/ + el: -> + $.el 'iframe', + src: "//player.vimeo.com/video/#{@name}" + title: + api: -> "https://vimeo.com/api/oembed.json?url=http://vimeo.com/#{@name}" + text: -> JSON.parse(@responseText).title + + LiveLeak: + regExp: /.*(?:liveleak.com\/view.+i=)([0-9a-z_]+)/ + el: -> + $.el 'iframe', + src: "http://www.liveleak.com/e/#{@name}?autostart=true" + + audio: + regExp: /(.*\.(mp3|ogg|wav))$/ + el: -> + $.el 'audio', + controls: 'controls' + preload: 'auto' + src: @name + + SoundCloud: + regExp: /.*(?:soundcloud.com\/|snd.sc\/)([^#\&\?]*).*/ + el: -> + div = $.el 'div', + className: "soundcloud" + name: "soundcloud" + $.ajax( + "//soundcloud.com/oembed?show_artwork=false&&maxwidth=500px&show_comments=false&format=json&url=#{@getAttribute 'data-originalURL'}&color=#{Style.colorToHex Themes[Conf['theme']]['Background Color']}" + div: div + onloadend: -> + @div.innerHTML = JSON.parse(@responseText).html + false) + + pastebin: + regExp: /.*(?:pastebin.com\/)([^#\&\?]*).*/ + el: -> + div = $.el 'iframe', + src: "http://pastebin.com/embed_iframe.php?i=#{@name}" + + embedder: (a) -> + return [a] unless Conf['Embedding'] + + callbacks = -> + a.textContent = switch @status + when 200, 304 + title = "[#{embed.getAttribute 'data-service'}] #{service.text.call @}" + embed.setAttribute 'data-title', title + titles[embed.name] = [title, Date.now()] + $.set 'CachedTitles', titles + title + when 404 + "[#{key}] Not Found" + when 403 + "[#{key}] Forbidden or Private" + else + "[#{key}] #{@status}'d" + + for key, type of Linkify.types + continue unless match = a.href.match type.regExp + + embed = $.el 'a', + name: (a.name = match[1]) + className: 'embedder' + href: 'javascript:;' + textContent: '(embed)' + + embed.setAttribute 'data-service', key + embed.setAttribute 'data-originalURL', a.href + + $.on embed, 'click', Linkify.toggle + + if Conf['Link Title'] and (service = type.title) + titles = $.get 'CachedTitles', {} + + if title = titles[match[1]] + a.textContent = title[0] + embed.setAttribute 'data-title', title[0] + else + try + $.cache service.api.call(a), callbacks + catch err + a.innerHTML = "[#{key}] Title Link Blocked (are you using NoScript?)" + + return [a, $.tn(' '), embed] + return [a] diff --git a/src/main.coffee b/src/main.coffee index 33fd3285e..cf4993677 100644 --- a/src/main.coffee +++ b/src/main.coffee @@ -341,6 +341,7 @@ Main = initFeature 'Settings', Settings initFeature 'Fourchan thingies', Fourchan initFeature 'Custom CSS', CustomCSS + initFeature 'Linkify', Linkify initFeature 'Resurrect Quotes', Quotify initFeature 'Filter', Filter initFeature 'Thread Hiding', ThreadHiding