diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..92b2c45a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +tmp/ diff --git a/4chan_x.meta.js b/4chan_x.meta.js new file mode 100644 index 000000000..58ba3f186 --- /dev/null +++ b/4chan_x.meta.js @@ -0,0 +1,21 @@ +// ==UserScript== +// @name 4chan X Alpha +// @version 3.0.0 +// @description Adds various features. +// @copyright 2009-2011 James Campos +// @copyright 2012 Nicolas Stepien +// @license MIT; http://en.wikipedia.org/wiki/Mit_license +// @match *://boards.4chan.org/* +// @match *://images.4chan.org/* +// @match *://sys.4chan.org/* +// @match *://api.4chan.org/* +// @match *://*.foolz.us/api/* +// @grant GM_getValue +// @grant GM_setValue +// @grant GM_deleteValue +// @grant GM_openInTab +// @run-at document-start +// @updateURL https://github.com/MayhemYDG/4chan-x/raw/stable/4chan_x.meta.js +// @downloadURL https://github.com/MayhemYDG/4chan-x/raw/stable/4chan_x.user.js +// @icon data:image/gif;base64,R0lGODlhEAAQAKECAAAAAGbMM////////yH5BAEKAAIALAAAAAAQABAAAAIxlI+pq+D9DAgUoFkPDlbs7lGiI2bSVnKglnJMOL6omczxVZK3dH/41AG6Lh7i6qUoAAA7 +// ==/UserScript== diff --git a/4chan_x.user.js b/4chan_x.user.js index 4b0a90118..a37eb8a78 100644 --- a/4chan_x.user.js +++ b/4chan_x.user.js @@ -1,5 +1,5 @@ // ==UserScript== -// @name 4chan X alpha +// @name 4chan X Alpha // @version 3.0.0 // @description Adds various features. // @copyright 2009-2011 James Campos @@ -15,69 +15,33 @@ // @grant GM_deleteValue // @grant GM_openInTab // @run-at document-start -// @updateURL https://github.com/MayhemYDG/4chan-x/raw/stable/4chan_x.user.js +// @updateURL https://github.com/MayhemYDG/4chan-x/raw/stable/4chan_x.meta.js // @downloadURL https://github.com/MayhemYDG/4chan-x/raw/stable/4chan_x.user.js -// @icon data:image/gif;base64,R0lGODlhEAAQAKECAAAAAGbMM////////yH5BAEKAAIALAAAAAAQABAAAAIxlI+pq+D9DAgUoFkPDlbs7lGiI2bSVnKglnJMOL6omczxVZK3dH/41AG6Lh7i6qUoAAA7 +// @icon https://github.com/MayhemYDG/4chan-x/raw/stable/img/icon.gif // ==/UserScript== -/* LICENSE +/* 4chan X Alpha - Version 3.0.0 - 2012-09-24 + * http://mayhemydg.github.com/4chan-x/ * * Copyright (c) 2009-2011 James Campos * Copyright (c) 2012 Nicolas Stepien - * http://mayhemydg.github.com/4chan-x/ - * 4chan X 3.0.0 + * Licensed under the MIT license. + * https://github.com/MayhemYDG/4chan-x/blob/master/LICENSE * - * Permission is hereby granted, free of charge, to any person - * obtaining a copy of this software and associated documentation - * files (the "Software"), to deal in the Software without - * restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the - * Software is furnished to do so, subject to the following - * conditions: - * - * The above copyright notice and this permission notice shall be - * included in all copies or substantial portions of the Software. - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES - * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR - * OTHER DEALINGS IN THE SOFTWARE. - * - * HACKING - * - * 4chan X is written in CoffeeScript[1], and developed on GitHub[2]. - * - * [1]: http://coffeescript.org/ - * [2]: https://github.com/MayhemYDG/4chan-x - * - * CONTRIBUTORS - * - * noface - unique ID fixes - * desuwa - Firefox filename upload fix - * seaweed - bottom padding for image hover + * Contributors: + * https://github.com/MayhemYDG/4chan-x/graphs/contributors + * ferongr, xat-, Ongpot, thisisanon and Anonymous - cooldown sanity check * e000 - cooldown sanity check - * ahodesuka - scroll back when unexpanding images, file info formatting - * Shou- - pentadactyl fixes - * ferongr - new favicons - * xat- - new favicons - * Zixaphir - fix qr textarea - captcha-image gap - * Ongpot - sfw favicon - * thisisanon - nsfw + 404 favicons - * Anonymous - empty favicon * Seiba - chrome quick reply focusing * herpaderpderp - recaptcha fixes * WakiMiko - recaptcha tab order http://userscripts.org/scripts/show/82657 - * btmcsweeney - allow users to specify text for sauce links * * All the people who've taken the time to write bug reports. * * Thank you. */ +// Generated by CoffeeScript 1.3.3 (function() { var $, $$, AutoGIF, Board, Build, Clone, Conf, Config, FileInfo, Get, ImageHover, Main, Post, QuoteBacklink, QuoteCT, QuoteInline, QuoteOP, QuotePreview, Quotify, Redirect, RevealSpoilers, Sauce, Thread, ThreadUpdater, Time, UI, d, g, __hasProp = {}.hasOwnProperty, @@ -86,7 +50,7 @@ Config = { main: { Enhancing: { - 'Disable 4chan\'s extension': [true, 'Avoid conflicts between 4chan X and 4chan\'s inline extension.'], + 'Disable 4chan\'s extension': [true, 'Avoid conflicts between 4chan X Alpha and 4chan\'s inline extension.'], '404 Redirect': [true, 'Redirect dead threads and images.'], 'Keybinds': [true, 'Bind actions to keyboard shortcuts.'], 'Time Formatting': [true, 'Localize and format timestamps arbitrarily.'], @@ -95,7 +59,7 @@ 'Thread Expansion': [true, 'Can expand threads to view all replies.'], 'Index Navigation': [false, 'Navigate to previous / next thread.'], 'Reply Navigation': [false, 'Navigate to top / bottom of thread.'], - 'Check for Updates': [true, 'Check for updated versions of 4chan X.'] + 'Check for Updates': [true, 'Check for updated versions of 4chan X Alpha.'] }, Filtering: { 'Anonymize': [false, 'Turn everyone Anonymous.'], @@ -206,22 +170,6 @@ imageFit: 'fit width' }; - if (!/^(boards|images|sys)\.4chan\.org$/.test(location.hostname)) { - return; - } - - Conf = {}; - - d = document; - - g = { - VERSION: '3.0.0', - NAMESPACE: '4chan_X.', - boards: {}, - threads: {}, - posts: {} - }; - UI = (function() { var dialog, drag, dragend, dragstart, hover, hoverend, hoverstart, touchend, touchmove; dialog = function(id, position, html) { @@ -386,13 +334,6 @@ }; })(); - /* - loosely follows the jquery api: - http://api.jquery.com/ - not chainable - */ - - $ = function(selector, root) { if (root == null) { root = d.body; @@ -715,6 +656,22 @@ } }); + if (!/^(boards|images|sys)\.4chan\.org$/.test(location.hostname)) { + return; + } + + Conf = {}; + + d = document; + + g = { + VERSION: '3.0.0', + NAMESPACE: "4chan_X_Alpha.", + boards: {}, + threads: {}, + posts: {} + }; + Board = (function() { Board.prototype.toString = function() { diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..c40fc9ea7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,45 @@ +## Reporting bugs + +1. Make sure your **browser** & **4chan X** are up to date. +2. Disable your other extensions & scripts. +3. If your issue persists: + 1. Report precise steps to reproduce the problem. + 2. Report console errors, if any. + 3. Report browser and browser version. + +Open your console with: +- `Ctrl + Shift + J` on Chrome & Firefox +- `Ctrl + Shift + O` on Opera. + +*** + +## Development & Contribution + +### Get started + +- Clone 4chan X. +- `cd` into it. +- Install [node.js](http://nodejs.org/). +- Install [CoffeeScript](http://coffeescript.org/) with `npm install -g coffee-script`. +- Install [Grunt](http://gruntjs.com/) with `npm install -g grunt`. +- Install [grunt-exec](https://npmjs.org/package/grunt-exec) with `npm install grunt-exec`. +- Install [grunt-image-embed](https://npmjs.org/package/grunt-image-embed) with `npm install grunt-image-embed`. + +### Build + +- Build with `grunt`. +- For development (continuous builds), run `grunt watch`. + +### Release + +- To upgrade, edit the version in `grunt.js` and run `grunt upgrade`. + +Note: this is only used to release new 4chan X versions, and is not needed or wanted in pull requests. + +### Contribute + +- Edit the CoffeeScript source. +- Build the JavaScript. +- If the edits affect regular users, edit the changelog. +- Fork the repo. +- Send a pull request. diff --git a/Cakefile b/Cakefile deleted file mode 100644 index a87b4cb0b..000000000 --- a/Cakefile +++ /dev/null @@ -1,120 +0,0 @@ -{log} = console -{exec} = require 'child_process' -fs = require 'fs' - -VERSION = '3.0.0' -CAKEFILE = 'Cakefile' -INFILE = 'script.coffee' -OUTFILE = '4chan_x.user.js' -CHANGELOG = 'changelog' -LATEST = 'latest.js' -HEADER = """ -// ==UserScript== -// @name 4chan X alpha -// @version #{VERSION} -// @description Adds various features. -// @copyright 2009-2011 James Campos -// @copyright 2012 Nicolas Stepien -// @license MIT; http://en.wikipedia.org/wiki/Mit_license -// @match *://boards.4chan.org/* -// @match *://images.4chan.org/* -// @match *://sys.4chan.org/* -// @match *://api.4chan.org/* -// @match *://*.foolz.us/api/* -// @grant GM_getValue -// @grant GM_setValue -// @grant GM_deleteValue -// @grant GM_openInTab -// @run-at document-start -// @updateURL https://github.com/MayhemYDG/4chan-x/raw/stable/4chan_x.user.js -// @downloadURL https://github.com/MayhemYDG/4chan-x/raw/stable/4chan_x.user.js -// @icon data:image/gif;base64,R0lGODlhEAAQAKECAAAAAGbMM////////yH5BAEKAAIALAAAAAAQABAAAAIxlI+pq+D9DAgUoFkPDlbs7lGiI2bSVnKglnJMOL6omczxVZK3dH/41AG6Lh7i6qUoAAA7 -// ==/UserScript== - -/* LICENSE - * - * Copyright (c) 2009-2011 James Campos - * Copyright (c) 2012 Nicolas Stepien - * http://mayhemydg.github.com/4chan-x/ - * 4chan X #{VERSION} - * - * Permission is hereby granted, free of charge, to any person - * obtaining a copy of this software and associated documentation - * files (the "Software"), to deal in the Software without - * restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the - * Software is furnished to do so, subject to the following - * conditions: - * - * The above copyright notice and this permission notice shall be - * included in all copies or substantial portions of the Software. - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES - * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR - * OTHER DEALINGS IN THE SOFTWARE. - * - * HACKING - * - * 4chan X is written in CoffeeScript[1], and developed on GitHub[2]. - * - * [1]: http://coffeescript.org/ - * [2]: https://github.com/MayhemYDG/4chan-x - * - * CONTRIBUTORS - * - * noface - unique ID fixes - * desuwa - Firefox filename upload fix - * seaweed - bottom padding for image hover - * e000 - cooldown sanity check - * ahodesuka - scroll back when unexpanding images, file info formatting - * Shou- - pentadactyl fixes - * ferongr - new favicons - * xat- - new favicons - * Zixaphir - fix qr textarea - captcha-image gap - * Ongpot - sfw favicon - * thisisanon - nsfw + 404 favicons - * Anonymous - empty favicon - * Seiba - chrome quick reply focusing - * herpaderpderp - recaptcha fixes - * WakiMiko - recaptcha tab order http://userscripts.org/scripts/show/82657 - * btmcsweeney - allow users to specify text for sauce links - * - * All the people who've taken the time to write bug reports. - * - * Thank you. - */ - - -""" - -option '-v', '--version [version]', 'Upgrade version.' - -task 'upgrade', (options) -> - {version} = options - unless version - console.warn 'Version argument not specified. Exiting.' - return - regexp = RegExp VERSION, 'g' - for file in [CAKEFILE, INFILE, OUTFILE, LATEST] - data = fs.readFileSync(file, 'utf8').replace regexp, version - fs.writeFileSync file, data - # data = fs.readFileSync CHANGELOG, 'utf8' - # fs.writeFileSync CHANGELOG, data.replace 'master', "master\n\n#{version}" - exec "git commit -am 'Release #{version}.' && git tag -a #{version} -m '#{version}' && git tag -af stable -m '#{version}'" - -task 'build', -> - exec 'coffee --print script.coffee', (err, stdout, stderr) -> - throw err if err - fs.writeFile OUTFILE, HEADER + stdout, (err) -> - throw err if err - -task 'dev', -> - invoke 'build' - fs.watchFile INFILE, interval: 250, (curr, prev) -> - if curr.mtime > prev.mtime - invoke 'build' diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..a8936987a --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2009-2011 James Campos +Copyright (c) 2012 Nicolas Stepien + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 000000000..edeb898bb --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# Get 4chan X [HERE](http://mayhemydg.github.com/4chan-x/). + +*** + +## [MIT License](/4chan-x/blob/master/LICENSE) + +*** + +## [Contribute](/4chan-x/blob/master/LICENSE) diff --git a/css/style.css b/css/style.css new file mode 100644 index 000000000..33f20ac7d --- /dev/null +++ b/css/style.css @@ -0,0 +1,158 @@ +/* general */ +.dialog.reply { + display: block; + border: 1px solid rgba(0, 0, 0, .25); + padding: 0; +} +.move { + cursor: move; +} +label { + cursor: pointer; +} +a[href="javascript:;"] { + text-decoration: none; +} +.warning { + color: red; +} + +/* 4chan style fixes */ +.opContainer, .op { + display: block !important; +} +.post { + overflow: visible !important; +} + +/* fixed, z-index */ +#qp, #ihover, +#updater, #stats, +#boardNavDesktop.reply, +#qr, #watcher { + position: fixed; +} +#qp, #ihover { + z-index: 100; +} +#updater, #stats { + z-index: 90; +} +#boardNavDesktop.reply:hover { + z-index: 80; +} +#qr { + z-index: 50; +} +#watcher { + z-index: 30; +} +#boardNavDesktop.reply { + z-index: 10; +} + + +/* header */ +body.fourchan_x { + margin-top: 2.5em; +} +#boardNavDesktop.reply { + border-width: 0 0 1px; + padding: 4px; + top: 0; + right: 0; + left: 0; + transition: opacity .1s ease-in-out; + -o-transition: opacity .1s ease-in-out; + -moz-transition: opacity .1s ease-in-out; + -webkit-transition: opacity .1s ease-in-out; +} +#boardNavDesktop.reply:not(:hover) { + opacity: .4; + transition: opacity 1.5s .5s ease-in-out; + -o-transition: opacity 1.5s .5s ease-in-out; + -moz-transition: opacity 1.5s .5s ease-in-out; + -webkit-transition: opacity 1.5s .5s ease-in-out; +} +#boardNavDesktop.reply a { + margin: -1px; +} +#settings { + float: right; +} + +/* thread updater */ +#updater { + text-align: right; +} +#updater:not(:hover) { + background: none; + border: none; +} +#updater input[type=number] { + width: 4em; +} +#updater:not(:hover) > div:not(.move) { + display: none; +} +.new { + color: limegreen; +} + +/* quote */ +.quotelink.deadlink { + text-decoration: underline !important; +} +.deadlink:not(.quotelink) { + text-decoration: none !important; +} +.inlined { + opacity: .5; +} +#qp input, .forwarded { + display: none; +} +.quotelink.forwardlink, +.backlink.forwardlink { + text-decoration: none; + border-bottom: 1px dashed; +} +.inline { + border: 1px solid rgba(128, 128, 128, .5); + display: table; + margin: 2px 0; +} +.inline .post { + border: 0 !important; + display: table !important; + margin: 0 !important; + padding: 1px 2px !important; +} +#qp { + padding: 2px 2px 5px; +} +#qp .post { + border: none; + margin: 0; + padding: 0; +} +#qp img { + max-height: 300px; + max-width: 500px; +} +.qphl { + box-shadow: 0 0 0 2px rgba(216, 94, 49, .7); +} + +/* file */ +.fileText:hover .fntrunc, +.fileText:not(:hover) .fnfull { + display: none; +} +#ihover { + box-sizing: border-box; + -moz-box-sizing: border-box; + max-height: 100%; + max-width: 75%; + padding-bottom: 16px; +} diff --git a/grunt.js b/grunt.js new file mode 100644 index 000000000..cd90e6089 --- /dev/null +++ b/grunt.js @@ -0,0 +1,120 @@ +module.exports = function(grunt) { + + // Some tasks do not support directives. + var meta = { + name: '4chan X Alpha', + version: '3.0.0', + }; + + // Project configuration. + grunt.initConfig({ + meta: { + name: meta.name, + version: meta.version, + repo: 'https://github.com/MayhemYDG/4chan-x/', + banner: [ + '/* <%= meta.name %> - Version <%= meta.version %> - <%= grunt.template.today("yyyy-mm-dd") %>', + ' * http://mayhemydg.github.com/4chan-x/', + ' *', + ' * Copyright (c) 2009-2011 James Campos ', + ' * Copyright (c) <%= grunt.template.today("yyyy") %> Nicolas Stepien ', + ' * Licensed under the MIT license.', + ' * <%= meta.repo %>blob/master/LICENSE', + ' *', + ' * Contributors:', + ' * <%= meta.repo %>graphs/contributors', + ' * ferongr, xat-, Ongpot, thisisanon and Anonymous - cooldown sanity check', + ' * e000 - cooldown sanity check', + ' * Seiba - chrome quick reply focusing', + ' * herpaderpderp - recaptcha fixes', + ' * WakiMiko - recaptcha tab order http://userscripts.org/scripts/show/82657', + ' *', + ' * All the people who\'ve taken the time to write bug reports.', + ' *', + ' * Thank you.', + ' */' + ].join('\n'), + metadataBlock: [ + '// ==UserScript==', + '// @name <%= meta.name %>', + '// @version <%= meta.version %>', + '// @description Adds various features.', + '// @copyright 2009-2011 James Campos ', + '// @copyright <%= grunt.template.today("yyyy") %> Nicolas Stepien ', + '// @license MIT; http://en.wikipedia.org/wiki/Mit_license', + '// @match *://boards.4chan.org/*', + '// @match *://images.4chan.org/*', + '// @match *://sys.4chan.org/*', + '// @match *://api.4chan.org/*', + '// @match *://*.foolz.us/api/*', + '// @grant GM_getValue', + '// @grant GM_setValue', + '// @grant GM_deleteValue', + '// @grant GM_openInTab', + '// @run-at document-start', + '// @updateURL <%= meta.repo %>raw/stable/<%= meta.files.metajs %>', + '// @downloadURL <%= meta.repo %>raw/stable/<%= meta.files.userjs %>', + '// @icon <%= meta.repo %>raw/stable/img/icon.gif', + '// ==/UserScript==' + ].join('\n'), + latest: 'document.dispatchEvent(new CustomEvent("<%= meta.name.replace(/ /g, "") %>Update",{detail:{v:"<%= meta.version %>"}}))', + files: { + metajs: '4chan_x.meta.js', + userjs: '4chan_x.user.js', + latestjs: 'latestv3.js' + }, + }, + concat: { + coffee: { + src: [ + '', + '', + '', + '', + '', + '' + ], + dest: 'tmp/script.coffee' + }, + js: { + src: ['', '', 'tmp/script.js'], + dest: '' + }, + meta: { + src: '', + dest: '' + }, + latest: { + src: '', + dest: '' + } + }, + exec: { + coffee: { + command: 'coffee --compile tmp/script.coffee', + stdout: true + }, + commit: { + command: [ + 'git commit -am "Release ' + meta.name + ' v' + meta.version + '."', + 'git tag -a ' + meta.version + ' -m "' + meta.version + '"', + 'git tag -af stable -m "' + meta.version + '"' + ].join(' && '), + stdout: true + }, + clean: { + command: 'rm -r tmp' + } + }, + watch: { + files: ['grunt.js', 'lib/**/*.coffee', 'src/**/*.coffee', 'css/**/*.css', 'img/*'], + tasks: 'coffee concat:build' + } + }); + + grunt.loadNpmTasks('grunt-exec'); + + grunt.registerTask('default', 'concat:coffee exec:coffee concat:js exec:clean'); + grunt.registerTask('upgrade', 'concat:meta concat:latest default exec:commit'); + +}; diff --git a/img/icon.gif b/img/icon.gif new file mode 100644 index 000000000..8365e93dc Binary files /dev/null and b/img/icon.gif differ diff --git a/latestv3.js b/latestv3.js new file mode 100644 index 000000000..8aa354586 --- /dev/null +++ b/latestv3.js @@ -0,0 +1 @@ +document.dispatchEvent(new CustomEvent("4chanXAlphaUpdate",{detail:{v:"3.0.0"}})) diff --git a/lib/$.coffee b/lib/$.coffee new file mode 100644 index 000000000..9cb8d1e7c --- /dev/null +++ b/lib/$.coffee @@ -0,0 +1,230 @@ +# loosely follows the jquery api: +# http://api.jquery.com/ +# not chainable +$ = (selector, root=d.body) -> + root.querySelector selector +$$ = (selector, root=d.body) -> + Array::slice.call root.querySelectorAll selector + +$.extend = (object, properties) -> + for key, val of properties + object[key] = val + return + +$.extend $, + SECOND: 1000 + MINUTE: 1000 * 60 + HOUR : 1000 * 60 * 60 + DAY : 1000 * 60 * 60 * 24 + log: console.log.bind console + engine: /WebKit|Presto|Gecko/.exec(navigator.userAgent)[0].toLowerCase() + id: (id) -> + d.getElementById id + ready: (fc) -> + if /interactive|complete/.test d.readyState + $.queueTask fc + return + cb = -> + $.off d, 'DOMContentLoaded', cb + fc() + $.on d, 'DOMContentLoaded', cb + sync: (key, cb) -> + $.on window, 'storage', (e) -> + if e.key is "#{g.NAMESPACE}#{key}" + cb JSON.parse e.newValue + formData: (form) -> + if form instanceof HTMLFormElement + return new FormData form + fd = new FormData() + for key, val of form + fd.append key, val if val + fd + ajax: (url, callbacks, opts={}) -> + {type, headers, upCallbacks, form} = opts + r = new XMLHttpRequest() + type or= form and 'post' or 'get' + r.open type, url, true + for key, val of headers + r.setRequestHeader key, val + $.extend r, callbacks + $.extend r.upload, upCallbacks + r.withCredentials = type is 'post' + r.send form + r + cache: (-> + reqs = {} + (url, cb) -> + if req = reqs[url] + if req.readyState is 4 + cb.call req + else + req.callbacks.push cb + return + req = $.ajax url, + onload: -> + cb.call @ for cb in @callbacks + delete @callbacks + onabort: -> delete reqs[url] + onerror: -> delete reqs[url] + req.callbacks = [cb] + reqs[url] = req + )() + cb: + checked: -> + $.set @name, @checked + Conf[@name] = @checked + value: -> + $.set @name, @value.trim() + Conf[@name] = @value + addStyle: (css) -> + style = $.el 'style', + textContent: css + # That's terrible. + # XXX tmp fix for scriptish: + # https://github.com/scriptish/scriptish/issues/16 + f = -> + # XXX Only Chrome has no d.head on document-start. + if root = d.head or d.documentElement + $.add root, style + else + setTimeout f, 20 + f() + style + x: (path, root=d.body) -> + # XPathResult.ANY_UNORDERED_NODE_TYPE === 8 + d.evaluate(path, root, null, 8, null).singleNodeValue + addClass: (el, className) -> + el.classList.add className + rmClass: (el, className) -> + el.classList.remove className + hasClass: (el, className) -> + el.classList.contains className + rm: (el) -> + el.parentNode.removeChild el + tn: (s) -> + d.createTextNode s + nodes: (nodes) -> + # In (at least) Chrome, elements created inside different + # scripts/window contexts inherit from unequal prototypes. + # window_context1.Node !== window_context2.Node + unless nodes instanceof Array + return nodes + frag = d.createDocumentFragment() + for node in nodes + frag.appendChild node + frag + add: (parent, el) -> + parent.appendChild $.nodes el + prepend: (parent, el) -> + parent.insertBefore $.nodes(el), parent.firstChild + after: (root, el) -> + root.parentNode.insertBefore $.nodes(el), root.nextSibling + before: (root, el) -> + root.parentNode.insertBefore $.nodes(el), root + replace: (root, el) -> + root.parentNode.replaceChild $.nodes(el), root + el: (tag, properties) -> + el = d.createElement tag + $.extend el, properties if properties + el + on: (el, events, handler) -> + for event in events.split ' ' + el.addEventListener event, handler, false + return + off: (el, events, handler) -> + for event in events.split ' ' + el.removeEventListener event, handler, false + return + open: (url) -> + (GM_openInTab or window.open) url, '_blank' + queueTask: (-> + # inspired by https://www.w3.org/Bugs/Public/show_bug.cgi?id=15007 + taskQueue = [] + execTask = -> + task = taskQueue.shift() + func = task[0] + args = Array::slice.call task, 1 + func.apply func, args + if window.MessageChannel + taskChannel = new MessageChannel() + taskChannel.port1.onmessage = execTask + -> + taskQueue.push arguments + taskChannel.port2.postMessage null + else # XXX Firefox + -> + taskQueue.push arguments + setTimeout execTask, 0 + )() + globalEval: (code) -> + script = $.el 'script', + textContent: code + $.add d.head, script + $.rm script + # http://mths.be/unsafewindow + unsafeWindow: + if window.opera # Opera + window + else if unsafeWindow isnt window # Firefox + unsafeWindow + else # Chrome + (-> + p = d.createElement 'p' + p.setAttribute 'onclick', 'return window' + p.onclick() + )() + bytesToString: (size) -> + unit = 0 # Bytes + while size >= 1024 + size /= 1024 + unit++ + # Remove trailing 0s. + size = + if unit > 1 + # Keep the size as a float if the size is greater than 2^20 B. + # Round to hundredth. + Math.round(size * 100) / 100 + else + # Round to an integer otherwise. + Math.round size + "#{size} #{['B', 'KB', 'MB', 'GB'][unit]}" + +$.extend $, + if GM_deleteValue? + delete: (name) -> + GM_deleteValue g.NAMESPACE + name + get: (name, defaultValue) -> + if value = GM_getValue g.NAMESPACE + name + JSON.parse value + else + defaultValue + set: (name, value) -> + name = g.NAMESPACE + name + value = JSON.stringify value + # for `storage` events + localStorage.setItem name, value + GM_setValue name, value + else if window.opera + delete: (name)-> + delete opera.scriptStorage[g.NAMESPACE + name] + get: (name, defaultValue) -> + if value = opera.scriptStorage[g.NAMESPACE + name] + JSON.parse value + else + defaultValue + set: (name, value) -> + name = g.NAMESPACE + name + value = JSON.stringify value + # for `storage` events + localStorage.setItem name, value + opera.scriptStorage[name] = value + else + delete: (name) -> + localStorage.removeItem g.NAMESPACE + name + get: (name, defaultValue) -> + if value = localStorage.getItem g.NAMESPACE + name + JSON.parse value + else + defaultValue + set: (name, value) -> + localStorage.setItem g.NAMESPACE + name, JSON.stringify value diff --git a/lib/ui.coffee b/lib/ui.coffee new file mode 100644 index 000000000..1dd7eecc9 --- /dev/null +++ b/lib/ui.coffee @@ -0,0 +1,129 @@ +UI = (-> + dialog = (id, position, html) -> + el = d.createElement 'div' + el.className = 'reply dialog' + el.innerHTML = html + el.id = id + el.style.cssText = localStorage.getItem("#{g.NAMESPACE}#{id}.position") or position + move = el.querySelector '.move' + move.addEventListener 'touchstart', dragstart, false + move.addEventListener 'mousedown', dragstart, false + el + + dragstart = (e) -> + # prevent text selection + e.preventDefault() + el = @parentNode + if isTouching = e.type is 'touchstart' + e = e.changedTouches[e.changedTouches.length - 1] + # distance from pointer to el edge is constant; calculate it here. + rect = el.getBoundingClientRect() + screenHeight = d.documentElement.clientHeight + screenWidth = d.documentElement.clientWidth + o = { + id: el.id + style: el.style + dx: e.clientX - rect.left + dy: e.clientY - rect.top + height: screenHeight - rect.height + width: screenWidth - rect.width + screenHeight: screenHeight + screenWidth: screenWidth + isTouching: isTouching + } + if isTouching + o.identifier = e.identifier + o.move = touchmove.bind o + o.up = touchend.bind o + d.addEventListener 'touchmove', o.move, false + d.addEventListener 'touchend', o.up, false + d.addEventListener 'touchcancel', o.up, false + else # mousedown + o.move = drag.bind o + o.up = dragend.bind o + d.addEventListener 'mousemove', o.move, false + d.addEventListener 'mouseup', o.up, false + touchmove = (e) -> + for touch in e.changedTouches + if touch.identifier is @identifier + drag.call @, touch + return + drag = (e) -> + left = e.clientX - @dx + top = e.clientY - @dy + if left < 10 then left = 0 + else if @width - left < 10 then left = null + if top < 10 then top = 0 + else if @height - top < 10 then top = null + if left is null + @style.left = null + @style.right = '0%' + else + @style.left = left / @screenWidth * 100 + '%' + @style.right = null + if top is null + @style.top = null + @style.bottom = '0%' + else + @style.top = top / @screenHeight * 100 + '%' + @style.bottom = null + touchend = (e) -> + for touch in e.changedTouches + if touch.identifier is @identifier + dragend.call @ + return + dragend = -> + if @isTouching + d.removeEventListener 'touchmove', @move, false + d.removeEventListener 'touchend', @up, false + d.removeEventListener 'touchcancel', @up, false + else # mouseup + d.removeEventListener 'mousemove', @move, false + d.removeEventListener 'mouseup', @up, false + localStorage.setItem "#{g.NAMESPACE}#{@id}.position", @style.cssText + + hoverstart = (root, el, events, cb) -> + o = { + root: root + el: el + style: el.style + cb: cb + events: events.split ' ' + clientHeight: d.documentElement.clientHeight + clientWidth: d.documentElement.clientWidth + } + o.hover = hover.bind o + o.hoverend = hoverend.bind o + for event in o.events + root.addEventListener event, o.hoverend, false + root.addEventListener 'mousemove', o.hover, false + hover = (e) -> + height = @el.offsetHeight + top = e.clientY - height / 2 + @style.top = + if @clientHeight <= height or top <= 0 + '0px' + else if top + height >= @clientHeight + @clientHeight - height + 'px' + else + top + 'px' + + {clientX} = e + if clientX <= @clientWidth - 400 + @style.left = clientX + 45 + 'px' + @style.right = null + else + @style.left = null + @style.right = @clientWidth - clientX + 45 + 'px' + hoverend = -> + @el.parentNode.removeChild @el + for event in @events + @root.removeEventListener event, @hoverend, false + @root.removeEventListener 'mousemove', @hover, false + @cb.call @ if @cb + + return { + dialog: dialog + hover: hoverstart + } +)() diff --git a/readme.md b/readme.md deleted file mode 100644 index c6012445a..000000000 --- a/readme.md +++ /dev/null @@ -1,22 +0,0 @@ -# Get 4chan X [HERE](http://mayhemydg.github.com/4chan-x/). - -# Building - -- Install [node.js](http://nodejs.org/). -- Install [CoffeeScript](http://coffeescript.org/) with `npm install -g coffee-script`. -- Clone 4chan X. -- `cd` into it and build with `cake build`. -- For development (continuous builds), run `cake dev &`. Kill the process with `killall node`. - -# Releasing - -- Upgrade version with `cake -v VERSION upgrade`. Note that this is only used to -release new 4chan x versions, and is not needed or wanted in pull requests. - -# Contributing - -- Edit the CoffeeScript source -- Build the JavaScript -- If the edits affect regular users, edit the changelog -- Fork the repo -- Send a pull request diff --git a/src/config.coffee b/src/config.coffee new file mode 100644 index 000000000..1349f6c6c --- /dev/null +++ b/src/config.coffee @@ -0,0 +1,163 @@ +Config = + main: + Enhancing: + 'Disable 4chan\'s extension': [true, 'Avoid conflicts between <%= meta.name %> and 4chan\'s inline extension.'] + '404 Redirect': [true, 'Redirect dead threads and images.'] + 'Keybinds': [true, 'Bind actions to keyboard shortcuts.'] + 'Time Formatting': [true, 'Localize and format timestamps arbitrarily.'] + 'File Info Formatting': [true, 'Reformat the file information.'] + 'Comment Expansion': [true, 'Can expand too long comments.'] + 'Thread Expansion': [true, 'Can expand threads to view all replies.'] + 'Index Navigation': [false, 'Navigate to previous / next thread.'] + 'Reply Navigation': [false, 'Navigate to top / bottom of thread.'] + 'Check for Updates': [true, 'Check for updated versions of <%= meta.name %>.'] + Filtering: + 'Anonymize': [false, 'Turn everyone Anonymous.'] + 'Filter': [true, 'Self-moderation placebo.'] + 'Recursive Filtering': [true, 'Filter replies of filtered posts, recursively.'] + 'Reply Hiding': [true, 'Hide single replies.'] + 'Thread Hiding': [true, 'Hide entire threads.'] + 'Stubs': [true, 'Make stubs of hidden threads / replies.'] + Imaging: + 'Auto-GIF': [false, 'Animate GIF thumbnails.'] + 'Image Expansion': [true, 'Expand images.'] + 'Expand From Position': [true, 'Expand all images only from current position to thread end.'] + 'Image Hover': [false, 'Show full image on mouseover.'] + 'Sauce': [true, 'Add sauce links to images.'] + 'Reveal Spoilers': [false, 'Reveal spoiler thumbnails.'] + Menu: + 'Menu': [true, 'Add a drop-down menu in posts.'] + 'Report Link': [true, 'Add a report link to the menu.'] + 'Delete Link': [true, 'Add post and image deletion links to the menu.'] + 'Download Link': [true, 'Add a download with original filename link to the menu. Chrome-only currently.'] + 'Archive Link': [true, 'Add an archive link to the menu.'] + Monitoring: + 'Thread Updater': [true, 'Fetch and insert new replies. Has more options in its own dialog.'] + 'Unread Count': [true, 'Show the unread posts count in the tab title.'] + 'Unread Favicon': [true, 'Show a different favicon when there are unread posts.'] + 'Post in Title': [true, 'Show the thread\'s subject in the tab title.'] + 'Thread Stats': [true, 'Display reply and image count.'] + 'Thread Watcher': [true, 'Bookmark threads.'] + 'Auto Watch': [true, 'Automatically watch threads that you start.'] + 'Auto Watch Reply': [false, 'Automatically watch threads that you reply to.'] + Posting: + 'Quick Reply': [true, 'WMD.'] + 'Persistent QR': [false, 'The Quick reply won\'t disappear after posting.'] + 'Auto Hide QR': [false, 'Automatically hide the quick reply when posting.'] + 'Open Reply in New Tab': [false, 'Open replies posted from the board pages in a new tab.'] + 'Remember Subject': [false, 'Remember the subject field, instead of resetting after posting.'] + 'Remember Spoiler': [false, 'Remember the spoiler state, instead of resetting after posting.'] + 'Hide Original Post Form': [true, 'Replace the normal post form with a shortcut to open the QR.'] + Quoting: + 'Quote Backlinks': [true, 'Add quote backlinks.'] + 'OP Backlinks': [false, 'Add backlinks to the OP.'] + 'Quote Inline': [true, 'Inline quoted post on click.'] + 'Forward Hiding': [true, 'Hide original posts of inlined backlinks.'] + 'Quote Preview': [true, 'Show quoted post on hover.'] + 'Quote Highlighting': [true, 'Highlight the previewed post.'] + 'Resurrect Quotes': [true, 'Linkify dead quotes to archives.'] + 'Indicate OP Quotes': [true, 'Add \'(OP)\' to OP quotes.'] + 'Indicate Cross-thread Quotes': [true, 'Add \'(Cross-thread)\' to cross-threads quotes.'] + filter: + name: [ + '# Filter any namefags:' + '#/^(?!Anonymous$)/' + ].join '\n' + uniqueid: [ + '# Filter a specific ID:' + '#/Txhvk1Tl/' + ].join '\n' + tripcode: [ + '# Filter any tripfags' + '#/^!/' + ].join '\n' + capcode: [ + '# Set a custom class for mods:' + '#/Mod$/;highlight:mod;op:yes' + '# Set a custom class for moot:' + '#/Admin$/;highlight:moot;op:yes' + ].join '\n' + email: [ + '# Filter any e-mails that are not `sage` on /a/ and /jp/:' + '#/^(?!sage$)/;boards:a,jp' + ].join '\n' + subject: [ + '# Filter Generals on /v/:' + '#/general/i;boards:v;op:only' + ].join '\n' + comment: [ + '# Filter Stallman copypasta on /g/:' + '#/what you\'re refer+ing to as linux/i;boards:g' + ].join '\n' + flag: [ + '' + ].join '\n' + filename: [ + '' + ].join '\n' + dimensions: [ + '# Highlight potential wallpapers:' + '#/1920x1080/;op:yes;highlight;top:no;boards:w,wg' + ].join '\n' + filesize: [ + '' + ].join '\n' + md5: [ + '' + ].join '\n' + sauces: [ + 'http://iqdb.org/?url=%turl' + 'http://www.google.com/searchbyimage?image_url=%turl' + '#http://tineye.com/search?url=%turl' + '#http://saucenao.com/search.php?db=999&url=%turl' + '#http://3d.iqdb.org/?url=%turl' + '#http://regex.info/exif.cgi?imgurl=%url' + '# uploaders:' + '#http://imgur.com/upload?url=%url;text:Upload to imgur' + '#http://omploader.org/upload?url1=%url;text:Upload to omploader' + '# "View Same" in archives:' + '#//archive.foolz.us/_/search/image/%md5/;text:View same on foolz' + '#//archive.foolz.us/%board/search/image/%md5/;text:View same on foolz /%board/' + '#//archive.installgentoo.net/%board/image/%md5;text:View same on installgentoo /%board/' + ].join '\n' + time: '%m/%d/%y(%a)%H:%M:%S' + backlink: '>>%id' + fileInfo: '%l (%p%s, %r)' + favicon: 'ferongr' + hotkeys: + # QR & Options + 'open QR': ['q', 'Open QR with post number inserted.'] + 'open empty QR': ['Q', 'Open QR without post number inserted.'] + 'open options': ['alt+o', 'Open Options.'] + 'close': ['Esc', 'Close Options or QR.'] + 'spoiler tags': ['ctrl+s', 'Insert spoiler tags.'] + 'code tags': ['alt+c', 'Insert code tags.'] + 'submit QR': ['alt+s', 'Submit post.'] + # Thread related + 'watch': ['w', 'Watch thread.'] + 'update': ['u', 'Update the thread now.'] + 'read thread': ['r', 'Mark thread as read.'] + # Images + 'expand image': ['E', 'Expand selected image.'] + 'expand images': ['e', 'Expand all images.'] + # Board Navigation + 'front page': ['0', 'Jump to page 0.'] + 'next page': ['Right', 'Jump to the next page.'] + 'previous page': ['Left', 'Jump to the previous page.'] + # Thread Navigation + 'next thread': ['Down', 'See next thread.'] + 'previous thread': ['Up', 'See previous thread.'] + 'expand thread': ['ctrl+e', 'Expand thread.'] + 'open thread': ['o', 'Open thread in current tab.'] + 'open thread tab': ['O', 'Open thread in new tab.'] + # Reply Navigation + 'next reply': ['j', 'Select next reply.'] + 'previous reply': ['k', 'Select previous reply.'] + 'hide': ['x', 'Hide thread.'] + updater: + checkbox: + 'Auto Scroll': [false, 'Scroll updated posts into view. Only enabled at bottom of page.'] + 'Scroll BG': [false, 'Auto-scroll background tabs.'] + 'Auto Update': [true, 'Automatically fetch new posts.'] + 'Interval': 30 + imageFit: 'fit width' diff --git a/script.coffee b/src/features.coffee similarity index 55% rename from script.coffee rename to src/features.coffee index 08bb28e82..af214e1e7 100644 --- a/script.coffee +++ b/src/features.coffee @@ -1,1164 +1,3 @@ -Config = - main: - Enhancing: - 'Disable 4chan\'s extension': [true, 'Avoid conflicts between 4chan X and 4chan\'s inline extension.'] - '404 Redirect': [true, 'Redirect dead threads and images.'] - 'Keybinds': [true, 'Bind actions to keyboard shortcuts.'] - 'Time Formatting': [true, 'Localize and format timestamps arbitrarily.'] - 'File Info Formatting': [true, 'Reformat the file information.'] - 'Comment Expansion': [true, 'Can expand too long comments.'] - 'Thread Expansion': [true, 'Can expand threads to view all replies.'] - 'Index Navigation': [false, 'Navigate to previous / next thread.'] - 'Reply Navigation': [false, 'Navigate to top / bottom of thread.'] - 'Check for Updates': [true, 'Check for updated versions of 4chan X.'] - Filtering: - 'Anonymize': [false, 'Turn everyone Anonymous.'] - 'Filter': [true, 'Self-moderation placebo.'] - 'Recursive Filtering': [true, 'Filter replies of filtered posts, recursively.'] - 'Reply Hiding': [true, 'Hide single replies.'] - 'Thread Hiding': [true, 'Hide entire threads.'] - 'Stubs': [true, 'Make stubs of hidden threads / replies.'] - Imaging: - 'Auto-GIF': [false, 'Animate GIF thumbnails.'] - 'Image Expansion': [true, 'Expand images.'] - 'Expand From Position': [true, 'Expand all images only from current position to thread end.'] - 'Image Hover': [false, 'Show full image on mouseover.'] - 'Sauce': [true, 'Add sauce links to images.'] - 'Reveal Spoilers': [false, 'Reveal spoiler thumbnails.'] - Menu: - 'Menu': [true, 'Add a drop-down menu in posts.'] - 'Report Link': [true, 'Add a report link to the menu.'] - 'Delete Link': [true, 'Add post and image deletion links to the menu.'] - 'Download Link': [true, 'Add a download with original filename link to the menu. Chrome-only currently.'] - 'Archive Link': [true, 'Add an archive link to the menu.'] - Monitoring: - 'Thread Updater': [true, 'Fetch and insert new replies. Has more options in its own dialog.'] - 'Unread Count': [true, 'Show the unread posts count in the tab title.'] - 'Unread Favicon': [true, 'Show a different favicon when there are unread posts.'] - 'Post in Title': [true, 'Show the thread\'s subject in the tab title.'] - 'Thread Stats': [true, 'Display reply and image count.'] - 'Thread Watcher': [true, 'Bookmark threads.'] - 'Auto Watch': [true, 'Automatically watch threads that you start.'] - 'Auto Watch Reply': [false, 'Automatically watch threads that you reply to.'] - Posting: - 'Quick Reply': [true, 'WMD.'] - 'Persistent QR': [false, 'The Quick reply won\'t disappear after posting.'] - 'Auto Hide QR': [false, 'Automatically hide the quick reply when posting.'] - 'Open Reply in New Tab': [false, 'Open replies posted from the board pages in a new tab.'] - 'Remember Subject': [false, 'Remember the subject field, instead of resetting after posting.'] - 'Remember Spoiler': [false, 'Remember the spoiler state, instead of resetting after posting.'] - 'Hide Original Post Form': [true, 'Replace the normal post form with a shortcut to open the QR.'] - Quoting: - 'Quote Backlinks': [true, 'Add quote backlinks.'] - 'OP Backlinks': [false, 'Add backlinks to the OP.'] - 'Quote Inline': [true, 'Inline quoted post on click.'] - 'Forward Hiding': [true, 'Hide original posts of inlined backlinks.'] - 'Quote Preview': [true, 'Show quoted post on hover.'] - 'Quote Highlighting': [true, 'Highlight the previewed post.'] - 'Resurrect Quotes': [true, 'Linkify dead quotes to archives.'] - 'Indicate OP Quotes': [true, 'Add \'(OP)\' to OP quotes.'] - 'Indicate Cross-thread Quotes': [true, 'Add \'(Cross-thread)\' to cross-threads quotes.'] - filter: - name: [ - '# Filter any namefags:' - '#/^(?!Anonymous$)/' - ].join '\n' - uniqueid: [ - '# Filter a specific ID:' - '#/Txhvk1Tl/' - ].join '\n' - tripcode: [ - '# Filter any tripfags' - '#/^!/' - ].join '\n' - capcode: [ - '# Set a custom class for mods:' - '#/Mod$/;highlight:mod;op:yes' - '# Set a custom class for moot:' - '#/Admin$/;highlight:moot;op:yes' - ].join '\n' - email: [ - '# Filter any e-mails that are not `sage` on /a/ and /jp/:' - '#/^(?!sage$)/;boards:a,jp' - ].join '\n' - subject: [ - '# Filter Generals on /v/:' - '#/general/i;boards:v;op:only' - ].join '\n' - comment: [ - '# Filter Stallman copypasta on /g/:' - '#/what you\'re refer+ing to as linux/i;boards:g' - ].join '\n' - flag: [ - '' - ].join '\n' - filename: [ - '' - ].join '\n' - dimensions: [ - '# Highlight potential wallpapers:' - '#/1920x1080/;op:yes;highlight;top:no;boards:w,wg' - ].join '\n' - filesize: [ - '' - ].join '\n' - md5: [ - '' - ].join '\n' - sauces: [ - 'http://iqdb.org/?url=%turl' - 'http://www.google.com/searchbyimage?image_url=%turl' - '#http://tineye.com/search?url=%turl' - '#http://saucenao.com/search.php?db=999&url=%turl' - '#http://3d.iqdb.org/?url=%turl' - '#http://regex.info/exif.cgi?imgurl=%url' - '# uploaders:' - '#http://imgur.com/upload?url=%url;text:Upload to imgur' - '#http://omploader.org/upload?url1=%url;text:Upload to omploader' - '# "View Same" in archives:' - '#//archive.foolz.us/_/search/image/%md5/;text:View same on foolz' - '#//archive.foolz.us/%board/search/image/%md5/;text:View same on foolz /%board/' - '#//archive.installgentoo.net/%board/image/%md5;text:View same on installgentoo /%board/' - ].join '\n' - time: '%m/%d/%y(%a)%H:%M:%S' - backlink: '>>%id' - fileInfo: '%l (%p%s, %r)' - favicon: 'ferongr' - hotkeys: - # QR & Options - 'open QR': ['q', 'Open QR with post number inserted.'] - 'open empty QR': ['Q', 'Open QR without post number inserted.'] - 'open options': ['alt+o', 'Open Options.'] - 'close': ['Esc', 'Close Options or QR.'] - 'spoiler tags': ['ctrl+s', 'Insert spoiler tags.'] - 'code tags': ['alt+c', 'Insert code tags.'] - 'submit QR': ['alt+s', 'Submit post.'] - # Thread related - 'watch': ['w', 'Watch thread.'] - 'update': ['u', 'Update the thread now.'] - 'read thread': ['r', 'Mark thread as read.'] - # Images - 'expand image': ['E', 'Expand selected image.'] - 'expand images': ['e', 'Expand all images.'] - # Board Navigation - 'front page': ['0', 'Jump to page 0.'] - 'next page': ['Right', 'Jump to the next page.'] - 'previous page': ['Left', 'Jump to the previous page.'] - # Thread Navigation - 'next thread': ['Down', 'See next thread.'] - 'previous thread': ['Up', 'See previous thread.'] - 'expand thread': ['ctrl+e', 'Expand thread.'] - 'open thread': ['o', 'Open thread in current tab.'] - 'open thread tab': ['O', 'Open thread in new tab.'] - # Reply Navigation - 'next reply': ['j', 'Select next reply.'] - 'previous reply': ['k', 'Select previous reply.'] - 'hide': ['x', 'Hide thread.'] - updater: - checkbox: - 'Auto Scroll': [false, 'Scroll updated posts into view. Only enabled at bottom of page.'] - 'Scroll BG': [false, 'Auto-scroll background tabs.'] - 'Auto Update': [true, 'Automatically fetch new posts.'] - 'Interval': 30 - imageFit: 'fit width' - -# Opera doesn't support the @match metadata key, -# return 4chan X here if we're not on 4chan. -return unless /^(boards|images|sys)\.4chan\.org$/.test location.hostname - -Conf = {} -d = document -g = - VERSION: '3.0.0' - NAMESPACE: '4chan_X.' - boards: {} - threads: {} - posts: {} - -UI = (-> - dialog = (id, position, html) -> - el = d.createElement 'div' - el.className = 'reply dialog' - el.innerHTML = html - el.id = id - el.style.cssText = localStorage.getItem("#{g.NAMESPACE}#{id}.position") or position - move = el.querySelector '.move' - move.addEventListener 'touchstart', dragstart, false - move.addEventListener 'mousedown', dragstart, false - el - - dragstart = (e) -> - # prevent text selection - e.preventDefault() - el = @parentNode - if isTouching = e.type is 'touchstart' - e = e.changedTouches[e.changedTouches.length - 1] - # distance from pointer to el edge is constant; calculate it here. - rect = el.getBoundingClientRect() - screenHeight = d.documentElement.clientHeight - screenWidth = d.documentElement.clientWidth - o = { - id: el.id - style: el.style - dx: e.clientX - rect.left - dy: e.clientY - rect.top - height: screenHeight - rect.height - width: screenWidth - rect.width - screenHeight: screenHeight - screenWidth: screenWidth - isTouching: isTouching - } - if isTouching - o.identifier = e.identifier - o.move = touchmove.bind o - o.up = touchend.bind o - d.addEventListener 'touchmove', o.move, false - d.addEventListener 'touchend', o.up, false - d.addEventListener 'touchcancel', o.up, false - else # mousedown - o.move = drag.bind o - o.up = dragend.bind o - d.addEventListener 'mousemove', o.move, false - d.addEventListener 'mouseup', o.up, false - touchmove = (e) -> - for touch in e.changedTouches - if touch.identifier is @identifier - drag.call @, touch - return - drag = (e) -> - left = e.clientX - @dx - top = e.clientY - @dy - if left < 10 then left = 0 - else if @width - left < 10 then left = null - if top < 10 then top = 0 - else if @height - top < 10 then top = null - if left is null - @style.left = null - @style.right = '0%' - else - @style.left = left / @screenWidth * 100 + '%' - @style.right = null - if top is null - @style.top = null - @style.bottom = '0%' - else - @style.top = top / @screenHeight * 100 + '%' - @style.bottom = null - touchend = (e) -> - for touch in e.changedTouches - if touch.identifier is @identifier - dragend.call @ - return - dragend = -> - if @isTouching - d.removeEventListener 'touchmove', @move, false - d.removeEventListener 'touchend', @up, false - d.removeEventListener 'touchcancel', @up, false - else # mouseup - d.removeEventListener 'mousemove', @move, false - d.removeEventListener 'mouseup', @up, false - localStorage.setItem "#{g.NAMESPACE}#{@id}.position", @style.cssText - - hoverstart = (root, el, events, cb) -> - o = { - root: root - el: el - style: el.style - cb: cb - events: events.split ' ' - clientHeight: d.documentElement.clientHeight - clientWidth: d.documentElement.clientWidth - } - o.hover = hover.bind o - o.hoverend = hoverend.bind o - for event in o.events - root.addEventListener event, o.hoverend, false - root.addEventListener 'mousemove', o.hover, false - hover = (e) -> - height = @el.offsetHeight - top = e.clientY - height / 2 - @style.top = - if @clientHeight <= height or top <= 0 - '0px' - else if top + height >= @clientHeight - @clientHeight - height + 'px' - else - top + 'px' - - {clientX} = e - if clientX <= @clientWidth - 400 - @style.left = clientX + 45 + 'px' - @style.right = null - else - @style.left = null - @style.right = @clientWidth - clientX + 45 + 'px' - hoverend = -> - @el.parentNode.removeChild @el - for event in @events - @root.removeEventListener event, @hoverend, false - @root.removeEventListener 'mousemove', @hover, false - @cb.call @ if @cb - - return { - dialog: dialog - hover: hoverstart - } -)() - -### -loosely follows the jquery api: -http://api.jquery.com/ -not chainable -### -$ = (selector, root=d.body) -> - root.querySelector selector -$$ = (selector, root=d.body) -> - Array::slice.call root.querySelectorAll selector - -$.extend = (object, properties) -> - for key, val of properties - object[key] = val - return - -$.extend $, - SECOND: 1000 - MINUTE: 1000 * 60 - HOUR : 1000 * 60 * 60 - DAY : 1000 * 60 * 60 * 24 - log: console.log.bind console - engine: /WebKit|Presto|Gecko/.exec(navigator.userAgent)[0].toLowerCase() - id: (id) -> - d.getElementById id - ready: (fc) -> - if /interactive|complete/.test d.readyState - $.queueTask fc - return - cb = -> - $.off d, 'DOMContentLoaded', cb - fc() - $.on d, 'DOMContentLoaded', cb - sync: (key, cb) -> - $.on window, 'storage', (e) -> - if e.key is "#{g.NAMESPACE}#{key}" - cb JSON.parse e.newValue - formData: (form) -> - if form instanceof HTMLFormElement - return new FormData form - fd = new FormData() - for key, val of form - fd.append key, val if val - fd - ajax: (url, callbacks, opts={}) -> - {type, headers, upCallbacks, form} = opts - r = new XMLHttpRequest() - type or= form and 'post' or 'get' - r.open type, url, true - for key, val of headers - r.setRequestHeader key, val - $.extend r, callbacks - $.extend r.upload, upCallbacks - r.withCredentials = type is 'post' - r.send form - r - cache: (-> - reqs = {} - (url, cb) -> - if req = reqs[url] - if req.readyState is 4 - cb.call req - else - req.callbacks.push cb - return - req = $.ajax url, - onload: -> - cb.call @ for cb in @callbacks - delete @callbacks - onabort: -> delete reqs[url] - onerror: -> delete reqs[url] - req.callbacks = [cb] - reqs[url] = req - )() - cb: - checked: -> - $.set @name, @checked - Conf[@name] = @checked - value: -> - $.set @name, @value.trim() - Conf[@name] = @value - addStyle: (css) -> - style = $.el 'style', - textContent: css - # That's terrible. - # XXX tmp fix for scriptish: - # https://github.com/scriptish/scriptish/issues/16 - f = -> - # XXX Only Chrome has no d.head on document-start. - if root = d.head or d.documentElement - $.add root, style - else - setTimeout f, 20 - f() - style - x: (path, root=d.body) -> - # XPathResult.ANY_UNORDERED_NODE_TYPE === 8 - d.evaluate(path, root, null, 8, null).singleNodeValue - addClass: (el, className) -> - el.classList.add className - rmClass: (el, className) -> - el.classList.remove className - hasClass: (el, className) -> - el.classList.contains className - rm: (el) -> - el.parentNode.removeChild el - tn: (s) -> - d.createTextNode s - nodes: (nodes) -> - # In (at least) Chrome, elements created inside different - # scripts/window contexts inherit from unequal prototypes. - # window_context1.Node !== window_context2.Node - unless nodes instanceof Array - return nodes - frag = d.createDocumentFragment() - for node in nodes - frag.appendChild node - frag - add: (parent, el) -> - parent.appendChild $.nodes el - prepend: (parent, el) -> - parent.insertBefore $.nodes(el), parent.firstChild - after: (root, el) -> - root.parentNode.insertBefore $.nodes(el), root.nextSibling - before: (root, el) -> - root.parentNode.insertBefore $.nodes(el), root - replace: (root, el) -> - root.parentNode.replaceChild $.nodes(el), root - el: (tag, properties) -> - el = d.createElement tag - $.extend el, properties if properties - el - on: (el, events, handler) -> - for event in events.split ' ' - el.addEventListener event, handler, false - return - off: (el, events, handler) -> - for event in events.split ' ' - el.removeEventListener event, handler, false - return - open: (url) -> - (GM_openInTab or window.open) url, '_blank' - queueTask: (-> - # inspired by https://www.w3.org/Bugs/Public/show_bug.cgi?id=15007 - taskQueue = [] - execTask = -> - task = taskQueue.shift() - func = task[0] - args = Array::slice.call task, 1 - func.apply func, args - if window.MessageChannel - taskChannel = new MessageChannel() - taskChannel.port1.onmessage = execTask - -> - taskQueue.push arguments - taskChannel.port2.postMessage null - else # XXX Firefox - -> - taskQueue.push arguments - setTimeout execTask, 0 - )() - globalEval: (code) -> - script = $.el 'script', - textContent: code - $.add d.head, script - $.rm script - # http://mths.be/unsafewindow - unsafeWindow: - if window.opera # Opera - window - else if unsafeWindow isnt window # Firefox - unsafeWindow - else # Chrome - (-> - p = d.createElement 'p' - p.setAttribute 'onclick', 'return window' - p.onclick() - )() - bytesToString: (size) -> - unit = 0 # Bytes - while size >= 1024 - size /= 1024 - unit++ - # Remove trailing 0s. - size = - if unit > 1 - # Keep the size as a float if the size is greater than 2^20 B. - # Round to hundredth. - Math.round(size * 100) / 100 - else - # Round to an integer otherwise. - Math.round size - "#{size} #{['B', 'KB', 'MB', 'GB'][unit]}" - -$.extend $, - if GM_deleteValue? - delete: (name) -> - GM_deleteValue g.NAMESPACE + name - get: (name, defaultValue) -> - if value = GM_getValue g.NAMESPACE + name - JSON.parse value - else - defaultValue - set: (name, value) -> - name = g.NAMESPACE + name - value = JSON.stringify value - # for `storage` events - localStorage.setItem name, value - GM_setValue name, value - else if window.opera - delete: (name)-> - delete opera.scriptStorage[g.NAMESPACE + name] - get: (name, defaultValue) -> - if value = opera.scriptStorage[g.NAMESPACE + name] - JSON.parse value - else - defaultValue - set: (name, value) -> - name = g.NAMESPACE + name - value = JSON.stringify value - # for `storage` events - localStorage.setItem name, value - opera.scriptStorage[name] = value - else - delete: (name) -> - localStorage.removeItem g.NAMESPACE + name - get: (name, defaultValue) -> - if value = localStorage.getItem g.NAMESPACE + name - JSON.parse value - else - defaultValue - set: (name, value) -> - localStorage.setItem g.NAMESPACE + name, JSON.stringify value - - -class Board - toString: -> @ID - - constructor: (@ID) -> - @threads = {} - @posts = {} - - g.boards[@] = @ - -class Thread - callbacks: [] - toString: -> @ID - - constructor: (ID, @board) -> - @ID = +ID - @posts = {} - - # XXX Can't check when parsing single posts - # move to Post constructor? unless @isReply - # postInfo = $ '.postInfo', root.firstElementChild - # @isClosed = !!$ 'img[title=Closed]', postInfo - # @isSticky = !!$ 'img[title=Sticky]', postInfo - - g.threads["#{board}.#{@}"] = board.threads[@] = @ - -class Post - callbacks: [] - toString: -> @ID - - constructor: (root, @thread, @board, that={}) -> - @ID = +root.id[2..] - - post = $ '.post', root - info = $ '.postInfo', post - @nodes = - root: root - post: post - info: info - comment: $ '.postMessage', post - quotelinks: [] - backlinks: info.getElementsByClassName 'backlink' - - @info = {} - if subject = $ '.subject', info - @nodes.subject = subject - @info.subject = subject.textContent - if name = $ '.name', info - @nodes.name = name - @info.name = name.textContent - if email = $ '.useremail', info - @nodes.email = email - @info.email = decodeURIComponent email.href[7..] - if tripcode = $ '.postertrip', info - @nodes.tripcode = tripcode - @info.tripcode = tripcode.textContent - if uniqueID = $ '.posteruid', info - @nodes.uniqueID = uniqueID - @info.uniqueID = uniqueID.textContent - if capcode = $ '.capcode', info - @nodes.capcode = capcode - @info.capcode = capcode.textContent - if flag = $ '.countryFlag', info - @nodes.flag = flag - @info.flag = flag.title - if date = $ '.dateTime', info - @nodes.date = date - @info.date = new Date date.dataset.utc * 1000 - - # Get the comment's text. - #
-> \n - # Remove: - # 'Comment too long'... - # Admin/Mod/Dev replies. (/q/) - # EXIF data. (/p/) - # Rolls. (/tg/) - # Preceding and following new lines. - # Trailing spaces. - bq = @nodes.comment.cloneNode true - for node in $$ '.abbr, .capcodeReplies, .exif, b', bq - $.rm node - text = [] - # XPathResult.ORDERED_NODE_SNAPSHOT_TYPE === 7 - nodes = d.evaluate './/br|.//text()', bq, null, 7, null - for i in [0...nodes.snapshotLength] - text.push if data = nodes.snapshotItem(i).data then data else '\n' - @info.comment = text.join('').replace /^\n+|\n+$| +(?=\n|$)/g, '' - - quotes = {} - for quotelink in $$ '.quotelink', @nodes.comment - # Don't add board links. (>>>/b/) - # Don't add text-board quotelinks. (>>>/img/1234) - # Don't count capcode replies as quotes. (Admin/Mod/Dev Replies: ...) - # Only add quotes that link to posts on an imageboard. - if quotelink.hash - @nodes.quotelinks.push quotelink - continue if quotelink.parentNode.parentNode.className is 'capcodeReplies' - quotes["#{quotelink.pathname.split('/')[1]}.#{quotelink.hash[2..]}"] = true - @quotes = Object.keys quotes - - if (file = $ '.file', post) and thumb = $ 'img[data-md5]', file - # Supports JPG/PNG/GIF/PDF. - # Flash files are not supported. - alt = thumb.alt - anchor = thumb.parentNode - fileInfo = file.firstElementChild - @file = - info: fileInfo - text: fileInfo.firstElementChild - thumb: thumb - URL: anchor.href - MD5: thumb.dataset.md5 - isSpoiler: $.hasClass anchor, 'imgspoiler' - size = +alt.match(/\d+(\.\d+)?/)[0] - unit = ['B', 'KB', 'MB', 'GB'].indexOf alt.match(/\w+$/)[0] - while unit-- - size *= 1024 - @file.size = size - @file.thumbURL = - if that.isArchived - thumb.src - else - "#{location.protocol}//thumbs.4chan.org/#{board}/thumb/#{@file.URL.match(/(\d+)\./)[1]}s.jpg" - # replace %22 with quotes, see: - # crbug.com/81193 - # webk.it/62107 - # https://www.w3.org/Bugs/Public/show_bug.cgi?id=16909 - # http://www.whatwg.org/specs/web-apps/current-work/#multipart-form-data - @file.name = $('span[title]', fileInfo).title.replace /%22/g, '"' - if @file.isImage = /(jpg|png|gif)$/i.test @file.name - @file.dimensions = @file.text.textContent.match(/\d+x\d+/)[0] - - @isReply = $.hasClass post, 'reply' - @clones = [] - g.posts["#{board}.#{@}"] = thread.posts[@] = board.posts[@] = @ - @kill() if that.isArchived - - kill: (img) -> - if @file and !@file.isDead - @file.isDead = true - return if img - @isDead = true - $.addClass @nodes.root, 'dead' - # XXX style dead posts. - - # Get quote/backlinks to this post, - # and paint them (Dead). - # First: - # In every posts, - # if it did quote this post, - # get all their backlinks. - # Second: - # If we have quote backlinks, - # in all posts this post quoted, - # and their clones, - # get all of their backlinks. - # Third: - # In all collected links, - # apply (Dead) if relevant. - quotelinks = [] - num = "#{@board}.#{@}" - for ID, post of g.posts - if -1 < post.quotes.indexOf num - for post in [post].concat post.clones - quotelinks.push.apply quotelinks, post.nodes.quotelinks - if Conf['Quote Backlinks'] - for quote in @quotes - post = g.posts[quote] - for post in [post].concat post.clones - quotelinks.push.apply quotelinks, Array::slice.call post.nodes.backlinks - for quotelink in quotelinks - continue if $.hasClass quotelink, 'deadlink' - {board, postID} = Get.postDataFromLink quotelink - if board is @board.ID postID is @ID - $.add quotelink, $.tn '\u00A0(Dead)' - $.addClass quotelinks, 'deadlink' - return - addClone: (context) -> - new Clone @, context - rmClone: (index) -> - @clones.splice index, 1 - for clone in @clones[index..] - clone.nodes.root.setAttribute 'data-clone', index++ - return - -class Clone extends Post - constructor: (@origin, @context) -> - for key in ['ID', 'board', 'thread', 'info', 'quotes', 'isReply'] - # Copy or point to the origin's key value. - @[key] = origin[key] - - {nodes} = origin - root = nodes.root.cloneNode true - post = $ '.post', root - info = $ '.postInfo', post - @nodes = - root: root - post: post - info: info - comment: $ '.postMessage', post - quotelinks: [] - backlinks: info.getElementsByClassName 'backlink' - - # Remove inlined posts inside of this post. - for inline in $$ '.inline', post - $.rm inline - for inlined in $$ '.inlined', post - $.rmClass inlined, 'inlined' - - # root.hidden = false # post hiding - $.rmClass root, 'forwarded' # quote inlining - # $.rmClass post, 'highlight' # keybind navigation - - if nodes.subject - @nodes.subject = $ '.subject', info - if nodes.name - @nodes.name = $ '.name', info - if nodes.email - @nodes.email = $ '.useremail', info - if nodes.tripcode - @nodes.tripcode = $ '.postertrip', info - if nodes.uniqueID - @nodes.uniqueID = $ '.posteruid', info - if nodes.capcode - @nodes.capcode = $ '.capcode', info - if nodes.flag - @nodes.flag = $ '.countryFlag', info - if nodes.date - @nodes.date = $ '.dateTime', info - - for quotelink in $$ '.quotelink', @nodes.comment - # See comments in Post's constructor. - if quotelink.hash or $.hasClass quotelink, 'deadlink' - @nodes.quotelinks.push quotelink - - if origin.file - # Copy values, point to relevant elements. - # See comments in Post's constructor. - @file = {} - for key, val of origin.file - @file[key] = val - file = $ '.file', post - @file.info = file.firstElementChild - @file.text = @file.info.firstElementChild - @file.thumb = $ 'img[data-md5]', file - - @isDead = true if origin.isDead - @isClone = true - index = origin.clones.push(@) - 1 - root.setAttribute 'data-clone', index - - -Main = - init: -> - # flatten Config into Conf - # and get saved or default values - flatten = (parent, obj) -> - if obj instanceof Array - Conf[parent] = obj[0] - else if typeof obj is 'object' - for key, val of obj - flatten key, val - else # string or number - Conf[parent] = obj - return - flatten null, Config - for key, val of Conf - Conf[key] = $.get key, val - - pathname = location.pathname.split '/' - g.BOARD = new Board pathname[1] - if g.REPLY = pathname[2] is 'res' - g.THREAD = +pathname[3] - - switch location.hostname - when 'boards.4chan.org' - Main.initHeader() - Main.initFeatures() - when 'sys.4chan.org' - return - when 'images.4chan.org' - $.ready -> - if Conf['404 Redirect'] and d.title is '4chan - 404 Not Found' - path = location.pathname.split '/' - url = Redirect.image path[1], path[3] - location.href = url if url - return - - initHeader: -> - $.addStyle Main.css - Main.header = $.el 'div', - className: 'reply' - innerHTML: '
' - $.ready Main.initHeaderReady - initHeaderReady: -> - header = Main.header - $.prepend d.body, header - - if nav = $.id 'boardNavDesktop' - header.id = nav.id - $.prepend header, nav - nav.id = nav.className = null - nav.lastElementChild.hidden = true - settings = $.el 'span', - id: 'settings' - innerHTML: '[Settings]' - $.on settings.firstElementChild, 'click', Main.settings - $.add nav, settings - $("a[href$='/#{g.BOARD}/']", nav)?.className = 'current' - - $.addClass d.body, $.engine - $.addClass d.body, 'fourchan_x' - - # disable the mobile layout - $('link[href*=mobile]', d.head)?.disabled = true - $.id('boardNavDesktopFoot')?.hidden = true - - initFeatures: -> - if Conf['Disable 4chan\'s extension'] - settings = JSON.parse(localStorage.getItem '4chan-settings') or {} - settings.disableAll = true - localStorage.setItem '4chan-settings', JSON.stringify settings - - if Conf['Resurrect Quotes'] - try - Quotify.init() - catch err - # XXX handle error - $.log err, 'Resurrect Quotes' - - if Conf['Quote Inline'] - try - QuoteInline.init() - catch err - # XXX handle error - $.log err, 'Quote Inline' - - if Conf['Quote Preview'] - try - QuotePreview.init() - catch err - # XXX handle error - $.log err, 'Quote Preview' - - if Conf['Quote Backlinks'] - try - QuoteBacklink.init() - catch err - # XXX handle error - $.log err, 'Quote Backlinks' - - if Conf['Indicate OP Quotes'] - try - QuoteOP.init() - catch err - # XXX handle error - $.log err, 'Indicate OP Quotes' - - if Conf['Indicate Cross-thread Quotes'] - try - QuoteCT.init() - catch err - # XXX handle error - $.log err, 'Indicate Cross-thread Quotes' - - if Conf['Time Formatting'] - try - Time.init() - catch err - # XXX handle error - $.log err, 'Time Formatting' - - if Conf['File Info Formatting'] - try - FileInfo.init() - catch err - # XXX handle error - $.log err, 'File Info Formatting' - - if Conf['Sauce'] - try - Sauce.init() - catch err - # XXX handle error - $.log err, 'Sauce' - - if Conf['Reveal Spoilers'] - try - RevealSpoilers.init() - catch err - # XXX handle error - $.log err, 'Reveal Spoilers' - - if Conf['Auto-GIF'] - try - AutoGIF.init() - catch err - # XXX handle error - $.log err, 'Auto-GIF' - - if Conf['Image Hover'] - try - ImageHover.init() - catch err - # XXX handle error - $.log err, 'Image Hover' - - if Conf['Thread Updater'] - try - ThreadUpdater.init() - catch err - # XXX handle error - $.log err, 'Thread Updater' - - $.ready Main.initFeaturesReady - initFeaturesReady: -> - if d.title is '4chan - 404 Not Found' - if Conf['404 Redirect'] and g.REPLY - location.href = Redirect.thread g.BOARD, g.THREAD, location.hash - return - - return unless $.id 'navtopright' - - threads = [] - posts = [] - - for boardChild in $('.board').children - continue unless $.hasClass boardChild, 'thread' - thread = new Thread boardChild.id[1..], g.BOARD - threads.push thread - for threadChild in boardChild.children - continue unless $.hasClass threadChild, 'postContainer' - try - posts.push new Post threadChild, thread, g.BOARD - catch err - # Skip posts that we failed to parse. - # XXX handle error - # Post parser crashed for post No.#{threadChild.id[2..]} - $.log threadChild, err - - Main.callbackNodes Thread, threads, true - Main.callbackNodes Post, posts, true - - callbackNodes: (klass, nodes, notify) -> - # get the nodes' length only once - len = nodes.length - for callback in klass::callbacks - try - for i in [0...len] - callback.cb.call nodes[i] - catch err - # XXX handle error if notify - $.log callback.name, 'crashed. error:', err.message, nodes[i], err - return - - settings: -> - alert 'Here be settings' - - css: """ -/* general */ -.dialog.reply { - display: block; - border: 1px solid rgba(0, 0, 0, .25); - padding: 0; -} -.move { - cursor: move; -} -label { - cursor: pointer; -} -a[href="javascript:;"] { - text-decoration: none; -} -.warning { - color: red; -} - -/* 4chan style fixes */ -.opContainer, .op { - display: block !important; -} -.post { - overflow: visible !important; -} - -/* fixed, z-index */ -#qp, #ihover, -#updater, #stats, -#boardNavDesktop.reply, -#qr, #watcher { - position: fixed; -} -#qp, #ihover { - z-index: 100; -} -#updater, #stats { - z-index: 90; -} -#boardNavDesktop.reply:hover { - z-index: 80; -} -#qr { - z-index: 50; -} -#watcher { - z-index: 30; -} -#boardNavDesktop.reply { - z-index: 10; -} - - -/* header */ -body.fourchan_x { - margin-top: 2.5em; -} -#boardNavDesktop.reply { - border-width: 0 0 1px; - padding: 4px; - top: 0; - right: 0; - left: 0; - transition: opacity .1s ease-in-out; - -o-transition: opacity .1s ease-in-out; - -moz-transition: opacity .1s ease-in-out; - -webkit-transition: opacity .1s ease-in-out; -} -#boardNavDesktop.reply:not(:hover) { - opacity: .4; - transition: opacity 1.5s .5s ease-in-out; - -o-transition: opacity 1.5s .5s ease-in-out; - -moz-transition: opacity 1.5s .5s ease-in-out; - -webkit-transition: opacity 1.5s .5s ease-in-out; -} -#boardNavDesktop.reply a { - margin: -1px; -} -#settings { - float: right; -} - -/* thread updater */ -#updater { - text-align: right; -} -#updater:not(:hover) { - background: none; - border: none; -} -#updater input[type=number] { - width: 4em; -} -#updater:not(:hover) > div:not(.move) { - display: none; -} -.new { - color: limegreen; -} - -/* quote */ -.quotelink.deadlink { - text-decoration: underline !important; -} -.deadlink:not(.quotelink) { - text-decoration: none !important; -} -.inlined { - opacity: .5; -} -#qp input, .forwarded { - display: none; -} -.quotelink.forwardlink, -.backlink.forwardlink { - text-decoration: none; - border-bottom: 1px dashed; -} -.inline { - border: 1px solid rgba(128, 128, 128, .5); - display: table; - margin: 2px 0; -} -.inline .post { - border: 0 !important; - display: table !important; - margin: 0 !important; - padding: 1px 2px !important; -} -#qp { - padding: 2px 2px 5px; -} -#qp .post { - border: none; - margin: 0; - padding: 0; -} -#qp img { - max-height: 300px; - max-width: 500px; -} -.qphl { - box-shadow: 0 0 0 2px rgba(216, 94, 49, .7); -} - -/* file */ -.fileText:hover .fntrunc, -.fileText:not(:hover) .fnfull { - display: none; -} -#ihover { - box-sizing: border-box; - -moz-box-sizing: border-box; - max-height: 100%; - max-width: 75%; - padding-bottom: 16px; -} -""" - - - Redirect = image: (board, filename) -> # XXX need to differentiate between thumbnail only and full_image for img src= diff --git a/src/globals.coffee b/src/globals.coffee new file mode 100644 index 000000000..5216f7621 --- /dev/null +++ b/src/globals.coffee @@ -0,0 +1,12 @@ +# Opera doesn't support the @match metadata key, +# return 4chan X here if we're not on 4chan. +return unless /^(boards|images|sys)\.4chan\.org$/.test location.hostname + +Conf = {} +d = document +g = + VERSION: '<%= meta.version %>' + NAMESPACE: "<%= meta.name.replace(/ /g, '_') %>." + boards: {} + threads: {} + posts: {} diff --git a/src/main.coffee b/src/main.coffee new file mode 100644 index 000000000..32eb83cd4 --- /dev/null +++ b/src/main.coffee @@ -0,0 +1,458 @@ +class Board + toString: -> @ID + + constructor: (@ID) -> + @threads = {} + @posts = {} + + g.boards[@] = @ + +class Thread + callbacks: [] + toString: -> @ID + + constructor: (ID, @board) -> + @ID = +ID + @posts = {} + + # XXX Can't check when parsing single posts + # move to Post constructor? unless @isReply + # postInfo = $ '.postInfo', root.firstElementChild + # @isClosed = !!$ 'img[title=Closed]', postInfo + # @isSticky = !!$ 'img[title=Sticky]', postInfo + + g.threads["#{board}.#{@}"] = board.threads[@] = @ + +class Post + callbacks: [] + toString: -> @ID + + constructor: (root, @thread, @board, that={}) -> + @ID = +root.id[2..] + + post = $ '.post', root + info = $ '.postInfo', post + @nodes = + root: root + post: post + info: info + comment: $ '.postMessage', post + quotelinks: [] + backlinks: info.getElementsByClassName 'backlink' + + @info = {} + if subject = $ '.subject', info + @nodes.subject = subject + @info.subject = subject.textContent + if name = $ '.name', info + @nodes.name = name + @info.name = name.textContent + if email = $ '.useremail', info + @nodes.email = email + @info.email = decodeURIComponent email.href[7..] + if tripcode = $ '.postertrip', info + @nodes.tripcode = tripcode + @info.tripcode = tripcode.textContent + if uniqueID = $ '.posteruid', info + @nodes.uniqueID = uniqueID + @info.uniqueID = uniqueID.textContent + if capcode = $ '.capcode', info + @nodes.capcode = capcode + @info.capcode = capcode.textContent + if flag = $ '.countryFlag', info + @nodes.flag = flag + @info.flag = flag.title + if date = $ '.dateTime', info + @nodes.date = date + @info.date = new Date date.dataset.utc * 1000 + + # Get the comment's text. + #
-> \n + # Remove: + # 'Comment too long'... + # Admin/Mod/Dev replies. (/q/) + # EXIF data. (/p/) + # Rolls. (/tg/) + # Preceding and following new lines. + # Trailing spaces. + bq = @nodes.comment.cloneNode true + for node in $$ '.abbr, .capcodeReplies, .exif, b', bq + $.rm node + text = [] + # XPathResult.ORDERED_NODE_SNAPSHOT_TYPE === 7 + nodes = d.evaluate './/br|.//text()', bq, null, 7, null + for i in [0...nodes.snapshotLength] + text.push if data = nodes.snapshotItem(i).data then data else '\n' + @info.comment = text.join('').replace /^\n+|\n+$| +(?=\n|$)/g, '' + + quotes = {} + for quotelink in $$ '.quotelink', @nodes.comment + # Don't add board links. (>>>/b/) + # Don't add text-board quotelinks. (>>>/img/1234) + # Don't count capcode replies as quotes. (Admin/Mod/Dev Replies: ...) + # Only add quotes that link to posts on an imageboard. + if quotelink.hash + @nodes.quotelinks.push quotelink + continue if quotelink.parentNode.parentNode.className is 'capcodeReplies' + quotes["#{quotelink.pathname.split('/')[1]}.#{quotelink.hash[2..]}"] = true + @quotes = Object.keys quotes + + if (file = $ '.file', post) and thumb = $ 'img[data-md5]', file + # Supports JPG/PNG/GIF/PDF. + # Flash files are not supported. + alt = thumb.alt + anchor = thumb.parentNode + fileInfo = file.firstElementChild + @file = + info: fileInfo + text: fileInfo.firstElementChild + thumb: thumb + URL: anchor.href + MD5: thumb.dataset.md5 + isSpoiler: $.hasClass anchor, 'imgspoiler' + size = +alt.match(/\d+(\.\d+)?/)[0] + unit = ['B', 'KB', 'MB', 'GB'].indexOf alt.match(/\w+$/)[0] + while unit-- + size *= 1024 + @file.size = size + @file.thumbURL = + if that.isArchived + thumb.src + else + "#{location.protocol}//thumbs.4chan.org/#{board}/thumb/#{@file.URL.match(/(\d+)\./)[1]}s.jpg" + # replace %22 with quotes, see: + # crbug.com/81193 + # webk.it/62107 + # https://www.w3.org/Bugs/Public/show_bug.cgi?id=16909 + # http://www.whatwg.org/specs/web-apps/current-work/#multipart-form-data + @file.name = $('span[title]', fileInfo).title.replace /%22/g, '"' + if @file.isImage = /(jpg|png|gif)$/i.test @file.name + @file.dimensions = @file.text.textContent.match(/\d+x\d+/)[0] + + @isReply = $.hasClass post, 'reply' + @clones = [] + g.posts["#{board}.#{@}"] = thread.posts[@] = board.posts[@] = @ + @kill() if that.isArchived + + kill: (img) -> + if @file and !@file.isDead + @file.isDead = true + return if img + @isDead = true + $.addClass @nodes.root, 'dead' + # XXX style dead posts. + + # Get quote/backlinks to this post, + # and paint them (Dead). + # First: + # In every posts, + # if it did quote this post, + # get all their backlinks. + # Second: + # If we have quote backlinks, + # in all posts this post quoted, + # and their clones, + # get all of their backlinks. + # Third: + # In all collected links, + # apply (Dead) if relevant. + quotelinks = [] + num = "#{@board}.#{@}" + for ID, post of g.posts + if -1 < post.quotes.indexOf num + for post in [post].concat post.clones + quotelinks.push.apply quotelinks, post.nodes.quotelinks + if Conf['Quote Backlinks'] + for quote in @quotes + post = g.posts[quote] + for post in [post].concat post.clones + quotelinks.push.apply quotelinks, Array::slice.call post.nodes.backlinks + for quotelink in quotelinks + continue if $.hasClass quotelink, 'deadlink' + {board, postID} = Get.postDataFromLink quotelink + if board is @board.ID postID is @ID + $.add quotelink, $.tn '\u00A0(Dead)' + $.addClass quotelinks, 'deadlink' + return + addClone: (context) -> + new Clone @, context + rmClone: (index) -> + @clones.splice index, 1 + for clone in @clones[index..] + clone.nodes.root.setAttribute 'data-clone', index++ + return + +class Clone extends Post + constructor: (@origin, @context) -> + for key in ['ID', 'board', 'thread', 'info', 'quotes', 'isReply'] + # Copy or point to the origin's key value. + @[key] = origin[key] + + {nodes} = origin + root = nodes.root.cloneNode true + post = $ '.post', root + info = $ '.postInfo', post + @nodes = + root: root + post: post + info: info + comment: $ '.postMessage', post + quotelinks: [] + backlinks: info.getElementsByClassName 'backlink' + + # Remove inlined posts inside of this post. + for inline in $$ '.inline', post + $.rm inline + for inlined in $$ '.inlined', post + $.rmClass inlined, 'inlined' + + # root.hidden = false # post hiding + $.rmClass root, 'forwarded' # quote inlining + # $.rmClass post, 'highlight' # keybind navigation + + if nodes.subject + @nodes.subject = $ '.subject', info + if nodes.name + @nodes.name = $ '.name', info + if nodes.email + @nodes.email = $ '.useremail', info + if nodes.tripcode + @nodes.tripcode = $ '.postertrip', info + if nodes.uniqueID + @nodes.uniqueID = $ '.posteruid', info + if nodes.capcode + @nodes.capcode = $ '.capcode', info + if nodes.flag + @nodes.flag = $ '.countryFlag', info + if nodes.date + @nodes.date = $ '.dateTime', info + + for quotelink in $$ '.quotelink', @nodes.comment + # See comments in Post's constructor. + if quotelink.hash or $.hasClass quotelink, 'deadlink' + @nodes.quotelinks.push quotelink + + if origin.file + # Copy values, point to relevant elements. + # See comments in Post's constructor. + @file = {} + for key, val of origin.file + @file[key] = val + file = $ '.file', post + @file.info = file.firstElementChild + @file.text = @file.info.firstElementChild + @file.thumb = $ 'img[data-md5]', file + + @isDead = true if origin.isDead + @isClone = true + index = origin.clones.push(@) - 1 + root.setAttribute 'data-clone', index + + +Main = + init: -> + # flatten Config into Conf + # and get saved or default values + flatten = (parent, obj) -> + if obj instanceof Array + Conf[parent] = obj[0] + else if typeof obj is 'object' + for key, val of obj + flatten key, val + else # string or number + Conf[parent] = obj + return + flatten null, Config + for key, val of Conf + Conf[key] = $.get key, val + + pathname = location.pathname.split '/' + g.BOARD = new Board pathname[1] + if g.REPLY = pathname[2] is 'res' + g.THREAD = +pathname[3] + + switch location.hostname + when 'boards.4chan.org' + Main.initHeader() + Main.initFeatures() + when 'sys.4chan.org' + return + when 'images.4chan.org' + $.ready -> + if Conf['404 Redirect'] and d.title is '4chan - 404 Not Found' + path = location.pathname.split '/' + url = Redirect.image path[1], path[3] + location.href = url if url + return + + initHeader: -> + $.addStyle Main.css + Main.header = $.el 'div', + className: 'reply' + innerHTML: '
' + $.ready Main.initHeaderReady + initHeaderReady: -> + header = Main.header + $.prepend d.body, header + + if nav = $.id 'boardNavDesktop' + header.id = nav.id + $.prepend header, nav + nav.id = nav.className = null + nav.lastElementChild.hidden = true + settings = $.el 'span', + id: 'settings' + innerHTML: '[Settings]' + $.on settings.firstElementChild, 'click', Main.settings + $.add nav, settings + $("a[href$='/#{g.BOARD}/']", nav)?.className = 'current' + + $.addClass d.body, $.engine + $.addClass d.body, 'fourchan_x' + + # disable the mobile layout + $('link[href*=mobile]', d.head)?.disabled = true + $.id('boardNavDesktopFoot')?.hidden = true + + initFeatures: -> + if Conf['Disable 4chan\'s extension'] + settings = JSON.parse(localStorage.getItem '4chan-settings') or {} + settings.disableAll = true + localStorage.setItem '4chan-settings', JSON.stringify settings + + if Conf['Resurrect Quotes'] + try + Quotify.init() + catch err + # XXX handle error + $.log err, 'Resurrect Quotes' + + if Conf['Quote Inline'] + try + QuoteInline.init() + catch err + # XXX handle error + $.log err, 'Quote Inline' + + if Conf['Quote Preview'] + try + QuotePreview.init() + catch err + # XXX handle error + $.log err, 'Quote Preview' + + if Conf['Quote Backlinks'] + try + QuoteBacklink.init() + catch err + # XXX handle error + $.log err, 'Quote Backlinks' + + if Conf['Indicate OP Quotes'] + try + QuoteOP.init() + catch err + # XXX handle error + $.log err, 'Indicate OP Quotes' + + if Conf['Indicate Cross-thread Quotes'] + try + QuoteCT.init() + catch err + # XXX handle error + $.log err, 'Indicate Cross-thread Quotes' + + if Conf['Time Formatting'] + try + Time.init() + catch err + # XXX handle error + $.log err, 'Time Formatting' + + if Conf['File Info Formatting'] + try + FileInfo.init() + catch err + # XXX handle error + $.log err, 'File Info Formatting' + + if Conf['Sauce'] + try + Sauce.init() + catch err + # XXX handle error + $.log err, 'Sauce' + + if Conf['Reveal Spoilers'] + try + RevealSpoilers.init() + catch err + # XXX handle error + $.log err, 'Reveal Spoilers' + + if Conf['Auto-GIF'] + try + AutoGIF.init() + catch err + # XXX handle error + $.log err, 'Auto-GIF' + + if Conf['Image Hover'] + try + ImageHover.init() + catch err + # XXX handle error + $.log err, 'Image Hover' + + if Conf['Thread Updater'] + try + ThreadUpdater.init() + catch err + # XXX handle error + $.log err, 'Thread Updater' + + $.ready Main.initFeaturesReady + initFeaturesReady: -> + if d.title is '4chan - 404 Not Found' + if Conf['404 Redirect'] and g.REPLY + location.href = Redirect.thread g.BOARD, g.THREAD, location.hash + return + + return unless $.id 'navtopright' + + threads = [] + posts = [] + + for boardChild in $('.board').children + continue unless $.hasClass boardChild, 'thread' + thread = new Thread boardChild.id[1..], g.BOARD + threads.push thread + for threadChild in boardChild.children + continue unless $.hasClass threadChild, 'postContainer' + try + posts.push new Post threadChild, thread, g.BOARD + catch err + # Skip posts that we failed to parse. + # XXX handle error + # Post parser crashed for post No.#{threadChild.id[2..]} + $.log threadChild, err + + Main.callbackNodes Thread, threads, true + Main.callbackNodes Post, posts, true + + callbackNodes: (klass, nodes, notify) -> + # get the nodes' length only once + len = nodes.length + for callback in klass::callbacks + try + for i in [0...len] + callback.cb.call nodes[i] + catch err + # XXX handle error if notify + $.log callback.name, 'crashed. error:', err.message, nodes[i], err + return + + settings: -> + alert 'Here be settings' + + css: """<%= grunt.file.read('css/style.css') %>"""