├── 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 |
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 | Report illegal content to archives
2 | Details
3 |
4 | Submit
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 |
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 |
3 |
4 |
5 |
15 |
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 |
2 | Guide
3 | General
4 | Post number
5 | Name
6 | Unique ID
7 | Tripcode
8 | Capcode
9 | Pass Date
10 | Email
11 | Subject
12 | Comment
13 | Flag
14 | Filename
15 | Image dimensions
16 | Filesize
17 | Image MD5
18 |
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 |
27 | }{
28 | ?{o.fileDeleted}{
29 |
30 |
31 |
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 (https://www.4chan.org/feedback) or ' +
33 | 'IRC (https://www.4chan-x.net/4chan-irc.html) .'
34 | ) %>`
35 |
--------------------------------------------------------------------------------
/src/General/Settings/Sauce.html:
--------------------------------------------------------------------------------
1 | Sauce is disabled.
2 |
3 |
4 |
[expand]
5 |
These parameters will be replaced by their corresponding values in the URL and displayed text:
6 |
7 | %IMG: Full image URL for GIF, JPG, and PNG; thumbnail URL for other types.
8 | %URL: Full image URL.
9 | %TURL: Thumbnail URL.
10 | %name: Original file name.
11 | %board: Current board.
12 | %MD5: MD5 hash in base64.
13 | %sMD5: MD5 hash in base64 using - and _.
14 | %hMD5: MD5 hash in hexadecimal.
15 | %$0: Matched regular expression within the filename.
16 | %$1, %$2, %$3, ... : Subexpressions within the matched regular expression.
17 | %%, %semi: Literal % and ;.
18 |
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 | — [Show ]
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | Index Sort
17 | Bump order
18 | Last reply
19 | Last long reply
20 | Creation date
21 | Reply count
22 | File count
23 |
24 |
25 | Image Size
26 | Small
27 | Large
28 |
29 |
30 | Index Mode
31 | Paged
32 | Infinite scrolling
33 | All threads
34 | Catalog
35 |
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}{ ${o.extra.xa19s} }
9 | ?{pass}{ }
10 | ?{capcode}{ ## ${capcode} }
11 | ?{email}{ }
12 | ?{boardID === "f" && !o.isReply || capcodeDescription}{}{ }
13 | ?{capcodeDescription}{ }
14 | ?{uniqueID && !capcode}{ (ID: ${uniqueID} ) }
15 | ?{flagCode}{ }
16 | ?{flagCodeTroll}{ }
17 |
18 |
${dateText}
19 |
20 | No.
21 | ${ID}
22 | ?{o.extra.xa19l && o.isReply}{ Like! ×${o.extra.xa19l} }
23 | ?{o.isSticky}{ }
24 | ?{o.isClosed && !o.isArchived}{ }
25 | ?{o.isArchived}{ }
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 |
19 | <%=
20 | content
21 | .match(/<\/h1>([^]*)