├── src ├── meta │ ├── newline.js │ ├── fend.js │ ├── fbegin.js │ ├── icon.gif │ ├── icon128.png │ ├── icon16.png │ ├── icon48.png │ ├── updates.json │ ├── updates.xml │ ├── jshint.json │ ├── manifest.json │ ├── eventPage.coffee │ └── metadata.js ├── site │ ├── SW.js │ ├── SW.yotsuba.Build │ │ ├── CatalogReply.html │ │ ├── Post.html │ │ ├── CatalogThread.html │ │ ├── File.html │ │ └── PostInfo.html │ └── Site.coffee ├── Posting │ ├── Captcha.js │ ├── PostSuccessful.coffee │ ├── PostRedirect.coffee │ ├── PassLink.coffee │ ├── QR.persona.coffee │ ├── Captcha.replace.coffee │ ├── Captcha.service.coffee │ ├── Captcha.cache.coffee │ └── QR │ │ └── QuickReply.html ├── css │ ├── linkify.audio.png │ ├── linkify.clyp.png │ ├── linkify.gist.png │ ├── linkify.image.png │ ├── linkify.video.png │ ├── linkify.vimeo.png │ ├── linkify.vine.png │ ├── linkify.bitchute.png │ ├── linkify.gfycat.png │ ├── linkify.liveleak.png │ ├── linkify.pastebin.png │ ├── linkify.peertube.png │ ├── linkify.twitchtv.png │ ├── linkify.twitter.png │ ├── linkify.vidlii.png │ ├── linkify.vocaroo.png │ ├── linkify.youtube.png │ ├── linkify.soundcloud.png │ ├── linkify.streamable.png │ ├── linkify.dailymotion.png │ ├── linkify.installgentoo.png │ ├── www.css │ ├── supports.css │ ├── report.css │ ├── style.inc │ ├── CSS.js │ ├── font-awesome.css │ ├── futaba.css │ ├── burichan.css │ ├── yotsuba.css │ ├── yotsuba-b.css │ └── photon.css ├── platform │ └── $$.coffee ├── Monitoring │ ├── Favicon │ │ ├── dead.gif │ │ ├── empty.gif │ │ ├── exclamation.png │ │ ├── Metro.readSFW.png │ │ ├── Mayhem.unreadSFW.png │ │ ├── Metro.readNSFW.png │ │ ├── Metro.unreadDead.png │ │ ├── Metro.unreadNSFW.png │ │ ├── Metro.unreadSFW.png │ │ ├── Metro.unreadSFWY.png │ │ ├── xat-.unreadDead.png │ │ ├── xat-.unreadDeadY.png │ │ ├── xat-.unreadNSFW.png │ │ ├── xat-.unreadNSFWY.png │ │ ├── xat-.unreadSFW.png │ │ ├── xat-.unreadSFWY.png │ │ ├── 4chanJS.unreadDead.png │ │ ├── 4chanJS.unreadNSFW.png │ │ ├── 4chanJS.unreadSFW.png │ │ ├── 4chanJS.unreadSFWY.png │ │ ├── Mayhem.unreadDead.png │ │ ├── Mayhem.unreadDeadY.png │ │ ├── Mayhem.unreadNSFW.png │ │ ├── Mayhem.unreadNSFWY.png │ │ ├── Mayhem.unreadSFWY.png │ │ ├── Metro.unreadDeadY.png │ │ ├── Metro.unreadNSFWY.png │ │ ├── Original.unreadSFW.png │ │ ├── ferongr.unreadDead.png │ │ ├── ferongr.unreadNSFW.png │ │ ├── ferongr.unreadSFW.png │ │ ├── ferongr.unreadSFWY.png │ │ ├── 4chanJS.unreadDeadY.png │ │ ├── 4chanJS.unreadNSFWY.png │ │ ├── Original.unreadDead.png │ │ ├── Original.unreadDeadY.png │ │ ├── Original.unreadNSFW.png │ │ ├── Original.unreadNSFWY.png │ │ ├── Original.unreadSFWY.png │ │ ├── ferongr.unreadDeadY.png │ │ └── ferongr.unreadNSFWY.png │ ├── ThreadUpdater │ │ └── beep.wav │ ├── ThreadWatcher │ │ └── ThreadWatcher.html │ ├── MarkNewIPs.coffee │ ├── Favicon.coffee │ └── UnreadIndex.coffee ├── Filtering │ ├── Anonymize.coffee │ └── Recursive.coffee ├── Linkification │ └── Embedding │ │ └── Embed.html ├── General │ ├── Index │ │ ├── PageList.html │ │ └── NavLinks.html │ ├── Settings │ │ ├── Keybinds.html │ │ ├── Settings.html │ │ ├── Filter-select.html │ │ ├── Sauce.html │ │ └── Filter-guide.html │ ├── Polyfill.coffee │ ├── BoardConfig.coffee │ └── Get.coffee ├── Miscellaneous │ ├── Report │ │ └── ArchiveReport.html │ ├── CustomCSS.coffee │ ├── PSA.coffee │ ├── Flash.coffee │ ├── PassMessage │ │ └── PassMessage.html │ ├── PassMessage.coffee │ ├── ThreadLinks.coffee │ ├── NormalizeURL.coffee │ ├── IDPostCount.coffee │ ├── RemoveSpoilers.coffee │ ├── IDHighlight.coffee │ ├── Tinyboard.coffee │ ├── AntiAutoplay.coffee │ ├── IDColor.coffee │ ├── ModContact.coffee │ ├── PSAHiding.coffee │ ├── PostJumper.coffee │ ├── Time.coffee │ ├── ExpandComment.coffee │ ├── Nav.coffee │ ├── FileInfo.coffee │ ├── Banner.coffee │ ├── Fourchan.coffee │ └── Report.coffee ├── classes │ ├── ShimSet.coffee │ ├── CatalogThreadNative.coffee │ ├── CatalogThread.coffee │ ├── SimpleDict.coffee │ ├── Board.coffee │ ├── Connection.coffee │ ├── Callbacks.coffee │ ├── Notice.coffee │ ├── RandomAccessList.coffee │ ├── Post.Clone.coffee │ └── Thread.coffee ├── Quotelinks │ ├── QuoteStrikeThrough.coffee │ ├── QuoteCT.coffee │ ├── QuoteOP.coffee │ ├── QuotePreview.coffee │ ├── QuoteBacklink.coffee │ ├── Quotify.coffee │ └── QuoteInline.coffee ├── Menu │ ├── DownloadLink.coffee │ ├── CopyTextLink.coffee │ ├── ReportLink.coffee │ ├── Menu.coffee │ └── ArchiveLink.coffee ├── config │ └── user.css ├── Images │ ├── Gallery │ │ └── Gallery.html │ ├── RevealSpoilers.coffee │ ├── ImageHost.coffee │ ├── FappeTyme.coffee │ ├── Metadata.coffee │ ├── ImageHover.coffee │ ├── Volume.coffee │ ├── ImageLoader.coffee │ └── ImageCommon.coffee └── globals │ └── globals.js ├── .jshintrc ├── img ├── 1.2.0.png ├── 1.3.6.gif ├── 1.4.1.png ├── 1.6.0.png ├── icon.gif ├── 1.1.18.png ├── 1.2.28.png ├── 1.2.31.png ├── 1.2.46.png ├── 1.11.27.0.png ├── 1.11.28.1.png ├── 1.11.34.0.png ├── 1.2.28-2.png ├── 1.9.17.3.png └── screenshot.png ├── version.json ├── crx-chromium-version.txt ├── tools ├── .jshintrc ├── newlinefix.js ├── install.js ├── unpinned.js ├── sign.sh ├── pkgvars.js ├── declare.js ├── markdown.js ├── banners.py ├── zip-crx.js ├── bump.js ├── chain.js ├── webstore.js └── updcl.js ├── .gitattributes ├── .travis.yml ├── .gitignore ├── web.css ├── LICENSE └── template.jst /src/meta/newline.js: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/site/SW.js: -------------------------------------------------------------------------------- 1 | SW = {}; 2 | -------------------------------------------------------------------------------- /src/Posting/Captcha.js: -------------------------------------------------------------------------------- 1 | Captcha = {}; 2 | -------------------------------------------------------------------------------- /src/meta/fend.js: -------------------------------------------------------------------------------- 1 | Main.init(); 2 | 3 | })(); 4 | -------------------------------------------------------------------------------- /src/meta/fbegin.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | 'use strict'; 4 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "undef": true, 3 | "unused": true 4 | } 5 | -------------------------------------------------------------------------------- /img/1.2.0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/img/1.2.0.png -------------------------------------------------------------------------------- /img/1.3.6.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/img/1.3.6.gif -------------------------------------------------------------------------------- /img/1.4.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/img/1.4.1.png -------------------------------------------------------------------------------- /img/1.6.0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/img/1.6.0.png -------------------------------------------------------------------------------- /img/icon.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/img/icon.gif -------------------------------------------------------------------------------- /img/1.1.18.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/img/1.1.18.png -------------------------------------------------------------------------------- /img/1.2.28.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/img/1.2.28.png -------------------------------------------------------------------------------- /img/1.2.31.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/img/1.2.31.png -------------------------------------------------------------------------------- /img/1.2.46.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/img/1.2.46.png -------------------------------------------------------------------------------- /img/1.11.27.0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/img/1.11.27.0.png -------------------------------------------------------------------------------- /img/1.11.28.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/img/1.11.28.1.png -------------------------------------------------------------------------------- /img/1.11.34.0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/img/1.11.34.0.png -------------------------------------------------------------------------------- /img/1.2.28-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/img/1.2.28-2.png -------------------------------------------------------------------------------- /img/1.9.17.3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/img/1.9.17.3.png -------------------------------------------------------------------------------- /img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/img/screenshot.png -------------------------------------------------------------------------------- /src/meta/icon.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/meta/icon.gif -------------------------------------------------------------------------------- /src/meta/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/meta/icon128.png -------------------------------------------------------------------------------- /src/meta/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/meta/icon16.png -------------------------------------------------------------------------------- /src/meta/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/meta/icon48.png -------------------------------------------------------------------------------- /version.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.14.14.4", 3 | "date": "2019-09-16T04:34:13.281Z" 4 | } -------------------------------------------------------------------------------- /src/css/linkify.audio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/css/linkify.audio.png -------------------------------------------------------------------------------- /src/css/linkify.clyp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/css/linkify.clyp.png -------------------------------------------------------------------------------- /src/css/linkify.gist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/css/linkify.gist.png -------------------------------------------------------------------------------- /src/css/linkify.image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/css/linkify.image.png -------------------------------------------------------------------------------- /src/css/linkify.video.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/css/linkify.video.png -------------------------------------------------------------------------------- /src/css/linkify.vimeo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/css/linkify.vimeo.png -------------------------------------------------------------------------------- /src/css/linkify.vine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/css/linkify.vine.png -------------------------------------------------------------------------------- /src/platform/$$.coffee: -------------------------------------------------------------------------------- 1 | $$ = (selector, root=d.body) -> 2 | [root.querySelectorAll(selector)...] 3 | -------------------------------------------------------------------------------- /crx-chromium-version.txt: -------------------------------------------------------------------------------- 1 | Chromium 73.0.3683.75 built on Debian buster/sid, running on Debian buster/sid 2 | -------------------------------------------------------------------------------- /src/css/linkify.bitchute.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/css/linkify.bitchute.png -------------------------------------------------------------------------------- /src/css/linkify.gfycat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/css/linkify.gfycat.png -------------------------------------------------------------------------------- /src/css/linkify.liveleak.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/css/linkify.liveleak.png -------------------------------------------------------------------------------- /src/css/linkify.pastebin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/css/linkify.pastebin.png -------------------------------------------------------------------------------- /src/css/linkify.peertube.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/css/linkify.peertube.png -------------------------------------------------------------------------------- /src/css/linkify.twitchtv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/css/linkify.twitchtv.png -------------------------------------------------------------------------------- /src/css/linkify.twitter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/css/linkify.twitter.png -------------------------------------------------------------------------------- /src/css/linkify.vidlii.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/css/linkify.vidlii.png -------------------------------------------------------------------------------- /src/css/linkify.vocaroo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/css/linkify.vocaroo.png -------------------------------------------------------------------------------- /src/css/linkify.youtube.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/css/linkify.youtube.png -------------------------------------------------------------------------------- /src/css/linkify.soundcloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/css/linkify.soundcloud.png -------------------------------------------------------------------------------- /src/css/linkify.streamable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/css/linkify.streamable.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/dead.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/dead.gif -------------------------------------------------------------------------------- /src/Monitoring/Favicon/empty.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/empty.gif -------------------------------------------------------------------------------- /src/css/linkify.dailymotion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/css/linkify.dailymotion.png -------------------------------------------------------------------------------- /src/css/linkify.installgentoo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/css/linkify.installgentoo.png -------------------------------------------------------------------------------- /tools/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "esnext": true, 3 | "undef": true, 4 | "unused": true, 5 | "node": true 6 | } 7 | -------------------------------------------------------------------------------- /src/Monitoring/Favicon/exclamation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/exclamation.png -------------------------------------------------------------------------------- /src/Monitoring/ThreadUpdater/beep.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/ThreadUpdater/beep.wav -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | builds/* -text 5 | index.html -text 6 | -------------------------------------------------------------------------------- /src/Monitoring/Favicon/Metro.readSFW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/Metro.readSFW.png -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: generic 3 | install: npm install 4 | script: make 5 | branches: 6 | only: 7 | - master 8 | -------------------------------------------------------------------------------- /src/Filtering/Anonymize.coffee: -------------------------------------------------------------------------------- 1 | Anonymize = 2 | init: -> 3 | return unless Conf['Anonymize'] 4 | $.addClass doc, 'anonymize' 5 | -------------------------------------------------------------------------------- /src/Monitoring/Favicon/Mayhem.unreadSFW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/Mayhem.unreadSFW.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/Metro.readNSFW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/Metro.readNSFW.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/Metro.unreadDead.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/Metro.unreadDead.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/Metro.unreadNSFW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/Metro.unreadNSFW.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/Metro.unreadSFW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/Metro.unreadSFW.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/Metro.unreadSFWY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/Metro.unreadSFWY.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/xat-.unreadDead.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/xat-.unreadDead.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/xat-.unreadDeadY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/xat-.unreadDeadY.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/xat-.unreadNSFW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/xat-.unreadNSFW.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/xat-.unreadNSFWY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/xat-.unreadNSFWY.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/xat-.unreadSFW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/xat-.unreadSFW.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/xat-.unreadSFWY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/xat-.unreadSFWY.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/4chanJS.unreadDead.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/4chanJS.unreadDead.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/4chanJS.unreadNSFW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/4chanJS.unreadNSFW.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/4chanJS.unreadSFW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/4chanJS.unreadSFW.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/4chanJS.unreadSFWY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/4chanJS.unreadSFWY.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/Mayhem.unreadDead.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/Mayhem.unreadDead.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/Mayhem.unreadDeadY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/Mayhem.unreadDeadY.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/Mayhem.unreadNSFW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/Mayhem.unreadNSFW.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/Mayhem.unreadNSFWY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/Mayhem.unreadNSFWY.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/Mayhem.unreadSFWY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/Mayhem.unreadSFWY.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/Metro.unreadDeadY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/Metro.unreadDeadY.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/Metro.unreadNSFWY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/Metro.unreadNSFWY.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/Original.unreadSFW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/Original.unreadSFW.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/ferongr.unreadDead.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/ferongr.unreadDead.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/ferongr.unreadNSFW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/ferongr.unreadNSFW.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/ferongr.unreadSFW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/ferongr.unreadSFW.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/ferongr.unreadSFWY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/ferongr.unreadSFWY.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/4chanJS.unreadDeadY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/4chanJS.unreadDeadY.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/4chanJS.unreadNSFWY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/4chanJS.unreadNSFWY.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/Original.unreadDead.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/Original.unreadDead.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/Original.unreadDeadY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/Original.unreadDeadY.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/Original.unreadNSFW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/Original.unreadNSFW.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/Original.unreadNSFWY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/Original.unreadNSFWY.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/Original.unreadSFWY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/Original.unreadSFWY.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/ferongr.unreadDeadY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/ferongr.unreadDeadY.png -------------------------------------------------------------------------------- /src/Monitoring/Favicon/ferongr.unreadNSFWY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/4chan-x/master/src/Monitoring/Favicon/ferongr.unreadNSFWY.png -------------------------------------------------------------------------------- /tools/newlinefix.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | 3 | var text = fs.readFileSync(process.argv[2], 'utf8'); 4 | text = text.replace(/\r\n/g, '\n'); 5 | fs.writeFileSync(process.argv[3], text); 6 | -------------------------------------------------------------------------------- /src/site/SW.yotsuba.Build/CatalogReply.html: -------------------------------------------------------------------------------- 1 | : 2 | ${excerpt} 3 | ... 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | /npm-shrinkwrap.json 3 | *~ 4 | *.db 5 | *.DS_Store 6 | /tmp/ 7 | /testbuilds/ 8 | /test.html 9 | /captchas.html 10 | /install.json 11 | /.tests_enabled 12 | /.events 13 | /.events2 14 | /dist/ 15 | /builds/*.gz 16 | /test/ 17 | -------------------------------------------------------------------------------- /src/Linkification/Embedding/Embed.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | × 5 |
6 |
7 | -------------------------------------------------------------------------------- /src/css/www.css: -------------------------------------------------------------------------------- 1 | #captcha-cnt { 2 | height: auto; 3 | } 4 | :root:not(.js-enabled) #form { 5 | display: block; 6 | } 7 | #bd > div[style], #bd > div[style] > * { 8 | height: auto !important; 9 | margin: 0 !important; 10 | font-size: 0; 11 | } 12 | -------------------------------------------------------------------------------- /tools/install.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | 3 | var installMap = JSON.parse(fs.readFileSync('install.json', 'utf8')); 4 | for (var src in installMap) { 5 | for (var dest of installMap[src]) { 6 | fs.writeFileSync(dest, fs.readFileSync(src)); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/meta/updates.json: -------------------------------------------------------------------------------- 1 | { 2 | "addons": { 3 | "<%= meta.appidGecko %>": { 4 | "updates": [ 5 | { 6 | "version": "<%= readJSON('/version.json').version %>", 7 | "update_link": "<%= meta.downloads %><%= name %><%= channel %>.crx" 8 | } 9 | ] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/meta/updates.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ' /> 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/site/SW.yotsuba.Build/Post.html: -------------------------------------------------------------------------------- 1 | ?{o.isReply}{
>>
} 2 |
3 | ?{o.isReply}{&{postInfo}&{fileBlock}}{&{fileBlock}&{postInfo}} 4 |
&{commentHTML}
5 |
6 | -------------------------------------------------------------------------------- /src/General/Index/PageList.html: -------------------------------------------------------------------------------- 1 | 6 |
7 | 12 | 15 | -------------------------------------------------------------------------------- /src/Miscellaneous/Report/ArchiveReport.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tools/unpinned.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | 3 | var pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); 4 | 5 | console.log( 6 | Object.keys(pkg.devDependencies).filter( 7 | name => !/^=/.test(pkg.devDependencies[name]) 8 | ).map( 9 | name => `${name}@${process.argv[2] || pkg.devDependencies[name]}` 10 | ).join(' ') 11 | ); 12 | -------------------------------------------------------------------------------- /src/Monitoring/ThreadWatcher/ThreadWatcher.html: -------------------------------------------------------------------------------- 1 |
2 | Thread Watcher 3 | 4 | 5 | × 6 |
7 |
8 | -------------------------------------------------------------------------------- /tools/sign.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | channel=$1 3 | mkdir -p tmp-crx 4 | cp -r "testbuilds/crx$channel" "tmp-crx/crx$channel" 5 | touch -d "$(jq -r '.date' version.json)" "tmp-crx/crx$channel"/* 6 | chromium --pack-extension="tmp-crx/crx$channel" --pack-extension-key="$(dirname "$PWD")/4chan-x.keys/4chan-X.pem" 7 | mv "tmp-crx/crx$channel.crx" "testbuilds/4chan-X$channel.crx" 8 | rm -r 'tmp-crx/' 9 | -------------------------------------------------------------------------------- /src/css/supports.css: -------------------------------------------------------------------------------- 1 | /* XXX Moved to end of stylesheet to avoid breaking whole stylesheet in Maxthon. */ 2 | @supports (text-decoration-style: dashed) or (-moz-text-decoration-style: dashed) { 3 | .quotelink.forwardlink, 4 | .backlink.forwardlink { 5 | text-decoration: underline; 6 | -moz-text-decoration-style: dashed; 7 | text-decoration-style: dashed; 8 | border-bottom: none; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tools/pkgvars.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | 3 | var pkg = JSON.parse(fs.readFileSync('package.json')); 4 | 5 | var vars = {}; 6 | var k; 7 | 8 | vars.name = pkg.name; 9 | for (k in pkg.meta) { 10 | vars[`meta_${k}`] = pkg.meta[k]; 11 | } 12 | for (k in pkg.devDependencies) { 13 | vars[`version_${k}`] = pkg.devDependencies[k]; 14 | } 15 | 16 | for (k in vars) { 17 | console.log(`\$(eval ${k} := ${vars[k]})`); 18 | } 19 | -------------------------------------------------------------------------------- /src/classes/ShimSet.coffee: -------------------------------------------------------------------------------- 1 | class ShimSet 2 | constructor: -> 3 | @elements = $.dict() 4 | @size = 0 5 | has: (value) -> 6 | value of @elements 7 | add: (value) -> 8 | return if @elements[value] 9 | @elements[value] = true 10 | @size++ 11 | delete: (value) -> 12 | return unless @elements[value] 13 | delete @elements[value] 14 | @size-- 15 | 16 | window.Set = ShimSet unless 'Set' of window 17 | -------------------------------------------------------------------------------- /src/Miscellaneous/CustomCSS.coffee: -------------------------------------------------------------------------------- 1 | CustomCSS = 2 | init: -> 3 | return unless Conf['Custom CSS'] 4 | @addStyle() 5 | 6 | addStyle: -> 7 | @style = $.addStyle CSS.sub(Conf['usercss']), 'custom-css', '#fourchanx-css' 8 | 9 | rmStyle: -> 10 | if @style 11 | $.rm @style 12 | delete @style 13 | 14 | update: -> 15 | unless @style 16 | return @addStyle() 17 | @style.textContent = CSS.sub Conf['usercss'] 18 | -------------------------------------------------------------------------------- /src/General/Settings/Keybinds.html: -------------------------------------------------------------------------------- 1 |
Keybinds are disabled.
2 |
Allowed keys: a-z, 0-9, Ctrl, Shift, Alt, Meta, Enter, Esc, Up, Down, Right, Left.
3 |
Press Backspace to disable a keybind.
4 | 5 | 6 |
ActionsKeybinds
7 | -------------------------------------------------------------------------------- /src/Miscellaneous/PSA.coffee: -------------------------------------------------------------------------------- 1 | PSA = 2 | init: -> 3 | return unless g.SITE.software is 'yotsuba' 4 | if g.BOARD.ID is 'qa' 5 | announcement = <%= html('Stay in touch with your /qa/ friends!') %> 6 | return unless announcement 7 | el = $.el 'div', {className: 'fcx-announcement'}, announcement 8 | $.onExists doc, '.boardBanner', (banner) -> 9 | $.after banner, el 10 | -------------------------------------------------------------------------------- /src/Miscellaneous/Flash.coffee: -------------------------------------------------------------------------------- 1 | Flash = 2 | init: -> 3 | if g.BOARD.ID is 'f' and Conf['Enable Native Flash Embedding'] 4 | $.ready Flash.initReady 5 | 6 | initReady: -> 7 | if $.hasStorage 8 | $.global -> (window.SWFEmbed.init() if JSON.parse(localStorage['4chan-settings'] or '{}').disableAll) 9 | else 10 | if g.VIEW is 'thread' 11 | $.global -> window.Main.tid = location.pathname.split(/\/+/)[3] 12 | $.global -> window.SWFEmbed.init() 13 | -------------------------------------------------------------------------------- /src/Miscellaneous/PassMessage/PassMessage.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | Trouble buying a 4chan Pass? (a message from 4chan X) 5 | × 6 |

7 |
8 |
9 | Check the 4chan X wiki for alternative solutions. 10 |
11 |
12 | -------------------------------------------------------------------------------- /src/classes/CatalogThreadNative.coffee: -------------------------------------------------------------------------------- 1 | class CatalogThreadNative 2 | toString: -> @ID 3 | 4 | constructor: (root) -> 5 | @nodes = 6 | root: root 7 | thumb: $(g.SITE.selectors.catalog.thumb, root) 8 | @siteID = g.SITE.ID 9 | @boardID = @nodes.thumb.parentNode.pathname.split(/\/+/)[1] 10 | @board = g.boards[@boardID] or new Board(@boardID) 11 | @ID = @threadID = +(root.dataset.id or root.id).match(/\d*$/)[0] 12 | @thread = @board.threads.get(@ID) or new Thread(@ID, @board) 13 | -------------------------------------------------------------------------------- /src/Miscellaneous/PassMessage.coffee: -------------------------------------------------------------------------------- 1 | PassMessage = 2 | init: -> 3 | return if Conf['passMessageClosed'] 4 | msg = $.el 'div', 5 | className: 'box-outer top-box' 6 | , 7 | `<%= readHTML('PassMessage.html') %>` 8 | msg.style.cssText = 'padding-bottom: 0;' 9 | close = $ 'a', msg 10 | $.on close, 'click', -> 11 | $.rm msg 12 | $.set 'passMessageClosed', true 13 | $.ready -> 14 | if (hd = $.id 'hd') 15 | $.after hd, msg 16 | else 17 | $.prepend d.body, msg 18 | -------------------------------------------------------------------------------- /tools/declare.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | 3 | var names = []; 4 | for (var d of fs.readdirSync('src')) { 5 | for (var f of fs.readdirSync(`src/${d}`)) { 6 | var m = f.match(/^([$A-Z][$\w]*)\.(?:coffee|js)$/); 7 | if (m) names.push(m[1]); 8 | } 9 | } 10 | var decl = `var ${names.sort().join(', ')};\n`; 11 | var oldDecl; 12 | try { 13 | oldDecl = fs.readFileSync('tmp/declaration.js', 'utf8'); 14 | } catch(err) { 15 | } 16 | if (decl !== oldDecl) { 17 | fs.writeFileSync('tmp/declaration.js', decl, 'utf8'); 18 | } 19 | -------------------------------------------------------------------------------- /src/Posting/PostSuccessful.coffee: -------------------------------------------------------------------------------- 1 | PostSuccessful = 2 | init: -> 3 | return unless Conf['Remember Your Posts'] 4 | $.ready @ready 5 | 6 | ready: -> 7 | return unless d.title is 'Post successful!' 8 | 9 | [_, threadID, postID] = $('h1').nextSibling.textContent.match /thread:(\d+),no:(\d+)/ 10 | postID = +postID 11 | threadID = +threadID or postID 12 | 13 | db = new DataBoard 'yourPosts' 14 | db.set 15 | boardID: g.BOARD.ID 16 | threadID: threadID 17 | postID: postID 18 | val: true 19 | -------------------------------------------------------------------------------- /src/classes/CatalogThread.coffee: -------------------------------------------------------------------------------- 1 | class CatalogThread 2 | toString: -> @ID 3 | 4 | constructor: (root, @thread) -> 5 | @ID = @thread.ID 6 | @board = @thread.board 7 | {post} = @thread.OP.nodes 8 | @nodes = 9 | root: root 10 | thumb: $ '.catalog-thumb', post 11 | icons: $ '.catalog-icons', post 12 | postCount: $ '.post-count', post 13 | fileCount: $ '.file-count', post 14 | pageCount: $ '.page-count', post 15 | replies: null 16 | @thread.catalogView = @ 17 | -------------------------------------------------------------------------------- /src/classes/SimpleDict.coffee: -------------------------------------------------------------------------------- 1 | class SimpleDict 2 | constructor: -> 3 | @keys = [] 4 | 5 | push: (key, data) -> 6 | key = "#{key}" 7 | @keys.push key unless @[key] 8 | @[key] = data 9 | 10 | rm: (key) -> 11 | key = "#{key}" 12 | if (i = @keys.indexOf key) isnt -1 13 | @keys.splice i, 1 14 | delete @[key] 15 | 16 | forEach: (fn) -> 17 | fn @[key] for key in [@keys...] 18 | return 19 | 20 | get: (key) -> 21 | if key is 'keys' 22 | undefined 23 | else 24 | $.getOwn(@, key) 25 | -------------------------------------------------------------------------------- /tools/markdown.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var md = require('markdown-it')({linkify: true}).use(require('markdown-it-anchor'), {slugify: s => String(s).trim().toLowerCase().replace(/\W+/g, '-')}); 3 | var template = require('lodash.template'); 4 | 5 | var readme = fs.readFileSync('README.md', 'utf8'); 6 | var content = md.render(readme); 7 | var webtemplate = fs.readFileSync('template.jst', 'utf8'); 8 | var output = template(webtemplate)({content: content}); 9 | output = output.replace(/\r\n/g, '\n'); 10 | fs.writeFileSync('test.html', output); 11 | -------------------------------------------------------------------------------- /src/Posting/PostRedirect.coffee: -------------------------------------------------------------------------------- 1 | PostRedirect = 2 | init: -> 3 | $.on d, 'QRPostSuccessful', (e) => 4 | return unless e.detail.redirect 5 | @event = e 6 | @delays = 0 7 | $.queueTask => 8 | if e is @event and @delays is 0 9 | location.href = e.detail.redirect 10 | 11 | delays: 0 12 | 13 | delay: -> 14 | return null unless @event 15 | e = @event 16 | @delays++ 17 | () => 18 | return unless e is @event 19 | @delays-- 20 | if @delays is 0 21 | location.href = e.detail.redirect 22 | -------------------------------------------------------------------------------- /src/Miscellaneous/ThreadLinks.coffee: -------------------------------------------------------------------------------- 1 | ThreadLinks = 2 | init: -> 3 | return unless g.VIEW is 'index' and Conf['Open Threads in New Tab'] 4 | 5 | Callbacks.Post.push 6 | name: 'Thread Links' 7 | cb: @node 8 | Callbacks.CatalogThread.push 9 | name: 'Thread Links' 10 | cb: @catalogNode 11 | 12 | node: -> 13 | return if @isReply or @isClone 14 | ThreadLinks.process @nodes.reply 15 | 16 | catalogNode: -> 17 | ThreadLinks.process @nodes.thumb.parentNode 18 | 19 | process: (link) -> 20 | link.target = '_blank' 21 | -------------------------------------------------------------------------------- /src/css/report.css: -------------------------------------------------------------------------------- 1 | #g-recaptcha, 2 | :root:not(.js-enabled) #captchaContainerAlt { 3 | height: auto; 4 | } 5 | #captchaContainerAlt td:nth-child(2) { 6 | display: table-cell !important; 7 | } 8 | 9 | /* Archive reports */ 10 | #archive-report { 11 | padding: 3px; 12 | } 13 | #archive-report-enabled { 14 | vertical-align: middle; 15 | } 16 | #archive-report > label { 17 | display: block; 18 | } 19 | #archive-report-reason { 20 | display: block; 21 | width: 98%; 22 | } 23 | .archive-report-success { 24 | color: green; 25 | } 26 | .archive-report-error { 27 | color: red; 28 | } -------------------------------------------------------------------------------- /src/Miscellaneous/NormalizeURL.coffee: -------------------------------------------------------------------------------- 1 | NormalizeURL = 2 | init: -> 3 | return unless Conf['Normalize URL'] 4 | 5 | pathname = location.pathname.split /\/+/ 6 | if g.SITE.software is 'yotsuba' 7 | switch g.VIEW 8 | when 'thread' 9 | pathname[2] = 'thread' 10 | pathname = pathname[0...4] 11 | when 'index' 12 | pathname = pathname[0...3] 13 | pathname = pathname.join '/' 14 | if location.pathname isnt pathname 15 | history.replaceState history.state, '', "#{location.protocol}//#{location.host}#{pathname}#{location.hash}" 16 | -------------------------------------------------------------------------------- /src/Quotelinks/QuoteStrikeThrough.coffee: -------------------------------------------------------------------------------- 1 | QuoteStrikeThrough = 2 | init: -> 3 | return unless g.VIEW in ['index', 'thread'] and 4 | (Conf['Reply Hiding Buttons'] or (Conf['Menu'] and Conf['Reply Hiding Link']) or Conf['Filter']) 5 | 6 | Callbacks.Post.push 7 | name: 'Strike-through Quotes' 8 | cb: @node 9 | 10 | node: -> 11 | return if @isClone 12 | for quotelink in @nodes.quotelinks 13 | {boardID, postID} = Get.postDataFromLink quotelink 14 | if g.posts.get("#{boardID}.#{postID}")?.isHidden 15 | $.addClass quotelink, 'filtered' 16 | return 17 | -------------------------------------------------------------------------------- /src/Menu/DownloadLink.coffee: -------------------------------------------------------------------------------- 1 | DownloadLink = 2 | init: -> 3 | return unless g.VIEW in ['index', 'thread'] and Conf['Menu'] and Conf['Download Link'] 4 | 5 | a = $.el 'a', 6 | className: 'download-link' 7 | textContent: 'Download file' 8 | 9 | # Specifying the filename with the download attribute only works for same-origin links. 10 | $.on a, 'click', ImageCommon.download 11 | 12 | Menu.menu.addEntry 13 | el: a 14 | order: 100 15 | open: ({file}) -> 16 | return false unless file 17 | a.href = file.url 18 | a.download = file.name 19 | true 20 | -------------------------------------------------------------------------------- /src/config/user.css: -------------------------------------------------------------------------------- 1 | /* Board title rice */ 2 | div.boardTitle { 3 | font-weight: 400 !important; 4 | } 5 | :root.yotsuba div.boardTitle { 6 | font-family: sans-serif !important; 7 | text-shadow: 1px 1px 1px rgba(100,0,0,0.6); 8 | } 9 | :root.yotsuba-b div.boardTitle { 10 | font-family: sans-serif !important; 11 | text-shadow: 1px 1px 1px rgba(105,10,15,0.6); 12 | } 13 | :root.photon div.boardTitle { 14 | font-family: sans-serif !important; 15 | text-shadow: 1px 1px 1px rgba(0,74,153,0.6); 16 | } 17 | :root.tomorrow div.boardTitle { 18 | font-family: sans-serif !important; 19 | text-shadow: 1px 1px 1px rgba(167,170,168,0.6); 20 | } 21 | -------------------------------------------------------------------------------- /src/Posting/PassLink.coffee: -------------------------------------------------------------------------------- 1 | PassLink = 2 | init: -> 3 | return unless g.SITE.software is 'yotsuba' and Conf['Pass Link'] 4 | Main.ready @ready 5 | 6 | ready: -> 7 | return if not (styleSelector = $.id 'styleSelector') 8 | 9 | passLink = $.el 'span', 10 | className: 'brackets-wrap pass-link-container' 11 | $.extend passLink, `<%= html('4chan Pass') %>` 12 | $.on passLink.firstElementChild, 'click', -> 13 | window.open "//sys.#{location.hostname.split('.')[1]}.org/auth", 14 | Date.now() 15 | 'width=500,height=280,toolbar=0' 16 | $.before styleSelector.previousSibling, [passLink, $.tn('\u00A0\u00A0')] 17 | -------------------------------------------------------------------------------- /src/General/Polyfill.coffee: -------------------------------------------------------------------------------- 1 | Polyfill = 2 | init: -> 3 | @toBlob() 4 | $.global @toBlob 5 | Element::matches or= Element::mozMatchesSelector or Element::webkitMatchesSelector 6 | return 7 | toBlob: -> 8 | return if HTMLCanvasElement::toBlob 9 | HTMLCanvasElement::toBlob = (cb, type, encoderOptions) -> 10 | url = @toDataURL type, encoderOptions 11 | data = atob url[url.indexOf(',')+1..] 12 | # DataUrl to Binary code from Aeosynth's 4chan X repo 13 | l = data.length 14 | ui8a = new Uint8Array l 15 | for i in [0...l] by 1 16 | ui8a[i] = data.charCodeAt i 17 | cb new Blob [ui8a], {type: type or 'image/png'} 18 | return 19 | -------------------------------------------------------------------------------- /src/Miscellaneous/IDPostCount.coffee: -------------------------------------------------------------------------------- 1 | IDPostCount = 2 | init: -> 3 | return unless g.VIEW is 'thread' and Conf['Count Posts by ID'] 4 | Callbacks.Thread.push 5 | name: 'Count Posts by ID' 6 | cb: -> IDPostCount.thread = @ 7 | Callbacks.Post.push 8 | name: 'Count Posts by ID' 9 | cb: @node 10 | 11 | node: -> 12 | if @nodes.uniqueID and @thread is IDPostCount.thread 13 | $.on @nodes.uniqueID, 'mouseover', IDPostCount.count 14 | 15 | count: -> 16 | {uniqueID} = Get.postFromNode(@).info 17 | n = 0 18 | IDPostCount.thread.posts.forEach (post) -> 19 | (n++ if post.info.uniqueID is uniqueID) 20 | @title = "#{n} post#{if n is 1 then '' else 's'} by this ID" 21 | -------------------------------------------------------------------------------- /src/Miscellaneous/RemoveSpoilers.coffee: -------------------------------------------------------------------------------- 1 | RemoveSpoilers = 2 | init: -> 3 | if Conf['Reveal Spoilers'] 4 | $.addClass doc, 'reveal-spoilers' 5 | 6 | return unless Conf['Remove Spoilers'] 7 | 8 | Callbacks.Post.push 9 | name: 'Reveal Spoilers' 10 | cb: @node 11 | 12 | if g.VIEW is 'archive' 13 | $.ready -> RemoveSpoilers.unspoiler $.id 'arc-list' 14 | 15 | node: -> 16 | RemoveSpoilers.unspoiler @nodes.comment 17 | 18 | unspoiler: (el) -> 19 | spoilers = $$ g.SITE.selectors.spoiler, el 20 | for spoiler in spoilers 21 | span = $.el 'span', className: 'removed-spoiler' 22 | $.replace spoiler, span 23 | $.add span, [spoiler.childNodes...] 24 | return 25 | -------------------------------------------------------------------------------- /tools/banners.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import urllib.request, urllib.error, json 3 | banners = [] 4 | for ext in ['jpg', 'png', 'gif']: 5 | for i in range(300): 6 | banner = str(i) + '.' + ext 7 | req = urllib.request.Request('http://s.4cdn.org/image/title/' + banner, method='HEAD') 8 | try: 9 | try: 10 | status = urllib.request.urlopen(req).status 11 | except urllib.error.URLError: 12 | status = urllib.request.urlopen(req).status 13 | except urllib.error.HTTPError as e: 14 | status = e.status 15 | print(banner, status) 16 | if status == 200: 17 | banners.append(banner) 18 | with open('src/config/banners.json', 'w') as f: 19 | f.write(json.dumps(banners)) 20 | -------------------------------------------------------------------------------- /src/classes/Board.coffee: -------------------------------------------------------------------------------- 1 | class Board 2 | toString: -> @ID 3 | 4 | constructor: (@ID) -> 5 | @boardID = @ID 6 | @siteID = g.SITE.ID 7 | @threads = new SimpleDict() 8 | @posts = new SimpleDict() 9 | @config = BoardConfig.boards?[@ID] or {} 10 | 11 | g.boards[@] = @ 12 | 13 | cooldowns: -> 14 | c2 = (@config or {}).cooldowns or {} 15 | c = 16 | thread: c2.threads or 0 17 | reply: c2.replies or 0 18 | image: c2.images or 0 19 | thread_global: 300 # inter-board thread cooldown 20 | # Pass users have reduced cooldowns. 21 | if d.cookie.indexOf('pass_enabled=1') >= 0 22 | for key in ['reply', 'image'] 23 | c[key] = Math.ceil(c[key] / 2) 24 | c 25 | -------------------------------------------------------------------------------- /src/Menu/CopyTextLink.coffee: -------------------------------------------------------------------------------- 1 | CopyTextLink = 2 | init: -> 3 | return unless g.VIEW in ['index', 'thread'] and Conf['Menu'] and Conf['Copy Text Link'] 4 | 5 | a = $.el 'a', 6 | className: 'copy-text-link' 7 | href: 'javascript:;' 8 | textContent: 'Copy Text' 9 | $.on a, 'click', CopyTextLink.copy 10 | 11 | Menu.menu.addEntry 12 | el: a 13 | order: 12 14 | open: (post) -> 15 | CopyTextLink.text = (post.origin or post).commentOrig() 16 | true 17 | 18 | copy: -> 19 | el = $.el 'textarea', 20 | className: 'copy-text-element', 21 | value: CopyTextLink.text 22 | $.add d.body, el 23 | el.select() 24 | try 25 | d.execCommand 'copy' 26 | $.rm el 27 | -------------------------------------------------------------------------------- /src/Images/Gallery/Gallery.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | × 7 | 8 | 9 | 10 | / 11 | 12 |
13 |
14 | 15 |
16 |
17 |
18 |
19 | -------------------------------------------------------------------------------- /src/classes/Connection.coffee: -------------------------------------------------------------------------------- 1 | class Connection 2 | constructor: (@target, @origin, @cb={}) -> 3 | $.on window, 'message', @onMessage 4 | 5 | targetWindow: -> 6 | if @target instanceof window.HTMLIFrameElement 7 | @target.contentWindow 8 | else 9 | @target 10 | 11 | send: (data) => 12 | @targetWindow().postMessage "#{g.NAMESPACE}#{JSON.stringify data}", @origin 13 | 14 | onMessage: (e) => 15 | return unless e.source is @targetWindow() and 16 | e.origin is @origin and 17 | typeof e.data is 'string' and 18 | e.data[...g.NAMESPACE.length] is g.NAMESPACE 19 | data = JSON.parse e.data[g.NAMESPACE.length..] 20 | for type, value of data when $.hasOwn(@cb, type) 21 | @cb[type] value 22 | return 23 | -------------------------------------------------------------------------------- /src/Images/RevealSpoilers.coffee: -------------------------------------------------------------------------------- 1 | RevealSpoilers = 2 | init: -> 3 | return unless g.VIEW in ['index', 'thread', 'archive'] and Conf['Reveal Spoiler Thumbnails'] 4 | 5 | Callbacks.Post.push 6 | name: 'Reveal Spoiler Thumbnails' 7 | cb: @node 8 | 9 | node: -> 10 | return if @isClone 11 | for file in @files when file.thumb and file.isSpoiler 12 | {thumb} = file 13 | # Remove old width and height. 14 | thumb.removeAttribute 'style' 15 | # Enforce thumbnail size if thumbnail is replaced. 16 | thumb.style.maxHeight = thumb.style.maxWidth = if @isReply then '125px' else '250px' 17 | if thumb.src 18 | thumb.src = file.thumbURL 19 | else 20 | thumb.dataset.src = file.thumbURL 21 | return 22 | -------------------------------------------------------------------------------- /tools/zip-crx.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var JSZip = require('jszip'); 3 | 4 | var pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); 5 | var v = JSON.parse(fs.readFileSync('version.json', 'utf8')); 6 | var channel = process.argv[2] || ''; 7 | 8 | var zip = new JSZip(); 9 | for (var file of ['script.js', 'eventPage.js', 'icon16.png', 'icon48.png', 'icon128.png', 'manifest.json']) { 10 | zip.file( 11 | file, 12 | fs.readFileSync(`testbuilds/crx${channel}/${file}`), 13 | {date: new Date(v.date)} 14 | ); 15 | } 16 | zip.generateAsync({ 17 | type: 'nodebuffer', 18 | compression: 'DEFLATE', 19 | compressionOptions: {level: 9}, 20 | }).then(function(output) { 21 | fs.writeFileSync(`testbuilds/${pkg.name}${channel}.crx.zip`, output); 22 | }, function() { 23 | process.exit(1); 24 | }); 25 | -------------------------------------------------------------------------------- /tools/bump.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | 3 | function bump(version, level) { 4 | var parts = version.split('.'); 5 | var i; 6 | for (i = 0; i < level; i++) { 7 | parts[i] = (parts[i] || '0'); 8 | } 9 | parts[level-1] = +parts[level-1] + 1; 10 | for (i = level; i < parts.length; i++) { 11 | parts[i] = '0'; 12 | } 13 | return parts.join('.'); 14 | } 15 | 16 | function setversion(version) { 17 | var data = {version: version, date: new Date()}; 18 | fs.writeFileSync('version.json', JSON.stringify(data, null, 2)); 19 | } 20 | 21 | var level = +process.argv[2]; 22 | var v = JSON.parse(fs.readFileSync('version.json', 'utf8')); 23 | var oldversion = v.version; 24 | var version = bump(oldversion, level); 25 | setversion(version); 26 | console.log(`Version updated from v${oldversion} to v${version}.`); 27 | -------------------------------------------------------------------------------- /src/General/Settings/Settings.html: -------------------------------------------------------------------------------- 1 |
2 | 16 |
17 |
18 | -------------------------------------------------------------------------------- /src/site/SW.yotsuba.Build/CatalogThread.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | ${postCount} / 7 | ${fileCount} / 8 | ${pageCount} 9 | 10 | 11 | ?{thread.isSticky}{} 12 | ?{thread.isClosed}{} 13 | 14 |
15 | -------------------------------------------------------------------------------- /src/General/Settings/Filter-select.html: -------------------------------------------------------------------------------- 1 | 19 |
20 | -------------------------------------------------------------------------------- /src/Miscellaneous/IDHighlight.coffee: -------------------------------------------------------------------------------- 1 | IDHighlight = 2 | init: -> 3 | return unless g.VIEW in ['index', 'thread'] 4 | 5 | Callbacks.Post.push 6 | name: 'Highlight by User ID' 7 | cb: @node 8 | 9 | uniqueID: null 10 | 11 | node: -> 12 | $.on @nodes.uniqueIDRoot, 'click', IDHighlight.click @ if @nodes.uniqueIDRoot 13 | $.on @nodes.capcode, 'click', IDHighlight.click @ if @nodes.capcode 14 | IDHighlight.set @ unless @isClone 15 | 16 | set: (post) -> 17 | match = (post.info.uniqueID or post.info.capcode) is IDHighlight.uniqueID 18 | $[if match then 'addClass' else 'rmClass'] post.nodes.post, 'highlight' 19 | 20 | click: (post) -> -> 21 | uniqueID = post.info.uniqueID or post.info.capcode 22 | IDHighlight.uniqueID = if IDHighlight.uniqueID is uniqueID then null else uniqueID 23 | g.posts.forEach IDHighlight.set 24 | -------------------------------------------------------------------------------- /src/classes/Callbacks.coffee: -------------------------------------------------------------------------------- 1 | class Callbacks 2 | @Post = new Callbacks 'Post' 3 | @Thread = new Callbacks 'Thread' 4 | @CatalogThread = new Callbacks 'Catalog Thread' 5 | @CatalogThreadNative = new Callbacks 'Catalog Thread' 6 | 7 | constructor: (@type) -> 8 | @keys = [] 9 | 10 | push: ({name, cb}) -> 11 | @keys.push name unless @[name] 12 | @[name] = cb 13 | 14 | execute: (node, keys=@keys, force=false) -> 15 | return if node.callbacksExecuted and !force 16 | node.callbacksExecuted = true 17 | for name in keys 18 | try 19 | @[name]?.call node 20 | catch err 21 | errors = [] unless errors 22 | errors.push 23 | message: ['"', name, '" crashed on node ', @type, ' No.', node.ID, ' (', node.board, ').'].join('') 24 | error: err 25 | html: node.nodes?.root?.outerHTML 26 | 27 | Main.handleErrors errors if errors 28 | -------------------------------------------------------------------------------- /src/Menu/ReportLink.coffee: -------------------------------------------------------------------------------- 1 | ReportLink = 2 | init: -> 3 | return unless g.VIEW in ['index', 'thread'] and Conf['Menu'] and Conf['Report Link'] 4 | 5 | a = $.el 'a', 6 | className: 'report-link' 7 | href: 'javascript:;' 8 | textContent: 'Report' 9 | $.on a, 'click', ReportLink.report 10 | 11 | Menu.menu.addEntry 12 | el: a 13 | order: 10 14 | open: (post) -> 15 | ReportLink.url = "//sys.#{location.hostname.split('.')[1]}.org/#{post.board}/imgboard.php?mode=report&no=#{post}" 16 | if d.cookie.indexOf('pass_enabled=1') >= 0 17 | ReportLink.dims = 'width=350,height=275' 18 | else 19 | ReportLink.dims = 'width=400,height=550' 20 | true 21 | 22 | report: -> 23 | {url, dims} = ReportLink 24 | id = Date.now() 25 | set = "toolbar=0,scrollbars=1,location=0,status=1,menubar=0,resizable=1,#{dims}" 26 | window.open url, id, set 27 | -------------------------------------------------------------------------------- /src/Quotelinks/QuoteCT.coffee: -------------------------------------------------------------------------------- 1 | QuoteCT = 2 | init: -> 3 | return if g.VIEW not in ['index', 'thread'] or !Conf['Mark Cross-thread Quotes'] 4 | 5 | if Conf['Comment Expansion'] 6 | ExpandComment.callbacks.push @node 7 | 8 | # \u00A0 is nbsp 9 | @mark = $.el 'span', 10 | textContent: '\u00A0(Cross-thread)' 11 | className: 'qmark-ct' 12 | Callbacks.Post.push 13 | name: 'Mark Cross-thread Quotes' 14 | cb: @node 15 | node: -> 16 | # Stop there if it's a clone of a post in the same thread. 17 | return if @isClone and @thread is @context.thread 18 | 19 | {board, thread} = @context 20 | for quotelink in @nodes.quotelinks 21 | {boardID, threadID} = Get.postDataFromLink quotelink 22 | continue unless threadID # deadlink 23 | if @isClone 24 | $.rm $('.qmark-ct', quotelink) 25 | if boardID is board.ID and threadID isnt thread.ID 26 | $.add quotelink, QuoteCT.mark.cloneNode(true) 27 | return 28 | -------------------------------------------------------------------------------- /tools/chain.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var template = require('./template'); 3 | var coffee = require('coffeescript'); 4 | 5 | for (var name of process.argv.slice(2)) { 6 | try { 7 | var parts = name.match(/^tmp\/([^_]*)(?:_(.*))?-(.*)\.(.*)\.js$/); 8 | var sourceName = `src/${parts[1]}/${parts[3]}.${parts[4]}`; 9 | var script = fs.readFileSync(sourceName, 'utf8'); 10 | script = script.replace(/\r\n/g, '\n'); 11 | script = template(script, {type: parts[2]}, sourceName); 12 | if (parts[4] === 'coffee') { 13 | var definesVar = /^[$A-Z][$\w]*$/.test(parts[3]); 14 | if (definesVar) { 15 | script = `${script}\nreturn ${parts[3]};\n`; 16 | } 17 | script = coffee.compile(script); 18 | if (definesVar) { 19 | script = `${parts[3]} = ${script}`; 20 | } 21 | } 22 | script += '\n'; 23 | fs.writeFileSync(name, script); 24 | } catch (err) { 25 | console.error(`Error processing ${name}`); 26 | throw err; 27 | } 28 | } 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/classes/Notice.coffee: -------------------------------------------------------------------------------- 1 | class Notice 2 | constructor: (type, content, @timeout, @onclose) -> 3 | @el = $.el 'div', 4 | `<%= html('
') %>` 5 | @el.style.opacity = 0 6 | @setType type 7 | $.on @el.firstElementChild, 'click', @close 8 | if typeof content is 'string' 9 | content = $.tn content 10 | $.add @el.lastElementChild, content 11 | 12 | $.ready @add 13 | 14 | setType: (type) -> 15 | @el.className = "notification #{type}" 16 | 17 | add: => 18 | return if @closed 19 | if d.hidden 20 | $.on d, 'visibilitychange', @add 21 | return 22 | $.off d, 'visibilitychange', @add 23 | $.add Header.noticesRoot, @el 24 | @el.clientHeight # force reflow 25 | @el.style.opacity = 1 26 | setTimeout @close, @timeout * $.SECOND if @timeout 27 | 28 | close: => 29 | @closed = true 30 | $.off d, 'visibilitychange', @add 31 | $.rm @el 32 | @onclose?() 33 | -------------------------------------------------------------------------------- /src/Menu/Menu.coffee: -------------------------------------------------------------------------------- 1 | Menu = 2 | init: -> 3 | return unless g.VIEW in ['index', 'thread'] and Conf['Menu'] 4 | 5 | @button = $.el 'a', 6 | className: 'menu-button' 7 | href: 'javascript:;' 8 | 9 | $.extend @button, `<%= html('') %>` 10 | 11 | @menu = new UI.Menu 'post' 12 | Callbacks.Post.push 13 | name: 'Menu' 14 | cb: @node 15 | 16 | Callbacks.CatalogThread.push 17 | name: 'Menu' 18 | cb: @catalogNode 19 | 20 | node: -> 21 | if @isClone 22 | button = $ '.menu-button', @nodes.info 23 | $.rmClass button, 'active' 24 | $.rm $('.dialog', button) 25 | Menu.makeButton @, button 26 | return 27 | $.add @nodes.info, Menu.makeButton @ 28 | 29 | catalogNode: -> 30 | $.after @nodes.icons, Menu.makeButton @thread.OP 31 | 32 | makeButton: (post, button) -> 33 | button or= Menu.button.cloneNode true 34 | $.on button, 'click', (e) -> 35 | Menu.menu.toggle e, @, post 36 | button 37 | -------------------------------------------------------------------------------- /src/meta/jshint.json: -------------------------------------------------------------------------------- 1 | { 2 | "undef": true, 3 | "unused": true, 4 | "eqnull": true, 5 | "expr": true, 6 | "sub": true, 7 | "scripturl": true, 8 | "multistr": true, 9 | "browser": true, 10 | "devel": true, 11 | "nonstandard": true, 12 | "-W018": true, 13 | "-W084": true, 14 | "-W083": true, 15 | "-W093": true, 16 | "globals": { 17 | "MediaError": false, 18 | "Set": false, 19 | "Promise": false, 20 | "BroadcastChannel": false, 21 | "GM_info": false, 22 | "cloneInto": false, 23 | "XPCNativeWrapper": false, 24 | "unsafeWindow": false, 25 | "chrome": false, 26 | "GM": false<%= 27 | meta.grants.filter(x => !/\./.test(x)).map(x => `,\n "${x}": false`).join('') 28 | %><%= 29 | read('/tmp/declaration.js').match(/^var (.*);/)[1].split(', ').map(x => `,\n "${x}": true`).join('') 30 | %><%= 31 | read('/src/globals/globals.js').match(/^var (.*);/)[1].split(', ').map(x => `,\n "${x}": true`).join('') 32 | %> 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/css/style.inc: -------------------------------------------------------------------------------- 1 | /* jshint esnext: true */ 2 | 3 | // == Reprocess Font Awesome CSS == // 4 | var fa = (css, font) => ( 5 | 6 | // Font Awesome CSS attribution and license 7 | css.match(/\/\*\![^]*?\*\//)[0] + '\n' + 8 | 9 | // Font Awesome web font 10 | `@font-face { 11 | font-family: FontAwesome; 12 | src: url('data:application/font-woff;base64,${font}') format('woff'); 13 | font-weight: 400; 14 | font-style: normal; 15 | } 16 | ` + 17 | 18 | // fa-[icon name] classes 19 | css 20 | .match(/(\.fa-[^{]*{\s*content:[^}]*}\s*)+/)[0] 21 | .replace(/([,{;])\s+/g, '$1') 22 | .replace(/,/g, ', ') 23 | 24 | ); 25 | 26 | // == Create CSS for Link Title Favicons == // 27 | var icons = (names, data) => ( 28 | 29 | '/* Link Title Favicons */\n' + 30 | names.map((file, i) => 31 | `.linkify.${file.split('.')[1]}::before { 32 | content: ""; 33 | background: transparent url('data:image/png;base64,${data[i]}') center left no-repeat!important; 34 | padding-left: 18px; 35 | } 36 | ` 37 | ).join('') 38 | 39 | ); 40 | 41 | return {fa, icons}; 42 | -------------------------------------------------------------------------------- /src/Filtering/Recursive.coffee: -------------------------------------------------------------------------------- 1 | Recursive = 2 | recursives: $.dict() 3 | init: -> 4 | return unless g.VIEW in ['index', 'thread'] 5 | Callbacks.Post.push 6 | name: 'Recursive' 7 | cb: @node 8 | 9 | node: -> 10 | return if @isClone or @isFetchedQuote 11 | for quote in @quotes when obj = Recursive.recursives[quote] 12 | for recursive, i in obj.recursives 13 | recursive @, obj.args[i]... 14 | return 15 | 16 | add: (recursive, post, args...) -> 17 | obj = Recursive.recursives[post.fullID] or= { 18 | recursives: [] 19 | args: [] 20 | } 21 | obj.recursives.push recursive 22 | obj.args.push args 23 | 24 | rm: (recursive, post) -> 25 | return if not (obj = Recursive.recursives[post.fullID]) 26 | for rec, i in obj.recursives when rec is recursive 27 | obj.recursives.splice i, 1 28 | obj.args.splice i, 1 29 | return 30 | 31 | apply: (recursive, post, args...) -> 32 | {fullID} = post 33 | g.posts.forEach (post) -> 34 | if fullID in post.quotes 35 | recursive post, args... 36 | -------------------------------------------------------------------------------- /src/Miscellaneous/Tinyboard.coffee: -------------------------------------------------------------------------------- 1 | Tinyboard = 2 | init: -> 3 | return unless g.SITE.software is 'tinyboard' 4 | if g.VIEW is 'thread' 5 | Main.ready -> 6 | $.global -> 7 | {boardID, threadID} = document.currentScript.dataset 8 | threadID = +threadID 9 | form = document.querySelector 'form[name="post"]' 10 | window.$(document).ajaxComplete (event, request, settings) -> 11 | return unless settings.url is form.action 12 | return unless (postID = +request.responseJSON?.id) 13 | detail = {boardID, threadID, postID} 14 | try 15 | {redirect, noko} = request.responseJSON 16 | if redirect and originalNoko? and !originalNoko and !noko 17 | detail.redirect = redirect 18 | event = new CustomEvent 'QRPostSuccessful', {bubbles: true, detail: detail} 19 | document.dispatchEvent event 20 | originalNoko = window.tb_settings?.ajax?.always_noko_replies 21 | ((window.tb_settings or= {}).ajax or= {}).always_noko_replies = true 22 | , {boardID: g.BOARD.ID, threadID: g.THREADID} 23 | -------------------------------------------------------------------------------- /src/globals/globals.js: -------------------------------------------------------------------------------- 1 | var Conf, E, c, d, doc, docSet, g; 2 | 3 | Conf = Object.create(null); 4 | c = console; 5 | d = document; 6 | doc = d.documentElement; 7 | 8 | // Workaround for userscript managers that run script before document.documentElement is set 9 | docSet = function() { 10 | return (doc = d.documentElement); 11 | }; 12 | 13 | g = { 14 | VERSION: '<%= readJSON('/version.json').version %>', 15 | NAMESPACE: '<%= meta.name %>.', 16 | sites: Object.create(null), 17 | boards: Object.create(null) 18 | }; 19 | 20 | E = (function() { 21 | var fn, r, regex, str; 22 | str = { 23 | '&': '&', 24 | "'": ''', 25 | '"': '"', 26 | '<': '<', 27 | '>': '>' 28 | }; 29 | r = String.prototype.replace; 30 | regex = /[&"'<>]/g; 31 | fn = function(x) { 32 | return str[x]; 33 | }; 34 | return function(text) { 35 | return r.call(text, regex, fn); 36 | }; 37 | })(); 38 | 39 | E.cat = function(templates) { 40 | var html, i, len; 41 | html = ''; 42 | for (i = 0, len = templates.length; i < len; i++) { 43 | html += templates[i].innerHTML; 44 | } 45 | return html; 46 | }; 47 | -------------------------------------------------------------------------------- /src/Images/ImageHost.coffee: -------------------------------------------------------------------------------- 1 | ImageHost = 2 | init: -> 3 | return unless (@useFaster = /\S/.test(Conf['fourchanImageHost'])) and g.SITE.software is 'yotsuba' and g.VIEW in ['index', 'thread'] 4 | Callbacks.Post.push 5 | name: 'Image Host Rewriting' 6 | cb: @node 7 | 8 | suggestions: ['i.4cdn.org', 'is2.4chan.org'] 9 | 10 | host: -> 11 | Conf['fourchanImageHost'].trim() or 'i.4cdn.org' 12 | flashHost: -> 13 | 'i.4cdn.org' 14 | thumbHost: -> 15 | 'i.4cdn.org' 16 | test: (hostname) -> 17 | hostname is 'i.4cdn.org' or ImageHost.regex.test(hostname) 18 | 19 | regex: /^is\d*\.4chan(?:nel)?\.org$/ 20 | 21 | node: -> 22 | return if @isClone 23 | host = ImageHost.host() 24 | if @file and ImageHost.test(@file.url.split('/')[2]) and not /\.swf$/.test(@file.url) 25 | @file.link.hostname = host 26 | @file.thumbLink.hostname = host if @file.thumbLink 27 | @file.url = @file.link.href 28 | ImageHost.fixLinks $$('a', @nodes.comment) 29 | 30 | fixLinks: (links) -> 31 | for link in links when ImageHost.test(link.hostname) and not /\.swf$/.test(link.pathname) 32 | host = ImageHost.host() 33 | link.hostname = host unless link.hostname is host 34 | return 35 | -------------------------------------------------------------------------------- /src/Miscellaneous/AntiAutoplay.coffee: -------------------------------------------------------------------------------- 1 | AntiAutoplay = 2 | init: -> 3 | return if !Conf['Disable Autoplaying Sounds'] 4 | $.addClass doc, 'anti-autoplay' 5 | @stop audio for audio in $$ 'audio[autoplay]', doc 6 | window.addEventListener 'loadstart', ((e) => @stop e.target), true 7 | Callbacks.Post.push 8 | name: 'Disable Autoplaying Sounds' 9 | cb: @node 10 | $.ready => @process d.body 11 | 12 | stop: (audio) -> 13 | return unless audio.autoplay 14 | audio.pause() 15 | audio.autoplay = false 16 | return if audio.controls 17 | audio.controls = true 18 | $.addClass audio, 'controls-added' 19 | 20 | node: -> 21 | AntiAutoplay.process @nodes.comment 22 | 23 | process: (root) -> 24 | for iframe in $$ 'iframe[src*="youtube"][src*="autoplay=1"]', root 25 | AntiAutoplay.processVideo iframe, 'src' 26 | for object in $$ 'object[data*="youtube"][data*="autoplay=1"]', root 27 | AntiAutoplay.processVideo object, 'data' 28 | return 29 | 30 | processVideo: (el, attr) -> 31 | el[attr] = el[attr].replace(/\?autoplay=1&?/, '?').replace('&autoplay=1', '') 32 | el.style.display = 'block' if window.getComputedStyle(el).display is 'none' 33 | $.addClass el, 'autoplay-removed' 34 | -------------------------------------------------------------------------------- /src/css/CSS.js: -------------------------------------------------------------------------------- 1 | <% 2 | var inc = require['style']; 3 | var faCSS = read('/node_modules/font-awesome/css/font-awesome.css'); 4 | var faWebFont = readBase64('/node_modules/font-awesome/fonts/fontawesome-webfont.woff'); 5 | var mainCSS = ['font-awesome', 'style', 'yotsuba', 'yotsuba-b', 'futaba', 'burichan', 'tomorrow', 'photon', 'spooky'].map(x => read(`${x}.css`)).join(''); 6 | var iconNames = files.filter(f => /^linkify\.[^.]+\.png$/.test(f)); 7 | var icons = iconNames.map(readBase64); 8 | %>CSS = { 9 | 10 | boards: 11 | <%= multiline( 12 | inc.fa(faCSS, faWebFont) + mainCSS + inc.icons(iconNames, icons) + read('supports.css') 13 | ) %>, 14 | 15 | report: 16 | <%= multiline(read('report.css')) %>, 17 | 18 | www: 19 | <%= multiline(read('www.css')) %>, 20 | 21 | sub: function(css) { 22 | var variables = { 23 | site: g.SITE.selectors 24 | }; 25 | return css.replace(/\$[\w\$]+/g, function(name) { 26 | var words = name.slice(1).split('$'); 27 | var sel = variables; 28 | for (var i = 0; i < words.length; i++) { 29 | if (typeof sel !== 'object') return ':not(*)'; 30 | sel = $.getOwn(sel, words[i]); 31 | } 32 | if (typeof sel !== 'string') return ':not(*)'; 33 | return sel; 34 | }); 35 | } 36 | 37 | }; 38 | -------------------------------------------------------------------------------- /src/Quotelinks/QuoteOP.coffee: -------------------------------------------------------------------------------- 1 | QuoteOP = 2 | init: -> 3 | return if g.VIEW not in ['index', 'thread'] or !Conf['Mark OP Quotes'] 4 | 5 | if Conf['Comment Expansion'] 6 | ExpandComment.callbacks.push @node 7 | 8 | # \u00A0 is nbsp 9 | @mark = $.el 'span', 10 | textContent: '\u00A0(OP)' 11 | className: 'qmark-op' 12 | Callbacks.Post.push 13 | name: 'Mark OP Quotes' 14 | cb: @node 15 | 16 | node: -> 17 | # Stop there if it's a clone of a post in the same thread. 18 | return if @isClone and @thread is @context.thread 19 | # Stop there if there's no quotes in that post. 20 | return unless (quotes = @quotes).length 21 | {quotelinks} = @nodes 22 | 23 | # rm (OP) from cross-thread quotes. 24 | if @isClone and @thread.fullID in quotes 25 | i = 0 26 | while quotelink = quotelinks[i++] 27 | $.rm $('.qmark-op', quotelink) 28 | 29 | {fullID} = @context.thread 30 | # add (OP) to quotes quoting this context's OP. 31 | 32 | return unless fullID in quotes 33 | i = 0 34 | while quotelink = quotelinks[i++] 35 | {boardID, postID} = Get.postDataFromLink quotelink 36 | if "#{boardID}.#{postID}" is fullID 37 | $.add quotelink, QuoteOP.mark.cloneNode(true) 38 | return 39 | -------------------------------------------------------------------------------- /src/Miscellaneous/IDColor.coffee: -------------------------------------------------------------------------------- 1 | IDColor = 2 | init: -> 3 | return unless g.VIEW in ['index', 'thread'] and Conf['Color User IDs'] 4 | @ids = $.dict() 5 | @ids['Heaven'] = [0, 0, 0, '#fff'] 6 | 7 | Callbacks.Post.push 8 | name: 'Color User IDs' 9 | cb: @node 10 | 11 | node: -> 12 | return if @isClone or !((uid = @info.uniqueID) and (span = @nodes.uniqueID)) 13 | 14 | rgb = IDColor.ids[uid] or IDColor.compute uid 15 | 16 | # Style the damn node. 17 | {style} = span 18 | style.color = rgb[3] 19 | style.backgroundColor = "rgb(#{rgb[0]},#{rgb[1]},#{rgb[2]})" 20 | $.addClass span, 'painted' 21 | 22 | compute: (uid) -> 23 | # Convert chars to integers, bitshift and math to create a larger integer 24 | # Create a nice string of binary 25 | hash = if g.SITE.uidColor then g.SITE.uidColor(uid) else parseInt(uid, 16) 26 | 27 | # Convert binary string to numerical values with bitshift and '&' truncation. 28 | rgb = [ 29 | (hash >> 16) & 0xFF 30 | (hash >> 8) & 0xFF 31 | hash & 0xFF 32 | ] 33 | 34 | # Weight color luminance values, assign a font color that should be readable. 35 | rgb.push if $.luma(rgb) > 125 36 | '#000' 37 | else 38 | '#fff' 39 | 40 | # Cache. 41 | @ids[uid] = rgb 42 | -------------------------------------------------------------------------------- /src/meta/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "<%= meta.name %>", 3 | "version": "<%= readJSON('/version.json').version %>", 4 | "manifest_version": 2, 5 | "description": "<%= description %>", 6 | "icons": { 7 | "16": "icon16.png", 8 | "48": "icon48.png", 9 | "128": "icon128.png" 10 | }, 11 | "content_scripts": [{ 12 | "js": ["script.js"], 13 | "matches": <%= JSON.stringify(meta.matches_only.concat(meta.matches, meta.matches_extra)) %>, 14 | "exclude_matches": <%= JSON.stringify(meta.exclude_matches) %>, 15 | "all_frames": true, 16 | "run_at": "document_start" 17 | }], 18 | "background": { 19 | "scripts": ["eventPage.js"], 20 | "persistent": false 21 | }, 22 | "homepage_url": "<%= meta.page %>", 23 | <% if (channel !== '-noupdate') { %> "update_url": "<%= meta.downloads %>updates<%= channel %>.xml", 24 | "key": "<%= meta.appid %>", 25 | <% } %> "minimum_chrome_version": "<%= meta.min.chrome %>", 26 | "permissions": <%= JSON.stringify(meta.matches_only.concat(meta.matches, ["storage"])) %>, 27 | "optional_permissions": [ 28 | "*://*/" 29 | ], 30 | "applications": { 31 | "gecko": { 32 | "id": "<%= meta.appidGecko %>"<% if (channel !== '-noupdate') { %>, 33 | "update_url": "<%= meta.downloads %>updates<%= channel %>.json" 34 | <% } %> } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tools/webstore.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var child_process = require('child_process'); 3 | var request = require('request'); 4 | 5 | var pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); 6 | var v = JSON.parse(child_process.execSync('git show stable:version.json').toString()); 7 | var secrets = JSON.parse(fs.readFileSync(`../${pkg.meta.path}.keys/chrome-store.json`, 'utf8')); 8 | var refresh = JSON.parse(fs.readFileSync(`../${pkg.meta.path}.keys/refresh-token.json`, 'utf8')); 9 | 10 | var webStore = require('chrome-webstore-upload')({ 11 | extensionId: pkg.meta.chromeStoreID, 12 | clientId: secrets.installed.client_id, 13 | clientSecret: secrets.installed.client_secret, 14 | refreshToken: refresh.refresh_token 15 | }); 16 | 17 | request(`https://chrome.google.com/webstore/detail/${pkg.meta.chromeStoreID}`, function (error, response, body) { 18 | 19 | if (body && body.indexOf(``) > 0 && process.argv[2] !== 'force') { 20 | console.log(`Version ${v.version} already uploaded.`); 21 | return; 22 | } 23 | 24 | var myZipFile = fs.createReadStream(`dist/builds/${pkg.name}.zip`); 25 | var token; 26 | webStore.fetchToken().then(t => { 27 | token = t; 28 | return webStore.uploadExisting(myZipFile, token); 29 | }).then(() => 30 | webStore.publish() 31 | ).catch(res => { 32 | console.error(res); 33 | process.exit(1); 34 | }); 35 | 36 | }); 37 | -------------------------------------------------------------------------------- /src/site/SW.yotsuba.Build/File.html: -------------------------------------------------------------------------------- 1 | ?{file}{ 2 |
3 | ?{boardID === "f"}{ 4 |
5 | File: 6 | ${file.name} 7 | -(${file.size}, ${file.dimensions}?{file.tag}{, ${file.tag}}) 8 |
9 | }{ 10 |
11 | File: 12 | 13 | ?{file.isSpoiler}{Spoiler Image}{${shortFilename}} 14 | 15 | (${file.size}, ${file.dimensions || "PDF"}) 16 |
17 | 18 | ${file.size} 24 | 25 | } 26 |
27 | }{ 28 | ?{o.fileDeleted}{ 29 |
30 | 31 | File deleted. 32 | 33 |
34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/css/font-awesome.css: -------------------------------------------------------------------------------- 1 | .fa::before { 2 | font-family: FontAwesome; 3 | font-weight: 400; 4 | font-style: normal; 5 | -webkit-font-smoothing: antialiased; 6 | text-decoration: inherit; 7 | speak: none; 8 | display: inline-block; 9 | font-size: 13px; 10 | visibility: visible; 11 | } 12 | 13 | :root:not(.shortcut-icons) #shortcuts .fa::before { 14 | display: none; 15 | } 16 | :root.shortcut-icons #shortcuts .fa::before { 17 | font-size: 15px !important; 18 | margin-top: -3px !important; 19 | position: relative; 20 | top: 1px; 21 | } 22 | :root.shortcut-icons #shortcuts .fa, .menu-button .fa { 23 | font-size: 0; 24 | visibility: hidden; 25 | } 26 | :root.shortcut-icons .shortcut.brackets-wrap::after, 27 | :root.shortcut-icons .shortcut.brackets-wrap::before { 28 | display: none; 29 | } 30 | :root.shortcut-icons #shortcuts a .fa, 31 | .menu-button .fa, 32 | .hide-reply-button .fa, 33 | .hide-thread-button .fa { 34 | display: inline; 35 | } 36 | 37 | .fa-spin::before { 38 | -webkit-animation:spin 2s infinite linear; 39 | -moz-animation:spin 2s infinite linear; 40 | -o-animation:spin 2s infinite linear; 41 | animation:spin 2s infinite linear; 42 | } 43 | @-moz-keyframes spin { 44 | 0% {-moz-transform:rotate(0deg);} 45 | 100% {-moz-transform:rotate(359deg);} 46 | } 47 | @-webkit-keyframes spin { 48 | 0% {-webkit-transform:rotate(0deg);} 49 | 100% {-webkit-transform:rotate(359deg);} 50 | } 51 | @keyframes spin { 52 | 0% {transform:rotate(0deg);} 53 | 100% {transform:rotate(359deg);} 54 | } 55 | -------------------------------------------------------------------------------- /src/Monitoring/MarkNewIPs.coffee: -------------------------------------------------------------------------------- 1 | MarkNewIPs = 2 | init: -> 3 | return unless g.SITE.software is 'yotsuba' and g.VIEW is 'thread' and Conf['Mark New IPs'] 4 | Callbacks.Thread.push 5 | name: 'Mark New IPs' 6 | cb: @node 7 | 8 | node: -> 9 | MarkNewIPs.ipCount = @ipCount 10 | MarkNewIPs.postCount = @posts.keys.length 11 | $.on d, 'ThreadUpdate', MarkNewIPs.onUpdate 12 | 13 | onUpdate: (e) -> 14 | {ipCount, postCount, newPosts, deletedPosts} = e.detail 15 | return unless ipCount? 16 | 17 | switch ipCount - MarkNewIPs.ipCount 18 | when postCount - MarkNewIPs.postCount + deletedPosts.length 19 | i = MarkNewIPs.ipCount 20 | for fullID in newPosts 21 | MarkNewIPs.markNew g.posts.get(fullID), ++i 22 | when -deletedPosts.length 23 | for fullID in newPosts 24 | MarkNewIPs.markOld g.posts.get(fullID) 25 | MarkNewIPs.ipCount = ipCount 26 | MarkNewIPs.postCount = postCount 27 | 28 | markNew: (post, ipCount) -> 29 | suffix = if (ipCount // 10) % 10 is 1 30 | 'th' 31 | else 32 | ['st', 'nd', 'rd'][ipCount % 10 - 1] or 'th' # fuck switches 33 | counter = $.el 'span', 34 | className: 'ip-counter' 35 | textContent: "(#{ipCount})" 36 | post.nodes.nameBlock.title = "This is the #{ipCount}#{suffix} IP in the thread." 37 | $.add post.nodes.nameBlock, [$.tn(' '), counter] 38 | $.addClass post.nodes.root, 'new-ip' 39 | 40 | markOld: (post) -> 41 | post.nodes.nameBlock.title = 'Not the first post from this IP.' 42 | $.addClass post.nodes.root, 'old-ip' 43 | -------------------------------------------------------------------------------- /src/meta/eventPage.coffee: -------------------------------------------------------------------------------- 1 | requestID = 0 2 | 3 | chrome.runtime.onMessage.addListener (request, sender, sendResponse) -> 4 | id = requestID 5 | requestID++ 6 | sendResponse id 7 | handlers[request.type] request, (response) -> 8 | chrome.tabs.sendMessage sender.tab.id, {id, data: response} 9 | 10 | handlers = 11 | permission: (request, cb) -> 12 | origins = request.origins or ['*://*/'] 13 | chrome.permissions.contains {origins}, (result) -> 14 | if result 15 | cb result 16 | else 17 | chrome.permissions.request {origins}, (result) -> 18 | if chrome.runtime.lastError 19 | cb false 20 | else 21 | cb result 22 | 23 | ajax: (request, cb) -> 24 | xhr = new XMLHttpRequest() 25 | xhr.open 'GET', request.url, true 26 | xhr.responseType = request.responseType 27 | xhr.timeout = request.timeout 28 | for key, value of (request.headers or {}) 29 | xhr.setRequestHeader key, value 30 | xhr.addEventListener 'load', -> 31 | {status, statusText, response} = @ 32 | responseHeaderString = @getAllResponseHeaders() 33 | if response and request.responseType is 'arraybuffer' 34 | response = [new Uint8Array(response)...] 35 | cb {status, statusText, response, responseHeaderString} 36 | , false 37 | xhr.addEventListener 'error', -> 38 | cb {error: true} 39 | , false 40 | xhr.addEventListener 'abort', -> 41 | cb {error: true} 42 | , false 43 | xhr.addEventListener 'timeout', -> 44 | cb {error: true} 45 | , false 46 | xhr.send() 47 | -------------------------------------------------------------------------------- /tools/updcl.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var child_process = require('child_process'); 3 | 4 | var pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); 5 | var v = JSON.parse(fs.readFileSync('version.json', 'utf8')); 6 | 7 | var name = pkg.name; 8 | var oldVersions = pkg.meta.oldVersions; 9 | var version = v.version; 10 | var date = v.date; 11 | 12 | var branch = version.replace(/\.\d+$/, ''); 13 | var headerLevel = branch.replace(/(\.0)*$/, '').split('.').length; 14 | var headerPrefix = new Array(headerLevel + 1).join('#'); 15 | var separator = `${headerPrefix} v${branch}`; 16 | 17 | var today = date.split('T')[0]; 18 | var filename = `/builds/${name}-noupdate`; 19 | var ffLink = `${oldVersions}${version}${filename}.user.js`; 20 | var crLink = `${oldVersions}${version}${filename}.crx`; 21 | var line = `**v${version}** *(${today})* - [[Userscript](${ffLink})] [[Chrome extension](${crLink})]`; 22 | 23 | var changelog = fs.readFileSync('CHANGELOG.md', 'utf8'); 24 | 25 | var breakPos = changelog.indexOf(separator); 26 | if (breakPos >= 0) { 27 | breakPos += separator.length; 28 | } else { 29 | breakPos = Math.max(changelog.indexOf('\n\n#'), 0); 30 | line = `${separator}\n\n${line}`; 31 | } 32 | 33 | var prevVersion = changelog.substr(breakPos).match(/\*\*v([\d\.]+)\*\*/)[1]; 34 | if (prevVersion.replace(/\.\d+$/, '') !== branch) { 35 | line += `\n- Based on v${prevVersion}.`; 36 | } 37 | line += '\n- ' + child_process.execSync(`git log --pretty=format:%s ${prevVersion}..HEAD`).toString().replace(/\n/g, '\n- '); 38 | 39 | fs.writeFileSync('CHANGELOG.md', `${changelog.substr(0, breakPos)}\n\n${line}${changelog.substr(breakPos)}`, 'utf8'); 40 | console.log(`Changelog updated for v${version}.`); 41 | -------------------------------------------------------------------------------- /src/Miscellaneous/ModContact.coffee: -------------------------------------------------------------------------------- 1 | ModContact = 2 | init: -> 3 | return unless g.SITE.software is 'yotsuba' and g.VIEW in ['index', 'thread'] 4 | Callbacks.Post.push 5 | name: 'Mod Contact Links' 6 | cb: @node 7 | 8 | node: -> 9 | return if @isClone or !$.hasOwn(ModContact.specific, @info.capcode) 10 | links = $.el 'span', className: 'contact-links brackets-wrap' 11 | $.extend links, ModContact.template(@info.capcode) 12 | $.after @nodes.capcode, links 13 | if (moved = @info.comment.match /This thread was moved to >>>\/(\w+)\//) and $.hasOwn(ModContact.moveNote, moved[1]) 14 | moveNote = $.el 'div', className: 'move-note' 15 | $.extend moveNote, ModContact.moveNote[moved[1]] 16 | $.add @nodes.post, moveNote 17 | 18 | template: (capcode) -> 19 | `<%= html( 20 | 'feedback&{ModContact.specific[capcode]()}' 21 | ) %>` 22 | 23 | specific: 24 | Mod: -> `<%= html(' IRC') %>` 25 | Manager: -> ModContact.specific.Mod() 26 | Developer: -> `<%= html(' github') %>` 27 | Admin: -> `<%= html(' twitter') %>` 28 | 29 | moveNote: 30 | qa: `<%= html( 31 | 'Moving a thread to /qa/ does not imply mods will read it. If you wish to contact mods, use ' + 32 | 'feedback or ' + 33 | 'IRC.' 34 | ) %>` 35 | -------------------------------------------------------------------------------- /src/General/Settings/Sauce.html: -------------------------------------------------------------------------------- 1 |
Sauce is disabled.
2 | 3 |
4 | 5 |
These parameters will be replaced by their corresponding values in the URL and displayed text:
6 | 19 |
Lines starting with a # will be ignored.
20 |
You can specify a display text by appending ;text:[text] to the URL.
21 |
You can specify the applicable boards/sites by appending ;boards:[board1],[board2]. See the Filter guide for details.
22 |
You can specify the applicable file types by appending ;types:[extension1],[extension2].
23 |
You can specify a regular expression the filename must match by appending ;regexp:[regular expression].
24 |
25 | 26 | -------------------------------------------------------------------------------- /src/Posting/QR.persona.coffee: -------------------------------------------------------------------------------- 1 | QR.persona = 2 | always: {} 3 | types: 4 | name: [] 5 | email: [] 6 | sub: [] 7 | 8 | init: -> 9 | return unless Conf['Quick Reply'] or (Conf['Menu'] and Conf['Delete Link']) 10 | for item in Conf['QR.personas'].split '\n' 11 | QR.persona.parseItem item.trim() 12 | return 13 | 14 | parseItem: (item) -> 15 | return if item[0] is '#' 16 | return if not (match = item.match /(name|options|email|subject|password):"(.*)"/i) 17 | [match, type, val] = match 18 | 19 | # Don't mix up item settings with val. 20 | item = item.replace match, '' 21 | 22 | boards = item.match(/boards:([^;]+)/i)?[1].toLowerCase() or 'global' 23 | return if boards isnt 'global' and g.BOARD.ID not in boards.split ',' 24 | 25 | 26 | if type is 'password' 27 | QR.persona.pwd = val 28 | return 29 | 30 | type = 'email' if type is 'options' 31 | type = 'sub' if type is 'subject' 32 | 33 | if /always/i.test item 34 | QR.persona.always[type] = val 35 | 36 | unless val in QR.persona.types[type] 37 | QR.persona.types[type].push val 38 | 39 | load: -> 40 | for type, arr of QR.persona.types 41 | list = $ "#list-#{type}", QR.nodes.el 42 | for val in arr when val 43 | $.add list, $.el 'option', 44 | textContent: val 45 | return 46 | 47 | getPassword: -> 48 | if QR.persona.pwd? 49 | QR.persona.pwd 50 | else if (m = d.cookie.match /4chan_pass=([^;]+)/) 51 | decodeURIComponent m[1] 52 | else 53 | '' 54 | 55 | get: (cb) -> 56 | $.get 'QR.persona', {}, ({'QR.persona': persona}) -> 57 | cb persona 58 | 59 | set: (post) -> 60 | $.get 'QR.persona', {}, ({'QR.persona': persona}) -> 61 | persona = 62 | name: post.name 63 | flag: post.flag 64 | $.set 'QR.persona', persona 65 | -------------------------------------------------------------------------------- /src/site/Site.coffee: -------------------------------------------------------------------------------- 1 | Site = 2 | defaultProperties: 3 | '4chan.org': {software: 'yotsuba'} 4 | '4channel.org': {canonical: '4chan.org'} 5 | '4cdn.org': {canonical: '4chan.org'} 6 | 7 | init: (cb) -> 8 | $.extend Conf['siteProperties'], Site.defaultProperties 9 | hostname = Site.resolve() 10 | if hostname and $.hasOwn(SW, Conf['siteProperties'][hostname].software) 11 | @set hostname 12 | cb() 13 | $.onExists doc, 'body', => 14 | for software of SW when (changes = SW[software].detect?()) 15 | changes.software = software 16 | hostname = location.hostname.replace(/^www\./, '') 17 | properties = (Conf['siteProperties'][hostname] or= $.dict()) 18 | changed = 0 19 | for key of changes when properties[key] isnt changes[key] 20 | properties[key] = changes[key] 21 | changed++ 22 | if changed 23 | $.set 'siteProperties', Conf['siteProperties'] 24 | unless g.SITE 25 | @set hostname 26 | cb() 27 | return 28 | return 29 | 30 | resolve: (url=location) -> 31 | {hostname} = url 32 | while hostname and not $.hasOwn(Conf['siteProperties'], hostname) 33 | hostname = hostname.replace(/^[^.]*\.?/, '') 34 | if hostname 35 | hostname = canonical if (canonical = Conf['siteProperties'][hostname].canonical) 36 | hostname 37 | 38 | parseURL: (url) -> 39 | siteID = Site.resolve url 40 | Main.parseURL g.sites[siteID], url 41 | 42 | set: (hostname) -> 43 | for ID, properties of Conf['siteProperties'] 44 | continue if properties.canonical 45 | software = properties.software 46 | continue unless software and $.hasOwn(SW, software) 47 | g.sites[ID] = site = Object.create SW[software] 48 | $.extend site, {ID, siteID: ID, properties, software} 49 | g.SITE = g.sites[hostname] 50 | -------------------------------------------------------------------------------- /src/Menu/ArchiveLink.coffee: -------------------------------------------------------------------------------- 1 | ArchiveLink = 2 | init: -> 3 | return unless g.SITE.software is 'yotsuba' and g.VIEW in ['index', 'thread'] and Conf['Menu'] and Conf['Archive Link'] 4 | 5 | div = $.el 'div', 6 | textContent: 'Archive' 7 | 8 | entry = 9 | el: div 10 | order: 60 11 | open: ({ID, thread, board}) -> 12 | !!Redirect.to 'thread', {postID: ID, threadID: thread.ID, boardID: board.ID} 13 | subEntries: [] 14 | 15 | for type in [ 16 | ['Post', 'post'] 17 | ['Name', 'name'] 18 | ['Tripcode', 'tripcode'] 19 | ['Capcode', 'capcode'] 20 | ['Subject', 'subject'] 21 | ['Flag', 'country'] 22 | ['Filename', 'filename'] 23 | ['Image MD5', 'MD5'] 24 | ] 25 | # Add a sub entry for each type. 26 | entry.subEntries.push @createSubEntry type[0], type[1] 27 | 28 | Menu.menu.addEntry entry 29 | 30 | createSubEntry: (text, type) -> 31 | el = $.el 'a', 32 | textContent: text 33 | target: '_blank' 34 | 35 | open = if type is 'post' 36 | ({ID, thread, board}) -> 37 | el.href = Redirect.to 'thread', {postID: ID, threadID: thread.ID, boardID: board.ID} 38 | true 39 | else 40 | (post) -> 41 | typeParam = if type is 'country' and post.info.flagCodeTroll 42 | 'tag' 43 | else 44 | type 45 | value = if type is 'country' 46 | post.info.flagCode or post.info.flagCodeTroll 47 | else 48 | Filter.values(type, post)[0] 49 | # We want to parse the exact same stuff as the filter does already. 50 | return false unless value 51 | el.href = Redirect.to 'search', 52 | boardID: post.board.ID 53 | type: typeParam 54 | value: value 55 | isSearch: true 56 | true 57 | 58 | return { 59 | el: el 60 | open: open 61 | } 62 | -------------------------------------------------------------------------------- /src/classes/RandomAccessList.coffee: -------------------------------------------------------------------------------- 1 | class RandomAccessList 2 | constructor: (items) -> 3 | @length = 0 4 | @push item for item in items if items 5 | 6 | push: (data) -> 7 | {ID} = data 8 | ID or= data.id 9 | return if @[ID] 10 | {last} = @ 11 | @[ID] = item = 12 | prev: last 13 | next: null 14 | data: data 15 | ID: ID 16 | item.prev = last 17 | @last = if last 18 | last.next = item 19 | else 20 | @first = item 21 | @length++ 22 | 23 | before: (root, item) -> 24 | return if item.next is root or item is root 25 | 26 | @rmi item 27 | 28 | {prev} = root 29 | root.prev = item 30 | item.next = root 31 | item.prev = prev 32 | if prev 33 | prev.next = item 34 | else 35 | @first = item 36 | 37 | after: (root, item) -> 38 | return if item.prev is root or item is root 39 | 40 | @rmi item 41 | 42 | {next} = root 43 | root.next = item 44 | item.prev = root 45 | item.next = next 46 | if next 47 | next.prev = item 48 | else 49 | @last = item 50 | 51 | prepend: (item) -> 52 | {first} = @ 53 | return if item is first or not @[item.ID] 54 | @rmi item 55 | item.next = first 56 | if first 57 | first.prev = item 58 | else 59 | @last = item 60 | @first = item 61 | delete item.prev 62 | 63 | shift: -> 64 | @rm @first.ID 65 | 66 | order: -> 67 | order = [item = @first] 68 | order.push item while item = item.next 69 | order 70 | 71 | rm: (ID) -> 72 | item = @[ID] 73 | return unless item 74 | delete @[ID] 75 | @length-- 76 | @rmi item 77 | delete item.next 78 | delete item.prev 79 | 80 | rmi: (item) -> 81 | {prev, next} = item 82 | if prev 83 | prev.next = next 84 | else 85 | @first = next 86 | if next 87 | next.prev = prev 88 | else 89 | @last = prev 90 | -------------------------------------------------------------------------------- /src/Images/FappeTyme.coffee: -------------------------------------------------------------------------------- 1 | FappeTyme = 2 | init: -> 3 | return unless (Conf['Fappe Tyme'] or Conf['Werk Tyme']) and g.VIEW in ['index', 'thread', 'archive'] 4 | 5 | @nodes = {} 6 | @enabled = 7 | fappe: false 8 | werk: Conf['werk'] 9 | 10 | for type in ["Fappe", "Werk"] when Conf["#{type} Tyme"] 11 | lc = type.toLowerCase() 12 | el = UI.checkbox lc, "#{type} Tyme", false 13 | el.title = "#{type} Tyme" 14 | 15 | @nodes[lc] = el.firstElementChild 16 | @set lc, true if Conf[lc] 17 | $.on @nodes[lc], 'change', @toggle.bind(@, lc) 18 | 19 | Header.menu.addEntry 20 | el: el 21 | order: 97 22 | 23 | indicator = $.el 'span', 24 | className: 'indicator' 25 | textContent: type[0] 26 | title: "#{type} Tyme active" 27 | $.on indicator, 'click', -> 28 | check = $.getOwn(FappeTyme.nodes, @parentNode.id.replace('shortcut-', '')) 29 | check.checked = !check.checked 30 | $.event 'change', null, check 31 | Header.addShortcut lc, indicator, 410 32 | 33 | if Conf['Werk Tyme'] 34 | $.sync 'werk', @set.bind(@, 'werk') 35 | 36 | Callbacks.Post.push 37 | name: 'Fappe Tyme' 38 | cb: @node 39 | 40 | Callbacks.CatalogThread.push 41 | name: 'Werk Tyme' 42 | cb: @catalogNode 43 | 44 | node: -> 45 | @nodes.root.classList.toggle 'noFile', !@files.length 46 | 47 | catalogNode: -> 48 | file = @thread.OP.files[0] 49 | return if !file 50 | filename = $.el 'div', 51 | textContent: file.name 52 | className: 'werkTyme-filename' 53 | $.add @nodes.thumb.parentNode, filename 54 | 55 | set: (type, enabled) -> 56 | @enabled[type] = @nodes[type].checked = enabled 57 | $["#{if enabled then 'add' else 'rm'}Class"] doc, "#{type}Tyme" 58 | 59 | toggle: (type) -> 60 | @set type, !@enabled[type] 61 | $.cb.checked.call @nodes[type] if type is 'werk' 62 | -------------------------------------------------------------------------------- /src/Quotelinks/QuotePreview.coffee: -------------------------------------------------------------------------------- 1 | QuotePreview = 2 | init: -> 3 | return unless Conf['Quote Previewing'] 4 | 5 | if g.VIEW is 'archive' 6 | $.on d, 'mouseover', (e) -> 7 | if e.target.nodeName is 'A' and $.hasClass(e.target, 'quotelink') 8 | QuotePreview.mouseover.call e.target, e 9 | 10 | return unless g.VIEW in ['index', 'thread'] 11 | 12 | if Conf['Comment Expansion'] 13 | ExpandComment.callbacks.push @node 14 | 15 | Callbacks.Post.push 16 | name: 'Quote Previewing' 17 | cb: @node 18 | 19 | node: -> 20 | for link in @nodes.quotelinks.concat [@nodes.backlinks...], @nodes.archivelinks 21 | $.on link, 'mouseover', QuotePreview.mouseover 22 | return 23 | 24 | mouseover: (e) -> 25 | return if ($.hasClass(@, 'inlined') and not $.hasClass(doc, 'catalog-mode')) or not d.contains(@) 26 | 27 | {boardID, threadID, postID} = Get.postDataFromLink @ 28 | 29 | qp = $.el 'div', 30 | id: 'qp' 31 | className: 'dialog' 32 | 33 | $.add Header.hover, qp 34 | new Fetcher boardID, threadID, postID, qp, Get.postFromNode(@) 35 | 36 | UI.hover 37 | root: @ 38 | el: qp 39 | latestEvent: e 40 | endEvents: 'mouseout click' 41 | cb: QuotePreview.mouseout 42 | 43 | if Conf['Quote Highlighting'] and (origin = g.posts.get("#{boardID}.#{postID}")) 44 | posts = [origin].concat origin.clones 45 | # Remove the clone that's in the qp from the array. 46 | posts.pop() 47 | for post in posts 48 | $.addClass post.nodes.post, 'qphl' 49 | return 50 | 51 | mouseout: -> 52 | # Stop if it only contains text. 53 | return if not (root = @el.firstElementChild) 54 | 55 | $.event 'PostsRemoved', null, Header.hover 56 | 57 | clone = Get.postFromRoot root 58 | post = clone.origin 59 | post.rmClone root.dataset.clone 60 | 61 | return unless Conf['Quote Highlighting'] 62 | for post in [post].concat post.clones 63 | $.rmClass post.nodes.post, 'qphl' 64 | return 65 | -------------------------------------------------------------------------------- /src/General/Index/NavLinks.html: -------------------------------------------------------------------------------- 1 | Index 2 | Catalog 3 | Archive 4 | Bottom 5 | 6 | 7 | × 8 | 9 | 10 | 11 | 15 | 24 | 29 | 36 | 37 | -------------------------------------------------------------------------------- /src/Posting/Captcha.replace.coffee: -------------------------------------------------------------------------------- 1 | Captcha.replace = 2 | init: -> 3 | return unless g.SITE.software is 'yotsuba' and d.cookie.indexOf('pass_enabled=1') < 0 4 | 5 | if Conf['Force Noscript Captcha'] and Main.jsEnabled 6 | $.ready Captcha.replace.noscript 7 | return 8 | 9 | if Conf['captchaLanguage'].trim() or Conf['Captcha Fixes'] 10 | if location.hostname in ['boards.4chan.org', 'boards.4channel.org'] 11 | $.onExists doc, '#captchaFormPart', (node) -> $.onExists node, 'iframe', Captcha.replace.iframe 12 | else 13 | $.onExists doc, 'iframe', Captcha.replace.iframe 14 | 15 | noscript: -> 16 | return if not ((original = $ '#g-recaptcha') and (noscript = $ 'noscript', original.parentNode)) 17 | span = $.el 'span', 18 | id: 'captcha-forced-noscript' 19 | $.replace noscript, span 20 | $.rm original 21 | insert = -> 22 | span.innerHTML = noscript.textContent 23 | Captcha.replace.iframe $('iframe', span) 24 | if (toggle = $ '#togglePostFormLink a, #form-link') 25 | $.on toggle, 'click', insert 26 | else 27 | insert() 28 | 29 | iframe: (iframe) -> 30 | if (lang = Conf['captchaLanguage'].trim()) 31 | src = if /[?&]hl=/.test iframe.src 32 | iframe.src.replace(/([?&]hl=)[^&]*/, '$1' + encodeURIComponent lang) 33 | else 34 | iframe.src + "&hl=#{encodeURIComponent lang}" 35 | iframe.src = src unless iframe.src is src 36 | Captcha.replace.autocopy iframe 37 | 38 | autocopy: (iframe) -> 39 | return unless Conf['Captcha Fixes'] and /^https:\/\/www\.google\.com\/recaptcha\/api\/fallback\?/.test(iframe.src) 40 | new Connection iframe, 'https://www.google.com', 41 | working: -> 42 | if $.id('qr')?.contains iframe 43 | $('#qr .captcha-container textarea')?.parentNode.hidden = true 44 | token: (token) -> 45 | node = iframe 46 | while (node = node.parentNode) 47 | break if (textarea = $ 'textarea', node) 48 | textarea.value = token 49 | $.event 'input', null, textarea 50 | -------------------------------------------------------------------------------- /src/Images/Metadata.coffee: -------------------------------------------------------------------------------- 1 | Metadata = 2 | init: -> 3 | return unless Conf['WEBM Metadata'] and g.VIEW in ['index', 'thread'] 4 | 5 | Callbacks.Post.push 6 | name: 'WEBM Metadata' 7 | cb: @node 8 | 9 | node: -> 10 | for file, i in @files when /webm$/i.test file.url 11 | if @isClone 12 | el = $ '.webm-title', file.text 13 | else 14 | el = $.el 'span', 15 | className: 'webm-title' 16 | el.dataset.index = i 17 | $.extend el, 18 | `<%= html('') %>` 19 | $.add file.text, [$.tn(' '), el] 20 | $.one el.lastElementChild, 'mouseover focus', Metadata.load if el.children.length is 1 21 | return 22 | 23 | load: -> 24 | $.rmClass @parentNode, 'error' 25 | $.addClass @parentNode, 'loading' 26 | {index} = @parentNode.dataset 27 | CrossOrigin.binary Get.postFromNode(@).files[+index].url, (data) => 28 | $.rmClass @parentNode, 'loading' 29 | if data? 30 | title = Metadata.parse data 31 | output = $.el 'span', 32 | textContent: title or '' 33 | $.addClass @parentNode, 'not-found' unless title? 34 | $.before @, output 35 | @parentNode.tabIndex = 0 36 | @parentNode.focus() if d.activeElement is @ 37 | @tabIndex = -1 38 | else 39 | $.addClass @parentNode, 'error' 40 | $.one @, 'click', Metadata.load 41 | , 42 | Range: 'bytes=0-9999' 43 | 44 | parse: (data) -> 45 | readInt = -> 46 | n = data[i++] 47 | len = 0 48 | len++ while n < (0x80 >> len) 49 | n ^= (0x80 >> len) 50 | while len-- and i < data.length 51 | n = (n << 8) ^ data[i++] 52 | n 53 | 54 | i = 0 55 | while i < data.length 56 | element = readInt() 57 | size = readInt() 58 | if element is 0x3BA9 # Title 59 | title = '' 60 | while size-- and i < data.length 61 | title += String.fromCharCode data[i++] 62 | return decodeURIComponent escape title # UTF-8 decoding 63 | else unless element in [0x8538067, 0x549A966] # Segment, Info 64 | i += size 65 | null 66 | -------------------------------------------------------------------------------- /web.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Helvetica Neue", Helvetica, "Segoe UI", Arial, freesans, sans-serif; 3 | margin: 1em; 4 | } 5 | #header { 6 | background-color: #eee; 7 | margin-bottom: 1em; 8 | text-align: center; 9 | } 10 | h1 { 11 | margin: 0; 12 | line-height: 1.5; 13 | } 14 | #links { 15 | background-color: #e0e0e0; 16 | display: table; 17 | width: 100%; 18 | height: 1.5em; 19 | } 20 | #links > a { 21 | display: table-cell; 22 | vertical-align: middle; 23 | width: 25%; 24 | color: #000; 25 | text-decoration: none; 26 | } 27 | #links > a:hover, 28 | #links > a:focus { 29 | background-color: #CCC; 30 | font-weight: bold; 31 | } 32 | a.screenshot { 33 | display: block; 34 | width: 640px; 35 | max-width: 100%; 36 | margin: auto; 37 | } 38 | a.screenshot > img { 39 | width: 100%; 40 | } 41 | @media (min-width: 1120px) { 42 | a.screenshot { 43 | float: right; 44 | margin: 0 0 1em 1em; 45 | } 46 | } 47 | span.hover { 48 | display: none; 49 | } 50 | a:hover + span.hover { 51 | display: block; 52 | position: fixed; 53 | top: 0; 54 | bottom: 0; 55 | left: 0; 56 | right: 0; 57 | pointer-events: none; 58 | background: rgba(0,0,0,0.4); 59 | } 60 | span.hover > img { 61 | position: absolute; 62 | top: 0; 63 | bottom: 0; 64 | left: 0; 65 | right: 0; 66 | margin: auto; 67 | width: auto; 68 | height: auto; 69 | max-width: 100%; 70 | max-height: 100%; 71 | box-shadow: 5px 5px 20px rgba(0,0,0,0.4); 72 | } 73 | @media (max-width: 960px) { 74 | a.screenshot:hover + span.hover { 75 | display: none; 76 | } 77 | } 78 | @supports not (pointer-events: auto) { 79 | a[href$=".png"] { 80 | position: relative; 81 | } 82 | a[href$=".png"]::after { 83 | content: " "; 84 | position: absolute; 85 | top: 0; 86 | bottom: 0; 87 | left: 0; 88 | right: 0; 89 | z-index: 1; 90 | } 91 | } 92 | h2 ~ p, h2 ~ p + ul, input + div, div > h3 ~ * { 93 | margin-left: 1em; 94 | } 95 | input + div { 96 | margin-top: 1em; 97 | margin-bottom: 1em; 98 | margin-left: 1em; 99 | } 100 | h3 { 101 | display: inline; 102 | } 103 | input:checked + div > h3 ~ * { 104 | display: none; 105 | } 106 | -------------------------------------------------------------------------------- /src/General/BoardConfig.coffee: -------------------------------------------------------------------------------- 1 | BoardConfig = 2 | cbs: [] 3 | 4 | init: -> 5 | return unless g.SITE.software is 'yotsuba' 6 | now = Date.now() 7 | unless now - 2 * $.HOUR < (Conf['boardConfig'].lastChecked or 0) <= now and Conf['boardConfig'].troll_flags 8 | $.ajax "#{location.protocol}//a.4cdn.org/boards.json", 9 | onloadend: @load 10 | else 11 | {boards, troll_flags} = Conf['boardConfig'] 12 | @set boards, troll_flags 13 | 14 | load: -> 15 | if @status is 200 and @response and @response.boards 16 | boards = $.dict() 17 | for board in @response.boards 18 | boards[board.board] = board 19 | {troll_flags} = @response 20 | $.set 'boardConfig', {boards, troll_flags, lastChecked: Date.now()} 21 | else 22 | {boards, troll_flags} = Conf['boardConfig'] 23 | err = switch @status 24 | when 0 then 'Connection Error' 25 | when 200 then 'Invalid Data' 26 | else "Error #{@statusText} (#{@status})" 27 | new Notice 'warning', "Failed to load board configuration. #{err}", 20 28 | BoardConfig.set boards, troll_flags 29 | 30 | set: (@boards, @troll_flags) -> 31 | for ID, board of g.boards 32 | board.config = @boards[ID] or {} 33 | for cb in @cbs 34 | $.queueTask cb 35 | return 36 | 37 | ready: (cb) -> 38 | if @boards 39 | cb() 40 | else 41 | @cbs.push cb 42 | 43 | sfwBoards: (sfw) -> 44 | board for board, data of (@boards or Conf['boardConfig'].boards) when !!data.ws_board is sfw 45 | 46 | isSFW: (board) -> 47 | !!(@boards or Conf['boardConfig'].boards)[board]?.ws_board 48 | 49 | domain: (board) -> 50 | "boards.#{if BoardConfig.isSFW(board) then '4channel' else '4chan'}.org" 51 | 52 | isArchived: (board) -> 53 | # assume archive exists if no data available to prevent cleaning of archived threads 54 | data = (@boards or Conf['boardConfig'].boards)[board] 55 | !data or data.is_archived 56 | 57 | noAudio: (boardID) -> 58 | return false unless g.SITE.software is 'yotsuba' 59 | boards = @boards or Conf['boardConfig'].boards 60 | boards and boards[boardID] and !boards[boardID].webm_audio 61 | 62 | title: (boardID) -> 63 | (@boards or Conf['boardConfig'].boards)?[boardID]?.title or '' 64 | -------------------------------------------------------------------------------- /src/Miscellaneous/PSAHiding.coffee: -------------------------------------------------------------------------------- 1 | PSAHiding = 2 | init: -> 3 | return unless Conf['Announcement Hiding'] and g.SITE.selectors.psa 4 | $.addClass doc, 'hide-announcement' 5 | $.onExists doc, g.SITE.selectors.psa, @setup 6 | $.ready -> 7 | $.rmClass doc, 'hide-announcement' if !$(g.SITE.selectors.psa) 8 | 9 | setup: (psa) -> 10 | PSAHiding.psa = psa 11 | PSAHiding.text = psa.dataset.utc ? psa.innerHTML 12 | if g.SITE.selectors.psaTop and (hr = $(g.SITE.selectors.psaTop)?.previousElementSibling) and hr.nodeName is 'HR' 13 | PSAHiding.hr = hr 14 | PSAHiding.content = $.el 'div' 15 | 16 | entry = 17 | el: $.el 'a', 18 | textContent: 'Show announcement' 19 | className: 'show-announcement' 20 | href: 'javascript:;' 21 | order: 50 22 | open: -> psa.hidden 23 | Header.menu.addEntry entry 24 | $.on entry.el, 'click', PSAHiding.toggle 25 | 26 | PSAHiding.btn = btn = $.el 'a', 27 | title: 'Mark announcement as read and hide.' 28 | className: 'hide-announcement-button fa fa-minus-square' 29 | href: 'javascript:;' 30 | $.on btn, 'click', PSAHiding.toggle 31 | if psa.firstChild?.tagName is 'HR' 32 | $.after psa.firstChild, btn 33 | else 34 | $.prepend psa, btn 35 | 36 | PSAHiding.sync Conf['hiddenPSAList'] 37 | $.rmClass doc, 'hide-announcement' 38 | 39 | $.sync 'hiddenPSAList', PSAHiding.sync 40 | 41 | toggle: -> 42 | hide = $.hasClass @, 'hide-announcement-button' 43 | set = (hiddenPSAList) -> 44 | if hide 45 | hiddenPSAList[g.SITE.ID] = PSAHiding.text 46 | else 47 | delete hiddenPSAList[g.SITE.ID] 48 | set Conf['hiddenPSAList'] 49 | PSAHiding.sync Conf['hiddenPSAList'] 50 | $.get 'hiddenPSAList', Conf['hiddenPSAList'], ({hiddenPSAList}) -> 51 | set hiddenPSAList 52 | $.set 'hiddenPSAList', hiddenPSAList 53 | 54 | sync: (hiddenPSAList) -> 55 | {psa, content} = PSAHiding 56 | psa.hidden = (hiddenPSAList[g.SITE.ID] is PSAHiding.text) 57 | # Remove content to prevent autoplaying sounds from hidden announcements 58 | if psa.hidden 59 | $.add content, [psa.childNodes...] 60 | else 61 | $.add psa, [content.childNodes...] 62 | PSAHiding.hr?.hidden = psa.hidden 63 | -------------------------------------------------------------------------------- /src/meta/metadata.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name <%= meta.name %><%= (channel === '-beta') ? ' beta' : '' %> 3 | // @version <%= readJSON('/version.json').version %> 4 | // @minGMVer <%= meta.min.greasemonkey %> 5 | // @minFFVer <%= meta.min.firefox %> 6 | // @namespace <%= name %> 7 | // @description <%= description %> 8 | // @license MIT; <%= meta.license %> 9 | <%= 10 | (function() { 11 | function expand(items, regex, substitutions) { 12 | var results = []; 13 | items.forEach(function(item) { 14 | if (regex.test(item)) { 15 | substitutions.forEach(function(s) { 16 | results.push(item.replace(regex, s)); 17 | }); 18 | } else { 19 | results.push(item); 20 | } 21 | }); 22 | return results; 23 | } 24 | function expandMatches(matches) { 25 | return expand(matches, /^\*/, ['http', 'https']); 26 | } 27 | return [].concat( 28 | expandMatches(meta.includes_only.concat(meta.matches, meta.matches_extra)).map(function(match) { 29 | return '// @include ' + match; 30 | }), 31 | expandMatches(meta.exclude_matches).map(function(match) { 32 | return '// @exclude ' + match; 33 | }) 34 | ).join('\n'); 35 | })() 36 | %> 37 | // @connect 4chan.org 38 | // @connect 4channel.org 39 | // @connect 4cdn.org 40 | // @connect mayhemydg.github.io 41 | <%= 42 | readJSON('/src/Archive/archives.json').map(function(archive) { 43 | return '// @connect ' + archive.domain; 44 | }).join('\n') 45 | %> 46 | // @connect api.clyp.it 47 | // @connect api.dailymotion.com 48 | // @connect api.github.com 49 | // @connect soundcloud.com 50 | // @connect api.streamable.com 51 | // @connect vimeo.com 52 | // @connect www.googleapis.com 53 | // @connect * 54 | <%= 55 | meta.grants.map(function(grant) { 56 | return '// @grant ' + grant; 57 | }).join('\n') 58 | %> 59 | // @run-at document-start 60 | // @updateURL <%= (channel !== '-noupdate') ? `${meta.downloads}${name}${channel}.meta.js` : 'https://noupdate.invalid/' %> 61 | // @downloadURL <%= (channel !== '-noupdate') ? `${meta.downloads}${name}${channel}.user.js` : 'https://noupdate.invalid/' %> 62 | // @icon data:image/png;base64,<%= readBase64('/src/meta/icon48.png') %> 63 | // ==/UserScript== 64 | -------------------------------------------------------------------------------- /src/Miscellaneous/PostJumper.coffee: -------------------------------------------------------------------------------- 1 | PostJumper = 2 | init: -> 3 | return unless Conf['Unique ID and Capcode Navigation'] and g.VIEW in ['index', 'thread'] 4 | 5 | @buttons = @makeButtons() 6 | 7 | Callbacks.Post.push 8 | name: 'Post Jumper' 9 | cb: @node 10 | 11 | node: -> 12 | if @isClone 13 | for buttons in $$ '.postJumper', @nodes.info 14 | PostJumper.addListeners buttons 15 | return 16 | 17 | if @nodes.uniqueIDRoot 18 | PostJumper.addButtons @,'uniqueID' 19 | 20 | if @nodes.capcode 21 | PostJumper.addButtons @,'capcode' 22 | 23 | addButtons: (post,type) -> 24 | value = post.info[type] 25 | buttons = PostJumper.buttons.cloneNode(true) 26 | $.extend buttons.dataset, {type, value} 27 | $.after post.nodes[type+(if type is 'capcode' then '' else 'Root')], buttons 28 | PostJumper.addListeners buttons 29 | 30 | addListeners: (buttons) -> 31 | $.on buttons.firstChild, 'click', PostJumper.buttonClick 32 | $.on buttons.lastChild, 'click', PostJumper.buttonClick 33 | 34 | buttonClick: -> 35 | dir = if $.hasClass(@, 'prev') then -1 else 1 36 | if (toJumper = PostJumper.find @parentNode, dir) 37 | PostJumper.scroll @parentNode, toJumper 38 | 39 | find: (jumper, dir) -> 40 | {type, value} = jumper.dataset 41 | xpath = "span[contains(@class,\"postJumper\") and @data-value=\"#{value}\" and @data-type=\"#{type}\"]" 42 | axis = if dir < 0 then 'preceding' else 'following' 43 | jumper2 = jumper 44 | while (jumper2 = $.x "#{axis}::#{xpath}", jumper2) 45 | return jumper2 if jumper2.getBoundingClientRect().height 46 | if (jumper2 = $.x "(//#{xpath})[#{if dir < 0 then 'last()' else '1'}]") 47 | return jumper2 if jumper2.getBoundingClientRect().height 48 | while (jumper2 = $.x "#{axis}::#{xpath}", jumper2) and jumper2 isnt jumper 49 | return jumper2 if jumper2.getBoundingClientRect().height 50 | null 51 | 52 | makeButtons: -> 53 | charPrev = '\u23EB' 54 | charNext = '\u23EC' 55 | classPrev = 'prev' 56 | classNext = 'next' 57 | span = $.el 'span', 58 | className: 'postJumper' 59 | $.extend span, `<%= html('${charPrev}${charNext}') %>` 60 | span 61 | 62 | scroll: (fromJumper, toJumper) -> 63 | prevPos = fromJumper.getBoundingClientRect().top 64 | destPos = toJumper.getBoundingClientRect().top 65 | window.scrollBy 0, destPos-prevPos 66 | -------------------------------------------------------------------------------- /src/site/SW.yotsuba.Build/PostInfo.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | ?{!o.isReply || boardID === "f" || subject}{${subject || ""} } 4 | 5 | ?{email}{} 6 | ${name} 7 | ?{tripcode}{ ${tripcode}} 8 | ?{o.extra.xa19s}{ } 9 | ?{pass}{ } 10 | ?{capcode}{ ## ${capcode}} 11 | ?{email}{} 12 | ?{boardID === "f" && !o.isReply || capcodeDescription}{}{ } 13 | ?{capcodeDescription}{ ${capcode} Icon} 14 | ?{uniqueID && !capcode}{ (ID: ${uniqueID})} 15 | ?{flagCode}{ } 16 | ?{flagCodeTroll}{ ${flagCodeTroll}} 17 | 18 | ${dateText} 19 | 20 | No. 21 | ${ID} 22 | ?{o.extra.xa19l && o.isReply}{ } 23 | ?{o.isSticky}{ Sticky} 24 | ?{o.isClosed && !o.isArchived}{ Closed} 25 | ?{o.isArchived}{ Archived} 26 | ?{!o.isReply && g.VIEW === "index"}{   [Reply]} 27 | 28 |
29 | -------------------------------------------------------------------------------- /src/Miscellaneous/Time.coffee: -------------------------------------------------------------------------------- 1 | Time = 2 | init: -> 3 | return unless g.VIEW in ['index', 'thread', 'archive'] and Conf['Time Formatting'] 4 | 5 | Callbacks.Post.push 6 | name: 'Time Formatting' 7 | cb: @node 8 | 9 | node: -> 10 | return if !@info.date or @isClone 11 | {textContent} = @nodes.date 12 | @nodes.date.textContent = textContent.match(/^\s*/)[0] + Time.format(Conf['time'], @info.date) + textContent.match(/\s*$/)[0] 13 | 14 | format: (formatString, date) -> 15 | formatString.replace /%(.)/g, (s, c) -> 16 | if $.hasOwn(Time.formatters, c) 17 | Time.formatters[c].call(date) 18 | else 19 | s 20 | 21 | day: [ 22 | 'Sunday' 23 | 'Monday' 24 | 'Tuesday' 25 | 'Wednesday' 26 | 'Thursday' 27 | 'Friday' 28 | 'Saturday' 29 | ] 30 | 31 | month: [ 32 | 'January' 33 | 'February' 34 | 'March' 35 | 'April' 36 | 'May' 37 | 'June' 38 | 'July' 39 | 'August' 40 | 'September' 41 | 'October' 42 | 'November' 43 | 'December' 44 | ] 45 | 46 | localeFormat: (date, options, defaultValue) -> 47 | if Conf['timeLocale'] 48 | try 49 | return Intl.DateTimeFormat(Conf['timeLocale'], options).format(date) 50 | defaultValue 51 | 52 | localeFormatPart: (date, options, part, defaultValue) -> 53 | if Conf['timeLocale'] 54 | try 55 | parts = Intl.DateTimeFormat(Conf['timeLocale'], options).formatToParts(date) 56 | return parts.map((x) -> if x.type is part then x.value else '').join('') 57 | defaultValue 58 | 59 | zeroPad: (n) -> if n < 10 then "0#{n}" else n 60 | 61 | formatters: 62 | a: -> Time.localeFormat @, {weekday: 'short'}, Time.day[@getDay()][...3] 63 | A: -> Time.localeFormat @, {weekday: 'long'}, Time.day[@getDay()] 64 | b: -> Time.localeFormat @, {month: 'short'}, Time.month[@getMonth()][...3] 65 | B: -> Time.localeFormat @, {month: 'long'}, Time.month[@getMonth()] 66 | d: -> Time.zeroPad @getDate() 67 | e: -> @getDate() 68 | H: -> Time.zeroPad @getHours() 69 | I: -> Time.zeroPad @getHours() % 12 or 12 70 | k: -> @getHours() 71 | l: -> @getHours() % 12 or 12 72 | m: -> Time.zeroPad @getMonth() + 1 73 | M: -> Time.zeroPad @getMinutes() 74 | p: -> Time.localeFormatPart @, {hour: 'numeric', hour12: true}, 'dayperiod', (if @getHours() < 12 then 'AM' else 'PM') 75 | P: -> Time.formatters.p.call(@).toLowerCase() 76 | S: -> Time.zeroPad @getSeconds() 77 | y: -> @getFullYear().toString()[2..] 78 | Y: -> @getFullYear() 79 | '%': -> '%' 80 | -------------------------------------------------------------------------------- /src/Miscellaneous/ExpandComment.coffee: -------------------------------------------------------------------------------- 1 | ExpandComment = 2 | init: -> 3 | return if g.VIEW isnt 'index' or !Conf['Comment Expansion'] or Conf['JSON Index'] 4 | 5 | Callbacks.Post.push 6 | name: 'Comment Expansion' 7 | cb: @node 8 | 9 | node: -> 10 | if a = $ '.abbr > a:not([onclick])', @nodes.comment 11 | $.on a, 'click', ExpandComment.cb 12 | 13 | callbacks: [] 14 | 15 | cb: (e) -> 16 | e.preventDefault() 17 | ExpandComment.expand Get.postFromNode @ 18 | 19 | expand: (post) -> 20 | if post.nodes.longComment and !post.nodes.longComment.parentNode 21 | $.replace post.nodes.shortComment, post.nodes.longComment 22 | post.nodes.comment = post.nodes.longComment 23 | return 24 | return if not (a = $ '.abbr > a', post.nodes.comment) 25 | a.textContent = "Post No.#{post} Loading..." 26 | $.cache g.SITE.urls.threadJSON({boardID: post.boardID, threadID: post.threadID}), -> ExpandComment.parse @, a, post 27 | 28 | contract: (post) -> 29 | return unless post.nodes.shortComment 30 | a = $ '.abbr > a', post.nodes.shortComment 31 | a.textContent = 'here' 32 | $.replace post.nodes.longComment, post.nodes.shortComment 33 | post.nodes.comment = post.nodes.shortComment 34 | 35 | parse: (req, a, post) -> 36 | {status} = req 37 | unless status in [200, 304] 38 | a.textContent = if status then "Error #{req.statusText} (#{status})" else 'Connection Error' 39 | return 40 | 41 | posts = req.response.posts 42 | if spoilerRange = posts[0].custom_spoiler 43 | g.SITE.Build.spoilerRange[g.BOARD] = spoilerRange 44 | 45 | for postObj in posts 46 | break if postObj.no is post.ID 47 | if postObj.no isnt post.ID 48 | a.textContent = "Post No.#{post} not found." 49 | return 50 | 51 | {comment} = post.nodes 52 | clone = comment.cloneNode false 53 | clone.innerHTML = postObj.com 54 | # Fix pathnames 55 | for quote in $$ '.quotelink', clone 56 | href = quote.getAttribute 'href' 57 | continue if href[0] is '/' # Cross-board quote, or board link 58 | if href[0] is '#' 59 | quote.href = "#{a.pathname.split(/\/+/).splice(0,4).join('/')}#{href}" 60 | else 61 | quote.href = "#{a.pathname.split(/\/+/).splice(0,3).join('/')}/#{href}" 62 | post.nodes.shortComment = comment 63 | $.replace comment, clone 64 | post.nodes.comment = post.nodes.longComment = clone 65 | post.parseComment() 66 | post.parseQuotes() 67 | 68 | for callback in ExpandComment.callbacks 69 | callback.call post 70 | return 71 | -------------------------------------------------------------------------------- /src/General/Get.coffee: -------------------------------------------------------------------------------- 1 | Get = 2 | url: (type, IDs, args...) -> 3 | if (site = g.sites[IDs.siteID]) and (f = $.getOwn(site.urls, type)) 4 | f IDs, args... 5 | else 6 | undefined 7 | threadExcerpt: (thread) -> 8 | {OP} = thread 9 | excerpt = ("/#{decodeURIComponent thread.board.ID}/ - ") + ( 10 | OP.info.subject?.trim() or 11 | OP.commentDisplay().replace(/\n+/g, ' // ') or 12 | OP.file?.name or 13 | "No.#{OP}") 14 | return "#{excerpt[...70]}..." if excerpt.length > 73 15 | excerpt 16 | threadFromRoot: (root) -> 17 | return null unless root? 18 | {board} = root.dataset 19 | g.threads.get("#{if board then encodeURIComponent(board) else g.BOARD.ID}.#{root.id.match(/\d*$/)[0]}") 20 | threadFromNode: (node) -> 21 | Get.threadFromRoot $.x "ancestor-or-self::#{g.SITE.xpath.thread}", node 22 | postFromRoot: (root) -> 23 | return null unless root? 24 | post = g.posts.get(root.dataset.fullID) 25 | index = root.dataset.clone 26 | if index then post.clones[+index] else post 27 | postFromNode: (root) -> 28 | Get.postFromRoot $.x "ancestor-or-self::#{g.SITE.xpath.postContainer}[1]", root 29 | postDataFromLink: (link) -> 30 | if link.dataset.postID # resurrected quote 31 | {boardID, threadID, postID} = link.dataset 32 | threadID or= 0 33 | else 34 | match = link.href.match g.SITE.regexp.quotelink 35 | [boardID, threadID, postID] = match[1..] 36 | postID or= threadID 37 | return { 38 | boardID: boardID 39 | threadID: +threadID 40 | postID: +postID 41 | } 42 | allQuotelinksLinkingTo: (post) -> 43 | # Get quotelinks & backlinks linking to the given post. 44 | quotelinks = [] 45 | {posts} = g 46 | {fullID} = post 47 | handleQuotes = (qPost, type) -> 48 | quotelinks.push qPost.nodes[type]... 49 | quotelinks.push clone.nodes[type]... for clone in qPost.clones 50 | return 51 | # First: 52 | # In every posts, 53 | # if it did quote this post, 54 | # get all their backlinks. 55 | posts.forEach (qPost) -> 56 | if fullID in qPost.quotes 57 | handleQuotes qPost, 'quotelinks' 58 | 59 | # Second: 60 | # If we have quote backlinks: 61 | # in all posts this post quoted 62 | # and their clones, 63 | # get all of their backlinks. 64 | if Conf['Quote Backlinks'] 65 | handleQuotes qPost, 'backlinks' for quote in post.quotes when qPost = posts.get(quote) 66 | 67 | # Third: 68 | # Filter out irrelevant quotelinks. 69 | quotelinks.filter (quotelink) -> 70 | {boardID, postID} = Get.postDataFromLink quotelink 71 | boardID is post.board.ID and postID is post.ID 72 | -------------------------------------------------------------------------------- /src/Images/ImageHover.coffee: -------------------------------------------------------------------------------- 1 | ImageHover = 2 | init: -> 3 | return if g.VIEW not in ['index', 'thread'] 4 | if Conf['Image Hover'] 5 | Callbacks.Post.push 6 | name: 'Image Hover' 7 | cb: @node 8 | if Conf['Image Hover in Catalog'] 9 | Callbacks.CatalogThread.push 10 | name: 'Image Hover' 11 | cb: @catalogNode 12 | 13 | node: -> 14 | for file in @files when (file.isImage or file.isVideo) and file.thumb 15 | $.on file.thumb, 'mouseover', ImageHover.mouseover(@, file) 16 | 17 | catalogNode: -> 18 | file = @thread.OP.files[0] 19 | return unless file and (file.isImage or file.isVideo) 20 | $.on @nodes.thumb, 'mouseover', ImageHover.mouseover(@thread.OP, file) 21 | 22 | mouseover: (post, file) -> (e) -> 23 | return unless doc.contains @ 24 | {isVideo} = file 25 | return if file.isExpanding or file.isExpanded or g.SITE.isThumbExpanded?(file) 26 | error = ImageHover.error post, file 27 | if ImageCommon.cache?.dataset.fileID is "#{post.fullID}.#{file.index}" 28 | el = ImageCommon.popCache() 29 | $.on el, 'error', error 30 | else 31 | el = $.el (if isVideo then 'video' else 'img') 32 | el.dataset.fileID = "#{post.fullID}.#{file.index}" 33 | $.on el, 'error', error 34 | el.src = file.url 35 | 36 | if Conf['Restart when Opened'] 37 | ImageCommon.rewind el 38 | ImageCommon.rewind @ 39 | el.id = 'ihover' 40 | $.add Header.hover, el 41 | if isVideo 42 | el.loop = true 43 | el.controls = false 44 | Volume.setup el 45 | if Conf['Autoplay'] 46 | el.play() 47 | @currentTime = el.currentTime if @nodeName is 'VIDEO' 48 | if file.dimensions 49 | [width, height] = (+x for x in file.dimensions.split 'x') 50 | maxWidth = doc.clientWidth 51 | maxHeight = doc.clientHeight - UI.hover.padding 52 | scale = Math.min 1, maxWidth / width, maxHeight / height 53 | width *= scale 54 | height *= scale 55 | el.style.maxWidth = "#{width}px" 56 | el.style.maxHeight = "#{height}px" 57 | UI.hover 58 | root: @ 59 | el: el 60 | latestEvent: e 61 | endEvents: 'mouseout click' 62 | height: height 63 | width: width 64 | noRemove: true 65 | cb: -> 66 | $.off el, 'error', error 67 | ImageCommon.pushCache el 68 | ImageCommon.pause el 69 | $.rm el 70 | el.removeAttribute 'style' 71 | 72 | error: (post, file) -> -> 73 | return if ImageCommon.decodeError @, file 74 | ImageCommon.error @, post, file, 3 * $.SECOND, (URL) => 75 | if URL 76 | @src = URL + if @src is URL then '?' + Date.now() else '' 77 | else 78 | $.rm @ 79 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | /* 2 | * 4chan X 3 | * 4 | * Licensed under the MIT license. 5 | * https://github.com/ccd0/4chan-x/blob/master/LICENSE 6 | * 7 | * Appchan X Copyright © 2013-2016 Zixaphir 8 | * http://zixaphir.github.io/appchan-x/ 9 | * 4chan x Copyright © 2009-2011 James Campos 10 | * https://github.com/aeosynth/4chan-x 11 | * 4chan x Copyright © 2012-2014 Nicolas Stepien 12 | * https://4chan-x.just-believe.in/ 13 | * 4chan x Copyright © 2013-2014 Jordan Bates 14 | * http://seaweedchan.github.io/4chan-x/ 15 | * 4chan x Copyright © 2012-2013 ihavenoface 16 | * http://ihavenoface.github.io/4chan-x/ 17 | * 4chan SS Copyright © 2011-2013 Ahodesuka 18 | * https://github.com/ahodesuka/4chan-Style-Script/ 19 | * 20 | * Permission is hereby granted, free of charge, to any person 21 | * obtaining a copy of this software and associated documentation 22 | * files (the "Software"), to deal in the Software without 23 | * restriction, including without limitation the rights to use, 24 | * copy, modify, merge, publish, distribute, sublicense, and/or sell 25 | * copies of the Software, and to permit persons to whom the 26 | * Software is furnished to do so, subject to the following 27 | * conditions: 28 | * 29 | * The above copyright notice and this permission notice shall be 30 | * included in all copies or substantial portions of the Software. 31 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 32 | * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 33 | * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 34 | * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 35 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 36 | * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 37 | * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 38 | * OTHER DEALINGS IN THE SOFTWARE. 39 | * 40 | * Contributors: 41 | * aeosynth 42 | * mayhemydg 43 | * noface 44 | * !K.WeEabo0o 45 | * blaise 46 | * that4chanwolf 47 | * desuwa 48 | * seaweed 49 | * e000 50 | * ahodesuka 51 | * Shou 52 | * ferongr 53 | * xat 54 | * Ongpot 55 | * thisisanon 56 | * Anonymous 57 | * Seiba 58 | * herpaderpderp 59 | * WakiMiko 60 | * btmcsweeney 61 | * AppleBloom 62 | * detharonil 63 | * 64 | * All the people who've taken the time to write bug reports. 65 | * 66 | * Thank you. 67 | */ 68 | 69 | /* 70 | * Contains data from external sources: 71 | * 72 | * src/Monitoring/ThreadUpdater/beep.wav from http://freesound.org/people/pierrecartoons1979/sounds/90112/ 73 | * cc-by-nc-3.0 74 | * 75 | * Font Awesome by Dave Gandy (http://fontawesome.io) 76 | * license: http://fontawesome.io/license/ 77 | * 78 | * Icons used to identify various websites are property of the respective websites. 79 | */ 80 | -------------------------------------------------------------------------------- /src/Miscellaneous/Nav.coffee: -------------------------------------------------------------------------------- 1 | Nav = 2 | init: -> 3 | switch g.VIEW 4 | when 'index' 5 | return unless Conf['Index Navigation'] 6 | when 'thread' 7 | return unless Conf['Reply Navigation'] 8 | else 9 | return 10 | 11 | span = $.el 'span', 12 | id: 'navlinks' 13 | prev = $.el 'a', 14 | textContent: '▲' 15 | href: 'javascript:;' 16 | next = $.el 'a', 17 | textContent: '▼' 18 | href: 'javascript:;' 19 | 20 | $.on prev, 'click', @prev 21 | $.on next, 'click', @next 22 | 23 | $.add span, [prev, $.tn(' '), next] 24 | append = -> 25 | $.off d, '4chanXInitFinished', append 26 | $.add d.body, span 27 | $.on d, '4chanXInitFinished', append 28 | 29 | prev: -> 30 | if g.VIEW is 'thread' 31 | window.scrollTo 0, 0 32 | else 33 | Nav.scroll -1 34 | 35 | next: -> 36 | if g.VIEW is 'thread' 37 | window.scrollTo 0, d.body.scrollHeight 38 | else 39 | Nav.scroll +1 40 | 41 | getThread: -> 42 | return g.threads.get("#{g.BOARD}.#{g.THREADID}").nodes.root if g.VIEW is 'thread' 43 | return if $.hasClass doc, 'catalog-mode' 44 | for threadRoot in $$ g.SITE.selectors.thread 45 | thread = Get.threadFromRoot threadRoot 46 | continue if thread.isHidden and !thread.stub 47 | if Header.getTopOf(threadRoot) >= -threadRoot.getBoundingClientRect().height # not scrolled past 48 | return threadRoot 49 | return 50 | 51 | scroll: (delta) -> 52 | d.activeElement?.blur() 53 | thread = Nav.getThread() 54 | return unless thread 55 | axis = if delta is +1 56 | 'following' 57 | else 58 | 'preceding' 59 | if next = $.x "#{axis}-sibling::#{g.SITE.xpath.thread}[not(@hidden)][1]", thread 60 | # Unless we're not at the beginning of the current thread, 61 | # and thus wanting to move to beginning, 62 | # or we're above the first thread and don't want to skip it. 63 | top = Header.getTopOf thread 64 | thread = next if delta is +1 and top < 5 or delta is -1 and top > -5 65 | # Add extra space to the end of the page if necessary so that all threads can be selected by keybinds. 66 | extra = Header.getTopOf(thread) + doc.clientHeight - d.body.getBoundingClientRect().bottom 67 | d.body.style.marginBottom = "#{extra}px" if extra > 0 68 | 69 | Header.scrollTo thread 70 | 71 | if extra > 0 and !Nav.haveExtra 72 | Nav.haveExtra = true 73 | $.on d, 'scroll', Nav.removeExtra 74 | 75 | removeExtra: -> 76 | extra = doc.clientHeight - d.body.getBoundingClientRect().bottom 77 | if extra > 0 78 | d.body.style.marginBottom = "#{extra}px" 79 | else 80 | d.body.style.marginBottom = '' 81 | delete Nav.haveExtra 82 | $.off d, 'scroll', Nav.removeExtra 83 | -------------------------------------------------------------------------------- /src/classes/Post.Clone.coffee: -------------------------------------------------------------------------------- 1 | Post.Clone = class extends Post 2 | isClone: true 3 | 4 | constructor: -> 5 | that = Object.create(Post.Clone.prototype) 6 | that.construct arguments... 7 | return that 8 | 9 | construct: (@origin, @context, contractThumb) -> 10 | for key in ['ID', 'postID', 'threadID', 'boardID', 'siteID', 'fullID', 'board', 'thread', 'info', 'quotes', 'isReply'] 11 | # Copy or point to the origin's key value. 12 | @[key] = @origin[key] 13 | 14 | {nodes} = @origin 15 | root = if contractThumb 16 | @cloneWithoutVideo nodes.root 17 | else 18 | nodes.root.cloneNode true 19 | Post.Clone.suffix or= 0 20 | for node in [root, $$('[id]', root)...] 21 | node.id += "_#{Post.Clone.suffix}" 22 | Post.Clone.suffix++ 23 | 24 | # Remove inlined posts inside of this post. 25 | for inline in $$ '.inline', root 26 | $.rm inline 27 | for inlined in $$ '.inlined', root 28 | $.rmClass inlined, 'inlined' 29 | 30 | @nodes = @parseNodes root 31 | 32 | root.hidden = false # post hiding 33 | $.rmClass root, 'forwarded' # quote inlining 34 | $.rmClass @nodes.post, 'highlight' # keybind navigation, ID highlighting 35 | 36 | # Remove catalog stuff. 37 | unless @isReply 38 | @setCatalogOP false 39 | $.rm $('.catalog-link', @nodes.post) 40 | $.rm $('.catalog-stats', @nodes.post) 41 | $.rm $('.catalog-replies', @nodes.post) 42 | 43 | @parseQuotes() 44 | @quotes = [@origin.quotes...] 45 | 46 | @files = [] 47 | fileRoots = @fileRoots() if @origin.files.length 48 | for originFile in @origin.files 49 | # Copy values, point to relevant elements. 50 | file = {} 51 | for key, val of originFile 52 | file[key] = val 53 | fileRoot = fileRoots[file.docIndex] 54 | for key, selector of g.SITE.selectors.file 55 | file[key] = $ selector, fileRoot 56 | file.thumbLink = file.thumb?.parentNode 57 | file.fullImage = $ '.full-image', file.thumbLink if file.thumbLink 58 | file.videoControls = $ '.video-controls', file.text 59 | file.thumb.muted = true if file.videoThumb 60 | @files.push file 61 | 62 | if @files.length 63 | @file = @files[0] 64 | 65 | # Contract thumbnails in quote preview 66 | ImageExpand.contract @ if @file.thumb and contractThumb 67 | 68 | @isDead = true if @origin.isDead 69 | root.dataset.clone = @origin.clones.push(@) - 1 70 | 71 | cloneWithoutVideo: (node) -> 72 | if node.tagName is 'VIDEO' and !node.dataset.md5 # (exception for WebM thumbnails) 73 | [] 74 | else if node.nodeType is Node.ELEMENT_NODE and $ 'video', node 75 | clone = node.cloneNode false 76 | $.add clone, @cloneWithoutVideo child for child in node.childNodes 77 | clone 78 | else 79 | node.cloneNode true 80 | -------------------------------------------------------------------------------- /src/Posting/Captcha.service.coffee: -------------------------------------------------------------------------------- 1 | Captcha.service = 2 | init: -> 3 | $.on d, 'LoadCaptcha', @loadCaptcha.bind(@) 4 | $.on d, 'AbortCaptcha SaveCaptcha', @abortCaptcha.bind(@) 5 | $.on d, 'RequestCaptcha', @requestCaptcha.bind(@) 6 | 7 | isEnabled: -> 8 | Conf['captchaServiceDomain'] and /\S/.test(Conf['captchaServiceDomain']) 9 | 10 | loadCaptcha: (e) -> 11 | return unless @isEnabled() 12 | e.preventDefault() if !@pending or @aborted 13 | 14 | abortCaptcha: -> 15 | @aborted = true if @pending 16 | 17 | requestCaptcha: (e) -> 18 | return unless @isEnabled() 19 | return if e.defaultPrevented 20 | if @pending and @aborted 21 | @aborted = false 22 | return 23 | return if @pending 24 | @pending = true 25 | @aborted = false 26 | e.preventDefault() 27 | CrossOrigin.permission @requestCaptcha2.bind(@), @noCaptcha.bind(@, 'Permission denied'), ["#{Conf['captchaServiceDomain']}/*"] 28 | 29 | requestCaptcha2: -> 30 | key = Conf['captchaServiceKey'][Conf['captchaServiceDomain']] 31 | return @noCaptcha 'API key not set' unless key and /\S/.test(key) 32 | url = "#{Conf['captchaServiceDomain']}/in.php?key=#{encodeURIComponent key}&method=userrecaptcha&googlekey=<%= meta.recaptchaKey %>&pageurl=https://boards.4channel.org/v/" 33 | @req = CrossOrigin.ajax url, 34 | responseType: 'text' 35 | onloadend: => 36 | response = @req.response or '' 37 | parts = response.split('|') 38 | if parts[0] is 'OK' 39 | @requestID = parts[1] 40 | @interval = setInterval @poll.bind(@), 5 * $.SECOND 41 | else 42 | @noCaptcha() 43 | 44 | poll: -> 45 | key = Conf['captchaServiceKey'][Conf['captchaServiceDomain']] 46 | return @noCaptcha 'API key not set' unless key and /\S/.test(key) 47 | url = "#{Conf['captchaServiceDomain']}/res.php?key=#{encodeURIComponent key}&action=get&id=#{encodeURIComponent @requestID}" 48 | @req = CrossOrigin.ajax url, 49 | responseType: 'text' 50 | onloadend: => 51 | return unless @req.status 52 | response = @req.response or '' 53 | parts = response.split('|') 54 | if parts[0] is 'CAPCHA_NOT_READY' 55 | # pass 56 | else if parts[0] is 'OK' 57 | clearInterval @interval 58 | @saveCaptcha parts[1] 59 | else 60 | clearInterval @interval 61 | @noCaptcha() 62 | 63 | noCaptcha: (error) -> 64 | @pending = false 65 | return if @aborted 66 | error or= if @req.status is 200 67 | @req.response 68 | else if @req.status 69 | "#{@req.statusText} (#{@req.status})" 70 | else 71 | 'Connection Error' 72 | error = "Failed to retrieve captcha: #{error}" 73 | $.event 'NoCaptcha', {error} 74 | 75 | saveCaptcha: (response) -> 76 | @pending = false 77 | timeout = Date.now() + Captcha.v2.lifetime 78 | $.event 'SaveCaptcha', {response, timeout} 79 | -------------------------------------------------------------------------------- /src/Images/Volume.coffee: -------------------------------------------------------------------------------- 1 | Volume = 2 | init: -> 3 | return unless g.VIEW in ['index', 'thread'] and 4 | (Conf['Image Expansion'] or Conf['Image Hover'] or Conf['Image Hover in Catalog'] or Conf['Gallery']) 5 | 6 | $.sync 'Allow Sound', (x) -> 7 | Conf['Allow Sound'] = x 8 | Volume.inputs?.unmute.checked = x 9 | 10 | $.sync 'Default Volume', (x) -> 11 | Conf['Default Volume'] = x 12 | Volume.inputs?.volume.value = x 13 | 14 | if Conf['Mouse Wheel Volume'] 15 | Callbacks.Post.push 16 | name: 'Mouse Wheel Volume' 17 | cb: @node 18 | 19 | return if g.SITE.noAudio?(g.BOARD) 20 | 21 | if Conf['Mouse Wheel Volume'] 22 | Callbacks.CatalogThread.push 23 | name: 'Mouse Wheel Volume' 24 | cb: @catalogNode 25 | 26 | unmuteEntry = UI.checkbox 'Allow Sound', 'Allow Sound' 27 | unmuteEntry.title = Config.main['Images and Videos']['Allow Sound'][1] 28 | 29 | volumeEntry = $.el 'label', 30 | title: 'Default volume for videos.' 31 | $.extend volumeEntry, 32 | `<%= html(' Volume') %>` 33 | 34 | @inputs = 35 | unmute: unmuteEntry.firstElementChild 36 | volume: volumeEntry.firstElementChild 37 | 38 | $.on @inputs.unmute, 'change', $.cb.checked 39 | $.on @inputs.volume, 'change', $.cb.value 40 | 41 | Header.menu.addEntry {el: unmuteEntry, order: 200} 42 | Header.menu.addEntry {el: volumeEntry, order: 201} 43 | 44 | setup: (video) -> 45 | video.muted = !Conf['Allow Sound'] 46 | video.volume = Conf['Default Volume'] 47 | $.on video, 'volumechange', Volume.change 48 | 49 | change: -> 50 | {muted, volume} = @ 51 | items = 52 | 'Allow Sound': !muted 53 | 'Default Volume': volume 54 | for key, val of items when Conf[key] is val 55 | delete items[key] 56 | $.set items 57 | $.extend Conf, items 58 | if Volume.inputs 59 | Volume.inputs.unmute.checked = !muted 60 | Volume.inputs.volume.value = volume 61 | 62 | node: -> 63 | return if g.SITE.noAudio?(@board) 64 | for file in @files when file.isVideo 65 | $.on file.thumb, 'wheel', Volume.wheel.bind(Header.hover) if file.thumb 66 | $.on ($('.file-info', file.text) or file.link), 'wheel', Volume.wheel.bind(file.thumbLink) 67 | return 68 | 69 | catalogNode: -> 70 | file = @thread.OP.files[0] 71 | return unless file?.isVideo 72 | $.on @nodes.thumb, 'wheel', Volume.wheel.bind(Header.hover) 73 | 74 | wheel: (e) -> 75 | return if e.shiftKey or e.altKey or e.ctrlKey or e.metaKey 76 | return if not (el = $ 'video:not([data-md5])', @) 77 | return if el.muted or not $.hasAudio el 78 | volume = el.volume + 0.1 79 | volume *= 1.1 if e.deltaY < 0 80 | volume /= 1.1 if e.deltaY > 0 81 | el.volume = $.minmax volume - 0.1, 0, 1 82 | e.preventDefault() 83 | -------------------------------------------------------------------------------- /src/classes/Thread.coffee: -------------------------------------------------------------------------------- 1 | class Thread 2 | toString: -> @ID 3 | 4 | constructor: (ID, @board) -> 5 | @ID = +ID 6 | @threadID = @ID 7 | @boardID = @board.ID 8 | @siteID = g.SITE.ID 9 | @fullID = "#{@board}.#{@ID}" 10 | @posts = new SimpleDict() 11 | @isDead = false 12 | @isHidden = false 13 | @isSticky = false 14 | @isClosed = false 15 | @isArchived = false 16 | @postLimit = false 17 | @fileLimit = false 18 | @lastPost = 0 19 | @ipCount = undefined 20 | @json = null 21 | 22 | @OP = null 23 | @catalogView = null 24 | 25 | @nodes = 26 | root: null 27 | 28 | @board.threads.push @ID, @ 29 | g.threads.push @fullID, @ 30 | 31 | setPage: (pageNum) -> 32 | {info, reply} = @OP.nodes 33 | if not (icon = $ '.page-num', info) 34 | icon = $.el 'span', className: 'page-num' 35 | $.replace reply.parentNode.previousSibling, [$.tn(' '), icon, $.tn(' ')] 36 | icon.title = "This thread is on page #{pageNum} in the original index." 37 | icon.textContent = "[#{pageNum}]" 38 | @catalogView.nodes.pageCount.textContent = pageNum if @catalogView 39 | 40 | setCount: (type, count, reachedLimit) -> 41 | return unless @catalogView 42 | el = @catalogView.nodes["#{type}Count"] 43 | el.textContent = count 44 | (if reachedLimit then $.addClass else $.rmClass) el, 'warning' 45 | 46 | setStatus: (type, status) -> 47 | name = "is#{type}" 48 | return if @[name] is status 49 | @[name] = status 50 | return unless @OP 51 | @setIcon 'Sticky', @isSticky 52 | @setIcon 'Closed', @isClosed and !@isArchived 53 | @setIcon 'Archived', @isArchived 54 | 55 | setIcon: (type, status) -> 56 | typeLC = type.toLowerCase() 57 | icon = $ ".#{typeLC}Icon", @OP.nodes.info 58 | return if !!icon is status 59 | 60 | unless status 61 | $.rm icon.previousSibling 62 | $.rm icon 63 | $.rm $ ".#{typeLC}Icon", @catalogView.nodes.icons if @catalogView 64 | return 65 | icon = $.el 'img', 66 | src: "#{g.SITE.Build.staticPath}#{typeLC}#{g.SITE.Build.gifIcon}" 67 | alt: type 68 | title: type 69 | className: "#{typeLC}Icon retina" 70 | if g.BOARD.ID is 'f' 71 | icon.style.cssText = 'height: 18px; width: 18px;' 72 | 73 | root = if type isnt 'Sticky' and @isSticky 74 | $ '.stickyIcon', @OP.nodes.info 75 | else 76 | $('.page-num', @OP.nodes.info) or @OP.nodes.quote 77 | $.after root, [$.tn(' '), icon] 78 | 79 | return unless @catalogView 80 | (if type is 'Sticky' and @isClosed then $.prepend else $.add) @catalogView.nodes.icons, icon.cloneNode() 81 | 82 | kill: -> 83 | @isDead = true 84 | 85 | collect: -> 86 | n = 0 87 | @posts.forEach (post) -> 88 | if post.clones.length 89 | n++ 90 | else 91 | post.collect() 92 | unless n 93 | g.threads.rm @fullID 94 | @board.threads.rm @ 95 | -------------------------------------------------------------------------------- /src/Miscellaneous/FileInfo.coffee: -------------------------------------------------------------------------------- 1 | FileInfo = 2 | init: -> 3 | return if g.VIEW not in ['index', 'thread', 'archive'] or !Conf['File Info Formatting'] 4 | 5 | Callbacks.Post.push 6 | name: 'File Info Formatting' 7 | cb: @node 8 | 9 | node: -> 10 | return unless @file 11 | if @isClone 12 | for a in $$ '.file-info .download-button', @file.text 13 | $.on a, 'click', ImageCommon.download 14 | for a in $$ '.file-info .quick-filter-md5', @file.text 15 | $.on a, 'click', Filter.quickFilterMD5 16 | return 17 | 18 | oldInfo = $.el 'span', {className: 'fileText-original'} 19 | $.prepend @file.link.parentNode, oldInfo 20 | $.add oldInfo, [@file.link.previousSibling, @file.link, @file.link.nextSibling] 21 | 22 | info = $.el 'span', {className: 'file-info'} 23 | FileInfo.format Conf['fileInfo'], @, info 24 | $.prepend @file.text, info 25 | 26 | format: (formatString, post, outputNode) -> 27 | output = [] 28 | formatString.replace /%(.)|[^%]+/g, (s, c) -> 29 | output.push if $.hasOwn(FileInfo.formatters, c) 30 | FileInfo.formatters[c].call post 31 | else 32 | `<%= html('${s}') %>` 33 | '' 34 | $.extend outputNode, `<%= html('@{output}') %>` 35 | for a in $$ '.download-button', outputNode 36 | $.on a, 'click', ImageCommon.download 37 | for a in $$ '.quick-filter-md5', outputNode 38 | $.on a, 'click', Filter.quickFilterMD5 39 | return 40 | 41 | formatters: 42 | t: -> `<%= html('${this.file.url.match(/[^\/]*$/)[0]}') %>` 43 | T: -> `<%= html('&{FileInfo.formatters.t.call(this)}') %>` 44 | l: -> `<%= html('&{FileInfo.formatters.n.call(this)}') %>` 45 | L: -> `<%= html('&{FileInfo.formatters.N.call(this)}') %>` 46 | n: -> 47 | fullname = @file.name 48 | shortname = g.SITE.Build.shortFilename @file.name, @isReply 49 | if fullname is shortname 50 | `<%= html('${fullname}') %>` 51 | else 52 | `<%= html('${shortname}${fullname}') %>` 53 | N: -> `<%= html('${this.file.name}') %>` 54 | d: -> `<%= html('') %>` 55 | f: -> `<%= html('') %>` 56 | p: -> `<%= html('?{this.file.isSpoiler}{Spoiler, }') %>` 57 | s: -> `<%= html('${this.file.size}') %>` 58 | B: -> `<%= html('${Math.round(this.file.sizeInBytes)} Bytes') %>` 59 | K: -> `<%= html('${Math.round(this.file.sizeInBytes/1024)} KB') %>` 60 | M: -> `<%= html('${Math.round(this.file.sizeInBytes/1048576*100)/100} MB') %>` 61 | r: -> `<%= html('${this.file.dimensions || "PDF"}') %>` 62 | g: -> `<%= html('?{this.file.tag}{, ${this.file.tag}}{}') %>` 63 | '%': -> `<%= html('%') %>` 64 | -------------------------------------------------------------------------------- /template.jst: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 4chan X 5 | 6 | 7 | 8 | 9 | 18 | Screenshot 19 | <%= 20 | content 21 | .match(/<\/h1>([^]*)

)(.*?)(<\/h3>[^]*?)(?=