Main = init: -> # XXX dwb userscripts extension reloads scripts run at document-start when replaceState/pushState is called. # XXX Firefox reinjects WebExtension content scripts when extension is updated / reloaded. try w = window w = (w.wrappedJSObject or w) if $.platform is 'crx' return if '<%= meta.name %> antidup' of w w['<%= meta.name %> antidup'] = true if location.hostname is 'www.google.com' $.get 'Captcha Fixes', true, ({'Captcha Fixes': enabled}) -> if enabled $.ready -> Captcha.fixes.init() return # Don't run inside ad iframes. try return if window.frameElement and window.frameElement.src in ['', 'about:blank'] # Detect multiple copies of 4chan X return if doc and $.hasClass(doc, 'fourchan-x') $.asap docSet, -> $.addClass doc, 'fourchan-x', 'seaweedchan' $.addClass doc, "ua-#{$.engine}" if $.engine $.on d, '4chanXInitFinished', -> if Main.expectInitFinished delete Main.expectInitFinished else new Notice 'error', 'Error: Multiple copies of 4chan X are enabled.' $.addClass doc, 'tainted' # Flatten default values from Config into Conf flatten = (parent, obj) -> if obj instanceof Array Conf[parent] = obj[0] else if typeof obj is 'object' for key, val of obj flatten key, val else # string or number Conf[parent] = obj return # XXX Remove document-breaking ad if location.hostname in ['boards.4chan.org', 'boards.4channel.org'] $.global -> fromCharCode0 = String.fromCharCode String.fromCharCode = -> if document.body String.fromCharCode = fromCharCode0 else if document.currentScript and not document.currentScript.src throw Error() fromCharCode0.apply @, arguments $.asap docSet, -> $.onExists doc, 'iframe[srcdoc]', $.rm flatten null, Config for db in DataBoard.keys Conf[db] = {} Conf['customTitles'] = {'4chan.org': {boards: {'qa': {'boardTitle': {orig: '/qa/ - Question & Answer', title: '/qa/ - 2D / Random'}}}}} Conf['boardConfig'] = boards: {} Conf['archives'] = Redirect.archives Conf['selectedArchives'] = {} Conf['cooldowns'] = {} Conf['Index Sort'] = {} Conf["Last Long Reply Thresholds #{i}"] = {} for i in [0...2] Conf['siteProperties'] = {} # XXX old key names Conf['Except Archives from Encryption'] = false Conf['JSON Navigation'] = true Conf['Oekaki Links'] = true Conf['Show Name and Subject'] = false Conf['QR Shortcut'] = true Conf['Bottom QR Link'] = true Conf['Toggleable Thread Watcher'] = true Conf['siteSoftware'] = '' Conf['Use Faster Image Host'] = 'true' # Enforce JS whitelist if /\.4chan(?:nel)?\.org$/.test(location.hostname) and !$$('script:not([src])', d).filter((s) -> /this\[/.test(s.textContent)).length ($.getSync or $.get) {'jsWhitelist': Conf['jsWhitelist']}, ({jsWhitelist}) -> $.addCSP "script-src #{jsWhitelist.replace(/^#.*$/mg, '').replace(/[\s;]+/g, ' ').trim()}" # Get saved values as items items = {} items[key] = undefined for key of Conf items['previousversion'] = undefined ($.getSync or $.get) items, (items) -> if !$.perProtocolSettings and /\.4chan(?:nel)?\.org$/.test(location.hostname) and (items['Redirect to HTTPS'] ? Conf['Redirect to HTTPS']) and location.protocol isnt 'https:' location.replace('https://' + location.host + location.pathname + location.search + location.hash) return $.asap docSet, -> # Don't hide the local storage warning behind a settings panel. if $.cantSet # pass # Fresh install else if !items.previousversion? Main.isFirstRun = true Main.ready -> $.set 'previousversion', g.VERSION Settings.open() # Migrate old settings else if items.previousversion isnt g.VERSION Main.upgrade items # Combine default values with saved values for key, val of Conf Conf[key] = items[key] ? val Site.init Main.initFeatures upgrade: (items) -> {previousversion} = items changes = Settings.upgrade items, previousversion items.previousversion = changes.previousversion = g.VERSION $.set changes, -> if items['Show Updated Notifications'] ? true el = $.el 'span', `<%= html(meta.name + ' has been updated to version ${g.VERSION}.') %>` new Notice 'info', el, 15 parseURL: (site=g.SITE, url=location) -> r = {} return r if !site r.siteID = site.ID return r if site.isBoardlessPage?(url) pathname = url.pathname.split /\/+/ r.boardID = pathname[1] if site.isFileURL(url) r.VIEW = 'file' else if site.isAuxiliaryPage?(url) # pass else if pathname[2] in ['thread', 'res'] r.VIEW = 'thread' r.threadID = r.THREADID = +pathname[3].replace(/\.\w+$/, '') else if /^(?:catalog|archive)(?:\.\w+)?$/.test(pathname[2]) r.VIEW = pathname[2].replace(/\.\w+$/, '') else if /^(?:index|\d*)(?:\.\w+)?$/.test(pathname[2]) r.VIEW = 'index' r initFeatures: -> $.global -> document.documentElement.classList.add 'js-enabled' window.FCX = {} Main.jsEnabled = $.hasClass doc, 'js-enabled' # XXX https://bugs.chromium.org/p/chromium/issues/detail?id=920638 $.ajaxPageInit?() $.extend g, Main.parseURL() g.BOARD = new Board g.boardID if g.boardID if !g.VIEW g.SITE.initAuxiliary?() return if g.VIEW is 'file' $.asap (-> d.readyState isnt 'loading'), -> if g.SITE.software is 'yotsuba' and Conf['404 Redirect'] and g.SITE.is404?() pathname = location.pathname.split /\/+/ Redirect.navigate 'file', { boardID: g.BOARD.ID filename: pathname[pathname.length - 1] } else if video = $ 'video' if Conf['Volume in New Tab'] Volume.setup video if Conf['Loop in New Tab'] video.loop = true video.controls = false video.play() ImageCommon.addControls video return g.threads = new SimpleDict() g.posts = new SimpleDict() # set up CSS when is completely loaded $.onExists doc, 'body', Main.initStyle # c.time 'All initializations' for [name, feature] in Main.features continue if g.SITE.disabledFeatures and name in g.SITE.disabledFeatures # c.time "#{name} initialization" try feature.init() catch err Main.handleErrors message: "\"#{name}\" initialization crashed." error: err # finally # c.timeEnd "#{name} initialization" # c.timeEnd 'All initializations' $.ready Main.initReady initStyle: -> return if !Main.isThisPageLegit() # disable the mobile layout $('link[href*=mobile]', d.head)?.disabled = true doc.dataset.host = location.host $.addClass doc, "sw-#{g.SITE.software}" $.addClass doc, if g.VIEW is 'thread' then 'thread-view' else g.VIEW $.onExists doc, '.ad-cnt, .adg-rects > .desktop', (ad) -> $.onExists ad, 'img, iframe', -> $.addClass doc, 'ads-loaded' $.addClass doc, 'autohiding-scrollbar' if Conf['Autohiding Scrollbar'] $.ready -> if d.body.clientHeight > doc.clientHeight and (window.innerWidth is doc.clientWidth) isnt Conf['Autohiding Scrollbar'] Conf['Autohiding Scrollbar'] = !Conf['Autohiding Scrollbar'] $.set 'Autohiding Scrollbar', Conf['Autohiding Scrollbar'] $.toggleClass doc, 'autohiding-scrollbar' $.addStyle CSS.sub(CSS.boards), 'fourchanx-css' Main.bgColorStyle = $.el 'style', id: 'fourchanx-bgcolor-css' keyboard = false $.on d, 'mousedown', -> keyboard = false $.on d, 'keydown', (e) -> (keyboard = true if e.keyCode is 9) # tab window.addEventListener 'focus', (-> doc.classList.toggle 'keyboard-focus', keyboard), true Main.setClass() setClass: -> knownStyles = ['yotsuba', 'yotsuba-b', 'futaba', 'burichan', 'photon', 'tomorrow', 'spooky'] if g.SITE.software is 'yotsuba' and g.VIEW is 'catalog' if (mainStyleSheet = $.id('base-css')) style = mainStyleSheet.href.match(/catalog_(\w+)/)?[1].replace('_new', '').replace(/_+/g, '-') if style in knownStyles $.addClass doc, style return style = mainStyleSheet = styleSheets = null setStyle = -> # Use preconfigured CSS for 4chan's default themes. if g.SITE.software is 'yotsuba' $.rmClass doc, style style = null for styleSheet in styleSheets if styleSheet.href is mainStyleSheet?.href style = styleSheet.title.toLowerCase().replace('new', '').trim().replace /\s+/g, '-' style = styleSheet.href.match(/[a-z]*(?=[^/]*$)/)[0] if style is '_special' style = null unless style in knownStyles break if style $.addClass doc, style $.rm Main.bgColorStyle return # Determine proper dialog background color for other themes. div = g.SITE.bgColoredEl() div.style.position = 'absolute'; div.style.visibility = 'hidden'; $.add d.body, div bgColor = window.getComputedStyle(div).backgroundColor $.rm div rgb = bgColor.match(/[\d.]+/g) # Use body background if reply background is transparent unless /^rgb\(/.test(bgColor) s = window.getComputedStyle(d.body) bgColor = "#{s.backgroundColor} #{s.backgroundImage} #{s.backgroundRepeat} #{s.backgroundPosition}" css = """ .dialog, .suboption-list > div:last-of-type, :root.catalog-hover-expand .catalog-container:hover > .post { background: #{bgColor}; } .unread-mark-read { background-color: rgba(#{rgb[...3].join(', ')}, #{0.5*(rgb[3] || 1)}); } """ if $.luma(rgb) < 100 css += """ .watch-thread-link { background-image: url("data:image/svg+xml,"); } """ Main.bgColorStyle.textContent = css $.after $.id('fourchanx-css'), Main.bgColorStyle $.onExists d.head, g.SITE.selectors.styleSheet, (el) -> mainStyleSheet = el if g.SITE.software is 'yotsuba' styleSheets = $$ 'link[rel="alternate stylesheet"]', d.head new MutationObserver(setStyle).observe mainStyleSheet, { attributes: true attributeFilter: ['href'] } $.on mainStyleSheet, 'load', setStyle setStyle() unless mainStyleSheet for styleSheet in $$ 'link[rel="stylesheet"]', d.head $.on styleSheet, 'load', setStyle setStyle() initReady: -> if g.SITE.is404?() if g.VIEW is 'thread' ThreadWatcher.set404 g.BOARD.ID, g.THREADID, -> if Conf['404 Redirect'] Redirect.navigate 'thread', boardID: g.BOARD.ID threadID: g.THREADID postID: +location.hash.match /\d+/ # post number or 0 , "/#{g.BOARD}/" return if g.SITE.isIncomplete?() msg = $.el 'div', `<%= html('The page didn't load completely.
Some features may not work unless you reload.') %>` $.on $('a', msg), 'click', -> location.reload() new Notice 'warning', msg # Parse HTML or skip it and start building from JSON. if g.VIEW is 'catalog' Main.initCatalog() else if !Index.enabled Main.initThread() else Main.expectInitFinished = true $.event '4chanXInitFinished' initThread: -> s = g.SITE.selectors if (board = $ s.board) threads = [] posts = [] errors = [] Main.addThreadsObserver = new MutationObserver Main.addThreads Main.addPostsObserver = new MutationObserver Main.addPosts Main.addThreadsObserver.observe board, {childList: true} Main.parseThreads $$(s.thread, board), threads, posts, errors Main.handleErrors errors if errors.length if g.VIEW is 'thread' g.SITE.parseThreadMetadata?(threads[0]) Main.callbackNodes 'Thread', threads Main.callbackNodesDB 'Post', posts, -> QuoteThreading.insert post for post in posts Main.expectInitFinished = true $.event '4chanXInitFinished' else Main.expectInitFinished = true $.event '4chanXInitFinished' parseThreads: (threadRoots, threads, posts, errors) -> for threadRoot in threadRoots boardObj = if (boardID = threadRoot.dataset.board) boardID = encodeURIComponent boardID g.boards[boardID] or new Board(boardID) else g.BOARD threadID = +threadRoot.id.match(/\d*$/)[0] return if !threadID or boardObj.threads[threadID]?.nodes.root thread = new Thread threadID, boardObj thread.nodes.root = threadRoot threads.push thread postRoots = $$ g.SITE.selectors.postContainer, threadRoot postRoots.unshift threadRoot if g.SITE.isOPContainerThread Main.parsePosts postRoots, thread, posts, errors Main.addPostsObserver.observe threadRoot, {childList: true} parsePosts: (postRoots, thread, posts, errors) -> for postRoot in postRoots when !postRoot.dataset.fullID and $(g.SITE.selectors.comment, postRoot) try posts.push new Post postRoot, thread, thread.board catch err # Skip posts that we failed to parse. errors.push message: "Parsing of Post No.#{postRoot.id.match(/\d+/)} failed. Post will be skipped." error: err return addThreads: (records) -> threadRoots = [] for record in records for node in record.addedNodes when node.nodeType is Node.ELEMENT_NODE and node.matches(g.SITE.selectors.thread) threadRoots.push node return unless threadRoots.length threads = [] posts = [] errors = [] Main.parseThreads threadRoots, threads, posts, errors Main.handleErrors errors if errors.length Main.callbackNodes 'Thread', threads Main.callbackNodesDB 'Post', posts, -> $.event 'PostsInserted', null, records[0].target addPosts: (records) -> threads = [] threadsRM = [] posts = [] errors = [] for record in records thread = Get.threadFromRoot record.target postRoots = [] for node in record.addedNodes when node.nodeType is Node.ELEMENT_NODE if node.matches(g.SITE.selectors.postContainer) or (node = $(g.SITE.selectors.postContainer, node)) postRoots.push node n = posts.length Main.parsePosts postRoots, thread, posts, errors if posts.length > n and thread not in threads threads.push thread anyRemoved = false for el in record.removedNodes if Get.postFromRoot(el)?.nodes.root is el and !doc.contains(el) anyRemoved = true break if anyRemoved and thread not in threadsRM threadsRM.push thread Main.handleErrors errors if errors.length Main.callbackNodesDB 'Post', posts, -> for thread in threads $.event 'PostsInserted', null, thread.nodes.root for thread in threadsRM $.event 'PostsRemoved', null, thread.nodes.root return initCatalog: -> s = g.SITE.selectors.catalog if s and (board = $ s.board) threads = [] errors = [] Main.addCatalogThreadsObserver = new MutationObserver Main.addCatalogThreads Main.addCatalogThreadsObserver.observe board, {childList: true} Main.parseCatalogThreads $$(s.thread, board), threads, errors Main.handleErrors errors if errors.length Main.callbackNodes 'CatalogThreadNative', threads Main.expectInitFinished = true $.event '4chanXInitFinished' parseCatalogThreads: (threadRoots, threads, errors) -> for threadRoot in threadRoots try thread = new CatalogThreadNative threadRoot if thread.thread.catalogViewNative?.nodes.root isnt threadRoot thread.thread.catalogViewNative = thread threads.push thread catch err # Skip threads that we failed to parse. errors.push message: "Parsing of Catalog Thread No.#{(threadRoot.dataset.id or threadRoot.id).match(/\d+/)} failed. Thread will be skipped." error: err return addCatalogThreads: (records) -> threadRoots = [] for record in records for node in record.addedNodes when node.nodeType is Node.ELEMENT_NODE and node.matches(g.SITE.selectors.catalog.thread) threadRoots.push node return unless threadRoots.length threads = [] errors = [] Main.parseCatalogThreads threadRoots, threads, errors Main.handleErrors errors if errors.length Main.callbackNodes 'CatalogThreadNative', threads callbackNodes: (klass, nodes) -> i = 0 cb = Callbacks[klass] while node = nodes[i++] cb.execute node return callbackNodesDB: (klass, nodes, cb) -> i = 0 cbs = Callbacks[klass] fn = -> return false if not (node = nodes[i]) cbs.execute node ++i % 25 softTask = -> while fn() continue unless nodes[i] (cb() if cb) return setTimeout softTask, 0 softTask() handleErrors: (errors) -> # Detect conflicts with 4chan X v2 if d.body and $.hasClass(d.body, 'fourchan_x') and not $.hasClass(doc, 'tainted') new Notice 'error', 'Error: Multiple copies of 4chan X are enabled.' $.addClass doc, 'tainted' # Detect conflicts with native extension if g.SITE.testNativeExtension and not $.hasClass(doc, 'tainted') {enabled} = g.SITE.testNativeExtension() if enabled $.addClass doc, 'tainted' if Conf['Disable Native Extension'] and !Main.isFirstRun msg = $.el 'div', `<%= html('Failed to disable the native extension. You may need to block it.') %>` new Notice 'error', msg unless errors instanceof Array error = errors else if errors.length is 1 error = errors[0] if error new Notice 'error', Main.parseError(error, Main.reportLink([error])), 15 return div = $.el 'div', `<%= html('${errors.length} errors occurred.&{Main.reportLink(errors)} [show]') %>` $.on div.lastElementChild, 'click', -> [@textContent, logs.hidden] = if @textContent is 'show' then ( ['hide', false] ) else ( ['show', true] ) logs = $.el 'div', hidden: true for error in errors $.add logs, Main.parseError error new Notice 'error', [div, logs], 30 parseError: (data, reportLink) -> c.error data.message, data.error.stack message = $.el 'div', `<%= html('${data.message}?{reportLink}{&{reportLink}}') %>` error = $.el 'div', textContent: "#{data.error.name or 'Error'}: #{data.error.message or 'see console for details'}" lines = data.error.stack?.match(/\d+(?=:\d+\)?$)/mg)?.join().replace(/^/, ' at ') or '' context = $.el 'div', textContent: "(<%= meta.name %> <%= meta.fork %> v#{g.VERSION} #{$.platform} on #{$.engine}#{lines})" [message, error, context] reportLink: (errors) -> data = errors[0] title = data.message title += " (+#{errors.length - 1} other errors)" if errors.length > 1 details = '' addDetails = (text) -> unless encodeURIComponent(title + details + text + '\n').length > <%= meta.newIssueMaxLength - meta.newIssue.replace(/%(title|details)/, '').length %> details += text + '\n' addDetails """ [Please describe the steps needed to reproduce this error.] Script: <%= meta.name %> <%= meta.fork %> v#{g.VERSION} #{$.platform} User agent: #{navigator.userAgent} URL: #{location.href} """ addDetails '\n' + data.error addDetails data.error.stack.replace(data.error.toString(), '').trim() if data.error.stack addDetails '\n`' + data.html + '`' if data.html details = details.replace /file:\/{3}.+\//g, '' # Remove local file paths url = '<%= meta.newIssue %>'.replace('%title', encodeURIComponent title).replace('%details', encodeURIComponent details) `<%= html(' [report]') %>` isThisPageLegit: -> # not 404 error page or similar. unless 'thisPageIsLegit' of Main Main.thisPageIsLegit = if g.SITE.isThisPageLegit g.SITE.isThisPageLegit() else !/^[45]\d\d\b/.test(document.title) and !/\.json$/.test(location.pathname) Main.thisPageIsLegit ready: (cb) -> $.ready -> (cb() if Main.isThisPageLegit()) features: [ ['Polyfill', Polyfill] ['Board Configuration', BoardConfig] ['Normalize URL', NormalizeURL] ['Delay Redirect on Post', PostRedirect] ['Captcha Configuration', Captcha.replace] ['Image Host Rewriting', ImageHost] ['Redirect', Redirect] ['Header', Header] ['Catalog Links', CatalogLinks] ['Settings', Settings] ['Index Generator', Index] ['Disable Autoplay', AntiAutoplay] ['Announcement Hiding', PSAHiding] ['Fourchan thingies', Fourchan] ['Tinyboard Glue', Tinyboard] ['Color User IDs', IDColor] ['Highlight by User ID', IDHighlight] ['Count Posts by ID', IDPostCount] ['Custom CSS', CustomCSS] ['Thread Links', ThreadLinks] ['Linkify', Linkify] ['Reveal Spoilers', RemoveSpoilers] ['Resurrect Quotes', Quotify] ['Filter', Filter] ['Thread Hiding Buttons', ThreadHiding] ['Reply Hiding Buttons', PostHiding] ['Recursive', Recursive] ['Strike-through Quotes', QuoteStrikeThrough] ['Captcha Solving Service', Captcha.service] ['Quick Reply Personas', QR.persona] ['Quick Reply', QR] ['Cooldown', QR.cooldown] ['Post Jumper', PostJumper] ['Pass Link', PassLink] ['Menu', Menu] ['Index Generator (Menu)', Index.menu] ['Report Link', ReportLink] ['Copy Text Link', CopyTextLink] ['Thread Hiding (Menu)', ThreadHiding.menu] ['Reply Hiding (Menu)', PostHiding.menu] ['Delete Link', DeleteLink] ['Filter (Menu)', Filter.menu] ['Edit Link', QR.oekaki.menu] ['Download Link', DownloadLink] ['Archive Link', ArchiveLink] ['Quote Inlining', QuoteInline] ['Quote Previewing', QuotePreview] ['Quote Backlinks', QuoteBacklink] ['Mark Quotes of You', QuoteYou] ['Mark OP Quotes', QuoteOP] ['Mark Cross-thread Quotes', QuoteCT] ['Anonymize', Anonymize] ['Time Formatting', Time] ['Relative Post Dates', RelativeDates] ['File Info Formatting', FileInfo] ['Fappe Tyme', FappeTyme] ['Gallery', Gallery] ['Gallery (menu)', Gallery.menu] ['Sauce', Sauce] ['Image Expansion', ImageExpand] ['Image Expansion (Menu)', ImageExpand.menu] ['Reveal Spoiler Thumbnails', RevealSpoilers] ['Image Loading', ImageLoader] ['Image Hover', ImageHover] ['Volume Control', Volume] ['WEBM Metadata', Metadata] ['Comment Expansion', ExpandComment] ['Thread Expansion', ExpandThread] ['Favicon', Favicon] ['Unread', Unread] ['Unread Line in Index', UnreadIndex] ['Quote Threading', QuoteThreading] ['Thread Stats', ThreadStats] ['Thread Updater', ThreadUpdater] ['Thread Watcher', ThreadWatcher] ['Thread Watcher (Menu)', ThreadWatcher.menu] ['Mark New IPs', MarkNewIPs] ['Index Navigation', Nav] ['Keybinds', Keybinds] ['Banner', Banner] ['Flash Features', Flash] ['Reply Pruning', ReplyPruning] ['Mod Contact Links', ModContact] <% if (readJSON('/.tests_enabled')) { %> ['Build Test', Test] <% } %> ]