diff --git a/Makefile b/Makefile
index b2152c72e..a8268f3fb 100644
--- a/Makefile
+++ b/Makefile
@@ -32,7 +32,7 @@ $(eval $(shell node tools/pkgvars.js))
version = $(shell node -p "JSON.parse(require('fs').readFileSync('version.json')).version")
source_directories := \
- globals config css platform classes \
+ globals config css platform classes site \
Archive Filtering General Images Linkification \
Menu Miscellaneous Monitoring Posting Quotelinks \
main
diff --git a/src/Filtering/PostHiding.coffee b/src/Filtering/PostHiding.coffee
index 586aab3ca..ebabee867 100644
--- a/src/Filtering/PostHiding.coffee
+++ b/src/Filtering/PostHiding.coffee
@@ -24,9 +24,14 @@ PostHiding =
Recursive.add PostHiding.hide, @, data.makeStub, true
return unless Conf['Reply Hiding Buttons']
- sideArrows = $('.sideArrows', @nodes.root)
- $.replace sideArrows.firstChild, PostHiding.makeButton @, 'hide'
- sideArrows.removeAttribute 'class'
+
+ button = PostHiding.makeButton @, 'hide'
+ if (sa = Site.selectors.sideArrows)
+ sideArrows = $ sa, @nodes.root
+ $.replace sideArrows.firstChild, button
+ sideArrows.removeAttribute 'class'
+ else
+ $.prepend @nodes.root, button
menu:
init: ->
diff --git a/src/General/BoardConfig.coffee b/src/General/BoardConfig.coffee
index 2e1a5b9ad..da7353867 100644
--- a/src/General/BoardConfig.coffee
+++ b/src/General/BoardConfig.coffee
@@ -43,5 +43,6 @@ BoardConfig =
board for board, data of (@boards or Conf['boardConfig'].boards) when !!data.ws_board is sfw
noAudio: (boardID) ->
+ return false unless Site.software is 'yotsuba'
boards = @boards or Conf['boardConfig'].boards
boards and !boards[boardID].webm_audio
diff --git a/src/General/Get.coffee b/src/General/Get.coffee
index bad4c3752..b9ff94909 100644
--- a/src/General/Get.coffee
+++ b/src/General/Get.coffee
@@ -10,25 +10,24 @@ Get =
excerpt
threadFromRoot: (root) ->
return null unless root?
- g.threads["#{g.BOARD}.#{root.id[1..]}"]
+ g.threads["#{g.BOARD}.#{root.id.match(/\d*$/)[0]}"]
threadFromNode: (node) ->
- Get.threadFromRoot $.x 'ancestor-or-self::div[contains(concat(" ",@class," ")," thread ")]', node
+ Get.threadFromRoot $.x "ancestor-or-self::#{Site.xpath.thread}", node
postFromRoot: (root) ->
return null unless root?
post = g.posts[root.dataset.fullID]
index = root.dataset.clone
if index then post.clones[index] else post
postFromNode: (root) ->
- Get.postFromRoot $.x 'ancestor-or-self::div[contains(@class,"postContainer")][1]', root
+ Get.postFromRoot $.x "ancestor-or-self::#{Site.xpath.postContainer}[1]", root
postDataFromLink: (link) ->
- if link.hostname is 'boards.4chan.org'
- path = link.pathname.split /\/+/
- boardID = path[1]
- threadID = path[3]
- postID = if link.hash then link.hash[2..] else path[3]
- else # resurrected quote
+ if link.dataset.postID # resurrected quote
{boardID, threadID, postID} = link.dataset
threadID or= 0
+ else
+ match = link.href.match Site.regexp.quotelink
+ [boardID, threadID, postID] = match[1..]
+ postID or= threadID
return {
boardID: boardID
threadID: +threadID
@@ -64,8 +63,3 @@ Get =
quotelinks.filter (quotelink) ->
{boardID, postID} = Get.postDataFromLink quotelink
boardID is post.board.ID and postID is post.ID
-
- scriptData: ->
- for script in $$ 'script:not([src])', d.head
- return script.textContent if /\bcooldowns *=/.test script.textContent
- ''
diff --git a/src/General/Header.coffee b/src/General/Header.coffee
index f2212737f..27923d924 100644
--- a/src/General/Header.coffee
+++ b/src/General/Header.coffee
@@ -84,15 +84,15 @@ Header =
$.on window, 'load popstate', Header.hashScroll
$.on d, 'CreateNotification', @createNotification
+ @setBoardList()
+
$.onExists doc, 'body', =>
return unless Main.isThisPageLegit()
$.prepend d.body, @bar
$.add d.body, Header.hover
@setBarPosition Conf['Bottom Header']
- # Wait for #boardNavMobile instead of #boardNavDesktop,
- # it might be incomplete otherwise.
- $.onExists doc, '#boardNavMobile', Header.setBoardList
+ $.onExists doc, "#{Site.selectors.boardList} + *", Header.generateFullBoardList
Main.ready ->
if not (footer = $.id 'boardNavDesktopFoot')
@@ -154,9 +154,20 @@ Header =
btn = $('.hide-board-list-button', boardList)
$.on btn, 'click', Header.toggleBoardList
+ $.add Header.bar, [Header.boardList, Header.shortcuts, Header.noticesRoot, Header.toggle]
+
+ Header.setCustomNav Conf['Custom Board Navigation']
+ Header.generateBoardList Conf['boardnav']
+
+ $.sync 'Custom Board Navigation', Header.setCustomNav
+ $.sync 'boardnav', Header.generateBoardList
+
+ generateFullBoardList: ->
nodes = []
spacer = -> $.el 'span', className: 'spacer'
- for node in $('#boardNavDesktop > .boardList').childNodes
+ items = $.X './/a|.//text()[not(ancestor::a)]', $(Site.selectors.boardList)
+ i = 0
+ while node = items.snapshotItem i++
switch node.nodeName
when '#text'
for chr in node.nodeValue
@@ -169,18 +180,10 @@ Header =
a = node.cloneNode true
a.className = 'current' if a.pathname.split('/')[1] is g.BOARD.ID
nodes.push a
- fullBoardList = $ '.boardList', boardList
+ fullBoardList = $ '.boardList', Header.boardList
$.add fullBoardList, nodes
CatalogLinks.setLinks fullBoardList
- $.add Header.bar, [Header.boardList, Header.shortcuts, Header.noticesRoot, Header.toggle]
-
- Header.setCustomNav Conf['Custom Board Navigation']
- Header.generateBoardList Conf['boardnav']
-
- $.sync 'Custom Board Navigation', Header.setCustomNav
- $.sync 'boardnav', Header.generateBoardList
-
generateBoardList: (boardnav) ->
list = $ '#custom-board-list', Header.boardList
$.rmAll list
@@ -224,7 +227,17 @@ Header =
return a
boardID = t.split('-')[0]
- boardID = g.BOARD.ID if boardID is 'current'
+ if boardID is 'current'
+ if location.hostname is 'boards.4chan.org'
+ boardID = g.BOARD.ID
+ else
+ a = $.el 'a',
+ href: "/#{g.BOARD.ID}/"
+ textContent: text or g.BOARD.ID
+ className: 'current'
+ if /-(catalog|archive|expired)/.test(t)
+ a = a.firstChild # Its text node.
+ return a
a = do ->
if boardID is '@'
@@ -237,13 +250,13 @@ Header =
return a.cloneNode true
a = $.el 'a',
- href: "/#{boardID}/"
+ href: "//boards.4chan.org/#{boardID}/"
textContent: boardID
a.href += g.VIEW if g.VIEW in ['catalog', 'archive']
- a.className = 'current' if boardID is g.BOARD.ID
+ a.className = 'current' if a.hostname is location.hostname and boardID is g.BOARD.ID
a
- a.textContent = if /-title/.test(t) or /-replace/.test(t) and boardID is g.BOARD.ID
+ a.textContent = if /-title/.test(t) or /-replace/.test(t) and a.hostname is location.hostname and boardID is g.BOARD.ID
a.title or a.textContent
else if /-full/.test t
("/#{boardID}/") + (if a.title then " - #{a.title}" else '')
@@ -271,7 +284,7 @@ Header =
if /-expired/.test t
if boardID not in ['b', 'f', 'trash', 'bant']
- a.href = "/#{boardID}/archive"
+ a.href = "//boards.4chan.org/#{boardID}/archive"
else
return a.firstChild # Its text node.
diff --git a/src/General/Settings.coffee b/src/General/Settings.coffee
index 3bc937944..02b05d400 100644
--- a/src/General/Settings.coffee
+++ b/src/General/Settings.coffee
@@ -169,7 +169,7 @@ Settings =
# Unsupported options
if $.engine isnt 'gecko'
$('div[data-name="Remember QR Size"]', section).hidden = true
- if $.perProtocolSettings
+ if $.perProtocolSettings or location.protocol isnt 'https:'
$('div[data-name="Redirect to HTTPS"]', section).hidden = true
$.get items, (items) ->
diff --git a/src/Images/Gallery.coffee b/src/Images/Gallery.coffee
index 8f1a9e9ef..3e8aae6db 100644
--- a/src/Images/Gallery.coffee
+++ b/src/Images/Gallery.coffee
@@ -82,8 +82,8 @@ Gallery =
$.on window, 'resize', Gallery.cb.setHeight
- for file in $$ '.post .file'
- post = Get.postFromNode file
+ for postThumb in $$ Site.selectors.file.thumb
+ post = Get.postFromNode postThumb
continue unless post.file?.thumb
Gallery.generateThumb post
# If no image to open is given, pick image we have scrolled to.
diff --git a/src/Images/ImageHover.coffee b/src/Images/ImageHover.coffee
index 91cc64dcc..282dc0bf9 100644
--- a/src/Images/ImageHover.coffee
+++ b/src/Images/ImageHover.coffee
@@ -23,7 +23,7 @@ ImageHover =
return unless doc.contains @
{file} = post
{isVideo} = file
- return if file.isExpanding or file.isExpanded
+ return if file.isExpanding or file.isExpanded or Site.isThumbExpanded?(file)
error = ImageHover.error post
if ImageCommon.cache?.dataset.fullID is post.fullID
el = ImageCommon.popCache()
diff --git a/src/Miscellaneous/CatalogLinks.coffee b/src/Miscellaneous/CatalogLinks.coffee
index a77cb2c1c..c430f69a4 100644
--- a/src/Miscellaneous/CatalogLinks.coffee
+++ b/src/Miscellaneous/CatalogLinks.coffee
@@ -78,12 +78,12 @@ CatalogLinks =
if Conf['External Catalog'] and board in ['a', 'c', 'g', 'biz', 'k', 'm', 'o', 'p', 'v', 'vg', 'vr', 'w', 'wg', 'cm', '3', 'adv', 'an', 'asp', 'cgl', 'ck', 'co', 'diy', 'fa', 'fit', 'gd', 'int', 'jp', 'lit', 'mlp', 'mu', 'n', 'out', 'po', 'sci', 'sp', 'tg', 'toy', 'trv', 'tv', 'vp', 'wsg', 'x', 'f', 'pol', 's4s', 'lgbt']
"//catalog.neet.tv/#{board}/"
else if Conf['JSON Index'] and Conf['Use <%= meta.name %> Catalog']
- if g.BOARD.ID is board and g.VIEW is 'index' then '#catalog' else "/#{board}/#catalog"
+ if location.hostname is 'boards.4chan.org' and g.BOARD.ID is board and g.VIEW is 'index' then '#catalog' else "//boards.4chan.org/#{board}/#catalog"
else
- "/#{board}/catalog"
+ "//boards.4chan.org/#{board}/catalog"
index: (board=g.BOARD.ID) ->
if Conf['JSON Index'] and board isnt 'f'
- if g.BOARD.ID is board and g.VIEW is 'index' then '#index' else "/#{board}/#index"
+ if location.hostname is 'boards.4chan.org' and g.BOARD.ID is board and g.VIEW is 'index' then '#index' else "//boards.4chan.org/#{board}/#index"
else
- "/#{board}/"
+ "//boards.4chan.org/#{board}/"
diff --git a/src/Posting/QR.coffee b/src/Posting/QR.coffee
index 0fc048fc9..fa3830119 100644
--- a/src/Posting/QR.coffee
+++ b/src/Posting/QR.coffee
@@ -326,7 +326,7 @@ QR =
$.replace node, $.tn '\n'
for node in $$ 'br', frag
$.replace node, $.tn '\n>' unless node is frag.lastChild
- Post::insertTags frag
+ Site.insertTags?(frag)
for node in $$ '.linkify[data-original]', frag
$.replace node, $.tn node.dataset.original
for node in $$ '.embedder', frag
diff --git a/src/classes/DataBoard.coffee b/src/classes/DataBoard.coffee
index 84d150bfd..7429a370e 100644
--- a/src/classes/DataBoard.coffee
+++ b/src/classes/DataBoard.coffee
@@ -2,7 +2,7 @@ class DataBoard
@keys = ['hiddenThreads', 'hiddenPosts', 'lastReadPosts', 'yourPosts', 'watchedThreads', 'customTitles']
constructor: (@key, sync, dontClean) ->
- @data = Conf[@key]
+ @initData Conf[@key]
$.sync @key, @onSync
@clean() unless dontClean
return unless sync
@@ -13,28 +13,34 @@ class DataBoard
@sync = sync
$.on d, '4chanXInitFinished', init
+ initData: (@allData) ->
+ if Site.hostname is '4chan.org' and @allData.boards
+ @data = @allData
+ else
+ @data = (@allData[Site.hostname] or= boards: {})
+
changes: []
save: (change, cb) ->
- snapshot1 = JSON.stringify @data
+ snapshot1 = JSON.stringify @allData
change()
{changes} = @
changes.push change
$.get @key, {boards: {}}, (items) =>
- @data = items[@key]
- snapshot2 = JSON.stringify @data
+ @initData items[@key]
+ snapshot2 = JSON.stringify @allData
c() for c in changes
- $.set @key, @data, =>
+ $.set @key, @allData, =>
@changes = []
@sync?() if snapshot1 isnt snapshot2
cb?()
forceSync: (cb) ->
- snapshot1 = JSON.stringify @data
+ snapshot1 = JSON.stringify @allData
{changes} = @
$.get @key, {boards: {}}, (items) =>
- @data = items[@key]
- snapshot2 = JSON.stringify @data
+ @initData items[@key]
+ snapshot2 = JSON.stringify @allData
c() for c in changes
@sync?() if snapshot1 isnt snapshot2
cb?()
@@ -103,6 +109,9 @@ class DataBoard
val or defaultValue
clean: ->
+ # XXX not yet multisite ready
+ return unless Site.software is 'yotsuba'
+
for boardID, val of @data.boards
@deleteIfEmpty {boardID}
@@ -133,8 +142,8 @@ class DataBoard
threads[ID] = board[ID] if ID of board
@data.boards[boardID] = threads
@deleteIfEmpty {boardID}
- $.set @key, @data
+ $.set @key, @allData
onSync: (data) =>
- @data = data or boards: {}
+ @initData data
@sync?()
diff --git a/src/classes/Post.Clone.coffee b/src/classes/Post.Clone.coffee
index 154348b62..1fd0c9a66 100644
--- a/src/classes/Post.Clone.coffee
+++ b/src/classes/Post.Clone.coffee
@@ -11,10 +11,10 @@ Post.Clone = class extends Post
@cloneWithoutVideo nodes.root
else
nodes.root.cloneNode true
- Post.Clone.prefix or= 0
+ Post.Clone.suffix or= 0
for node in [root, $$('[id]', root)...]
- node.id = Post.Clone.prefix + node.id
- Post.Clone.prefix++
+ node.id += "_#{Post.Clone.suffix}"
+ Post.Clone.suffix++
# Remove inlined posts inside of this post.
for inline in $$ '.inline', root
@@ -44,12 +44,10 @@ Post.Clone = class extends Post
@file = {}
for key, val of @origin.file
@file[key] = val
- {fileRoot} = @nodes
- @file.text = fileRoot.firstElementChild
- @file.link = $ '.fileText > a, .fileText-original', fileRoot
- @file.thumb = $ 'a.fileThumb > [data-md5]', fileRoot
+ for key, selector of Site.selectors.file
+ @file[key] = $ selector, @nodes.root
@file.thumbLink = @file.thumb?.parentNode
- @file.fullImage = $ '.full-image', fileRoot
+ @file.fullImage = $ '.full-image', @file.thumbLink if @file.thumbLink
@file.videoControls = $ '.video-controls', @file.text
@file.thumb.muted = true if @file.videoThumb
diff --git a/src/classes/Post.coffee b/src/classes/Post.coffee
index a7389bab5..b8f9282ad 100644
--- a/src/classes/Post.coffee
+++ b/src/classes/Post.coffee
@@ -6,7 +6,7 @@ class Post
@normalizedOriginal = Build.Test.normalize root
<% } %>
- @ID = +root.id[2..]
+ @ID = +root.id.match(/\d*$/)[0]
@threadID = @thread.ID
@boardID = @board.ID
@fullID = "#{@board}.#{@ID}"
@@ -16,15 +16,13 @@ class Post
@nodes = @parseNodes root
- if not (@isReply = $.hasClass @nodes.post, 'reply')
+ if not (@isReply = @ID isnt @threadID)
@thread.OP = @
- if @boardID is 'f'
- for type in ['Sticky', 'Closed'] when (icon = $ "img[alt=#{type}]", @nodes.info)
- $.addClass icon, "#{type.toLowerCase()}Icon", 'retina'
- @thread.isArchived = !!$ '.archivedIcon', @nodes.info
- @thread.isSticky = !!$ '.stickyIcon', @nodes.info
- @thread.isClosed = @thread.isArchived or !!$ '.closedIcon', @nodes.info
- @thread.kill() if @thread.isArchived
+ for key in ['isSticky', 'isClosed', 'isArchived']
+ @thread[key] = if (selector = Site.selectors.icons[key]) then !!$(selector, @nodes.info) else false
+ if @thread.isArchived
+ @thread.isClosed = true
+ @thread.kill()
@info =
subject: @nodes.subject?.textContent or undefined
@@ -36,7 +34,7 @@ class Post
flagCode: @nodes.flag?.className.match(/flag-(\w+)/)?[1].toUpperCase()
flagCodeTroll: @nodes.flag?.src?.match(/(\w+)\.gif$/)?[1].toUpperCase()
flag: @nodes.flag?.title
- date: if @nodes.date then new Date(@nodes.date.dataset.utc * 1000)
+ date: if @nodes.date then new Date(@nodes.date.getAttribute('datetime')?.trim() or (@nodes.date.dataset.utc * 1000))
if Conf['Anonymize']
@info.nameBlock = 'Anonymous'
@@ -66,30 +64,21 @@ class Post
g.posts.push @fullID, @
parseNodes: (root) ->
- post = $ '.post', root
- info = $ '.postInfo', post
+ s = Site.selectors
+ post = $(s.post, root) or root
+ info = $ s.infoRoot, post
nodes =
- root: root
- post: post
- info: info
- subject: $ '.subject', info
- name: $ '.name', info
- email: $ '.useremail', info
- tripcode: $ '.postertrip', info
- uniqueIDRoot: $ '.posteruid', info
- uniqueID: $ '.posteruid > .hand', info
- capcode: $ '.capcode.hand', info
- pass: $ '.n-pu', info
- flag: $ '.flag, .countryFlag', info
- date: $ '.dateTime', info
- nameBlock: $ '.nameBlock', info
- quote: $ '.postNum > a:nth-of-type(2)', info
- reply: $ '.replylink', info
- fileRoot: $ '.file', post
- comment: $ '.postMessage', post
- quotelinks: []
+ root: root
+ post: post
+ info: info
+ comment: $ s.comment, post
+ quotelinks: []
archivelinks: []
embedlinks: []
+ for key, selector of s.info
+ nodes[key] = $ selector, info
+ Site.parseNodes?(@, nodes)
+ nodes.uniqueIDRoot or= nodes.uniqueID
# XXX Edge invalidates HTMLCollections when an ancestor node is inserted into another node.
# https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7560353/
@@ -113,7 +102,7 @@ class Post
# 'Comment too long'...
# EXIF data. (/p/)
@nodes.commentClean = bq = @nodes.comment.cloneNode true
- @cleanComment bq
+ Site.cleanComment?(bq)
@info.comment = @nodesToText bq
commentDisplay: ->
@@ -126,13 +115,13 @@ class Post
# Trailing spaces.
bq = @nodes.commentClean.cloneNode true
@cleanSpoilers bq unless Conf['Remove Spoilers'] or Conf['Reveal Spoilers']
- @cleanCommentDisplay bq
+ Site.cleanCommentDisplay?(bq)
@nodesToText(bq).trim().replace(/\s+$/gm, '')
commentOrig: ->
# Get the comment's text for reposting purposes.
bq = @nodes.commentClean.cloneNode true
- @insertTags bq
+ Site.insertTags?(bq)
@nodesToText bq
nodesToText: (bq) ->
@@ -143,36 +132,15 @@ class Post
text += node.data or '\n'
text
- cleanComment: (bq) ->
- if (abbr = $ '.abbr', bq) # 'Comment too long' or 'EXIF data available'
- for node in $$ '.abbr + br, .exif', bq
- $.rm node
- for i in [0...2]
- $.rm br if (br = abbr.previousSibling) and br.nodeName is 'BR'
- $.rm abbr
-
cleanSpoilers: (bq) ->
- spoilers = $$ 's', bq
+ spoilers = $$ Site.selectors.spoiler, bq
for node in spoilers
$.replace node, $.tn '[spoiler]'
return
- cleanCommentDisplay: (bq) ->
- $.rm b if (b = $ 'b', bq) and /^Rolled /.test(b.textContent)
- $.rm $('.fortune', bq)
-
- insertTags: (bq) ->
- for node in $$ 's, .removed-spoiler', bq
- $.replace node, [$.tn('[spoiler]'), node.childNodes..., $.tn '[/spoiler]']
- for node in $$ '.prettyprint', bq
- $.replace node, [$.tn('[code]'), node.childNodes..., $.tn '[/code]']
- return
-
parseQuotes: ->
@quotes = []
- # XXX https://github.com/4chan/4chan-JS/issues/77
- # 4chan currently creates quote links inside [code] tags; ignore them
- for quotelink in $$ ':not(pre) > .quotelink', @nodes.comment
+ for quotelink in $$ Site.selectors.quotelink, @nodes.comment
@parseQuote quotelink
return
@@ -183,13 +151,7 @@ class Post
# - catalog links. (>>>/b/catalog or >>>/b/search)
# - rules links. (>>>/a/rules)
# - text-board quotelinks. (>>>/img/1234)
- match = quotelink.href.match ///
- ^https?://boards\.4chan\.org/+
- ([^/]+) # boardID
- /+(?:res|thread)/+\d+(?:[/?][^#]*)?#p
- (\d+) # postID
- $
- ///
+ match = quotelink.href.match Site.regexp.quotelink
return unless match or (@isClone and quotelink.dataset.postID) # normal or resurrected quote
@nodes.quotelinks.push quotelink
@@ -197,39 +159,28 @@ class Post
return if @isClone
# ES6 Set when?
- fullID = "#{match[1]}.#{match[2]}"
+ fullID = "#{match[1]}.#{match[3]}"
@quotes.push fullID unless fullID in @quotes
parseFile: ->
- {fileRoot} = @nodes
- return unless fileRoot
- return if not (link = $ '.fileText > a, .fileText-original > a', fileRoot)
- return if not (info = link.nextSibling?.textContent.match /\(([\d.]+ [KMG]?B).*\)/)
- fileText = fileRoot.firstElementChild
- @file =
- text: fileText
- link: link
- url: link.href
- name: fileText.title or link.title or link.textContent
- size: info[1]
- isImage: /(jpg|png|gif)$/i.test link.href
- isVideo: /webm$/i.test link.href
- dimensions: info[0].match(/\d+x\d+/)?[0]
- tag: info[0].match(/,[^,]*, ([a-z]+)\)/i)?[1]
- MD5: fileText.dataset.md5
- size = +@file.size.match(/[\d.]+/)[0]
- unit = ['B', 'KB', 'MB', 'GB'].indexOf @file.size.match(/\w+$/)[0]
+ file = {}
+ for key, selector of Site.selectors.file
+ file[key] = $ selector, @nodes.root
+ file.thumbLink = file.thumb?.parentNode
+
+ return if not (file.text and file.link)
+ return if not Site.parseFile @, file
+
+ $.extend file,
+ url: file.link.href
+ isImage: /(jpg|png|gif)$/i.test file.link.href
+ isVideo: /(webm|mp4)$/i.test file.link.href
+ size = +file.size.match(/[\d.]+/)[0]
+ unit = ['B', 'KB', 'MB', 'GB'].indexOf file.size.match(/\w+$/)[0]
size *= 1024 while unit-- > 0
- @file.sizeInBytes = size
- if (thumb = $ 'a.fileThumb > [data-md5]', fileRoot)
- $.extend @file,
- thumb: thumb
- thumbLink: thumb.parentNode
- thumbURL: thumb.src
- MD5: thumb.dataset.md5
- isSpoiler: $.hasClass thumb.parentNode, 'imgspoiler'
- if @file.isSpoiler
- @file.thumbURL = if (m = link.href.match /\d+(?=\.\w+$)/) then "#{location.protocol}//#{ImageHost.thumbHost()}/#{@board}/#{m[0]}s.jpg"
+ file.sizeInBytes = size
+
+ @file = file
@deadMark =
# \u00A0 is nbsp
diff --git a/src/config/Config.coffee b/src/config/Config.coffee
index 8f774cf84..4e51edb03 100644
--- a/src/config/Config.coffee
+++ b/src/config/Config.coffee
@@ -1119,3 +1119,7 @@ Config =
'updater.position': 'bottom: 0px; left: 0px;'
'thread-watcher.position': 'top: 50px; left: 0px;'
'qr.position': 'top: 50px; right: 0px;'
+
+ siteSoftware: """
+ 4chan.org yotsuba
+ """
diff --git a/src/css/style.css b/src/css/style.css
index 794e93571..48e33dede 100644
--- a/src/css/style.css
+++ b/src/css/style.css
@@ -1272,7 +1272,7 @@ span.hide-announcement {
.expanded-image > .post > .file > .fileThumb > img[data-md5] {
display: none;
}
-.full-image {
+.full-image[data-full-i-d] {
display: none;
cursor: pointer;
}
diff --git a/src/main/Main.coffee b/src/main/Main.coffee
index 5b28c4b6c..25df930db 100644
--- a/src/main/Main.coffee
+++ b/src/main/Main.coffee
@@ -56,7 +56,7 @@ Main =
flatten null, Config
for db in DataBoard.keys
- Conf[db] = boards: {}
+ Conf[db] = {}
Conf['boardConfig'] = boards: {}
Conf['archives'] = Redirect.archives
Conf['selectedArchives'] = {}
@@ -74,15 +74,16 @@ Main =
Conf['Toggleable Thread Watcher'] = true
# Enforce JS whitelist
- ($.getSync or $.get) {'jsWhitelist': Conf['jsWhitelist']}, ({jsWhitelist}) ->
- $.addCSP "script-src #{jsWhitelist.replace(/^#.*$/mg, '').replace(/[\s;]+/g, ' ').trim()}"
+ if /\.4chan\.org$/.test(location.hostname)
+ ($.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 (items['Redirect to HTTPS'] ? Conf['Redirect to HTTPS']) and location.protocol isnt 'https:'
+ if !$.perProtocolSettings and /\.4chan\.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, ->
@@ -105,7 +106,7 @@ Main =
for key, val of Conf
Conf[key] = items[key] ? val
- Main.initFeatures()
+ Site.init Main.initFeatures
upgrade: (items) ->
{previousversion} = items
@@ -122,11 +123,10 @@ Main =
pathname = location.pathname.split /\/+/
g.BOARD = new Board pathname[1] unless hostname is 'www.4chan.org'
- if hostname in ['boards.4chan.org', 'sys.4chan.org', 'www.4chan.org']
- $.global ->
- document.documentElement.classList.add 'js-enabled'
- window.FCX = {}
- Main.jsEnabled = $.hasClass doc, 'js-enabled'
+ $.global ->
+ document.documentElement.classList.add 'js-enabled'
+ window.FCX = {}
+ Main.jsEnabled = $.hasClass doc, 'js-enabled'
switch hostname
when 'www.4chan.org'
@@ -151,7 +151,7 @@ Main =
if ImageHost.test hostname
return unless pathname[2] and not /[sm]\.jpg$/.test(pathname[2])
$.asap (-> d.readyState isnt 'loading'), ->
- if Conf['404 Redirect'] and d.title in ['4chan - Temporarily Offline', '4chan - 404 Not Found']
+ if Conf['404 Redirect'] and Site.is404?()
Redirect.navigate 'file', {
boardID: g.BOARD.ID
filename: pathname[pathname.length - 1]
@@ -166,14 +166,14 @@ Main =
ImageCommon.addControls video
return
- return unless hostname is 'boards.4chan.org'
+ return if Site.isAuxiliaryPage?()
if pathname[2] in ['thread', 'res']
g.VIEW = 'thread'
g.THREADID = +pathname[3]
- else if pathname[2] in ['catalog', 'archive']
- g.VIEW = pathname[2]
- else if pathname[2].match /^\d*$/
+ else if /^(?:catalog|archive)(?:\.html)?$/.test(pathname[2])
+ g.VIEW = pathname[2].replace('.html', '')
+ else if /^(?:index|\d*)(?:\.html)?$/.test(pathname[2])
g.VIEW = 'index'
else
return
@@ -186,6 +186,7 @@ Main =
# c.time 'All initializations'
for [name, feature] in Main.features
+ continue if Site.disabledFeatures and name in Site.disabledFeatures
# c.time "#{name} initialization"
try
feature.init()
@@ -245,9 +246,9 @@ Main =
$.rm Main.bgColorStyle
else
# Determine proper background color for dialogs if 4chan is using a special stylesheet.
- div = $.el 'div',
- className: 'reply'
- div.style.cssText = 'position: absolute; visibility: hidden;'
+ div = Site.bgColoredEl()
+ div.style.position = 'absolute';
+ div.style.visibility = 'hidden';
$.add d.body, div
bgColor = window.getComputedStyle(div).backgroundColor
$.rm div
@@ -265,42 +266,44 @@ Main =
}
initReady: ->
- # XXX Sometimes threads don't 404 but are left over as stubs containing one garbage reply post.
- if g.VIEW is 'thread' and (d.title in ['4chan - Temporarily Offline', '4chan - 404 Not Found'] or ($('.board') and not $('.opContainer')))
- 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}/"
+ if 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
- return if d.title in ['4chan - Temporarily Offline', '4chan - 404 Not Found']
-
- if g.VIEW in ['index', 'thread'] and not $('.board + *')
+ if 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.
- unless Conf['JSON Index'] and g.VIEW is 'index'
+ unless Index.enabled
Main.initThread()
else
Main.expectInitFinished = true
$.event '4chanXInitFinished'
initThread: ->
- if (board = $ '.board')
+ s = Site.selectors
+ if (board = $ s.board)
threads = []
posts = []
- for threadRoot in $$ '.board > .thread', board
- thread = new Thread +threadRoot.id[1..], g.BOARD
+ for threadRoot in $$(s.thread, board)
+ thread = new Thread +threadRoot.id.match(/\d*$/)[0], g.BOARD
thread.nodes.root = threadRoot
threads.push thread
- for postRoot in $$('.thread > .postContainer', threadRoot) when $('.postMessage', postRoot)
+ postRoots = $$ s.postContainer, threadRoot
+ postRoots.unshift threadRoot if Site.isOPContainerThread
+ for postRoot in postRoots when $(s.comment, postRoot)
try
posts.push new Post postRoot, thread, g.BOARD
catch err
@@ -313,17 +316,7 @@ Main =
Main.handleErrors errors if errors
if g.VIEW is 'thread'
- scriptData = Get.scriptData()
- threads[0].postLimit = /\bbumplimit *= *1\b/.test scriptData
- threads[0].fileLimit = /\bimagelimit *= *1\b/.test scriptData
- threads[0].ipCount = if m = scriptData.match /\bunique_ips *= *(\d+)\b/ then +m[1]
-
- if g.BOARD.ID is 'f' and g.VIEW is 'thread'
- $.ajax "#{location.protocol}//a.4cdn.org/f/thread/#{g.THREADID}.json",
- timeout: $.MINUTE
- onloadend: ->
- if @response and posts[0].file
- posts[0].file.text.dataset.md5 = posts[0].file.MD5 = @response.posts[0].md5
+ Site.parseThreadMetadata?(threads[0])
Main.callbackNodes 'Thread', threads
Main.callbackNodesDB 'Post', posts, ->
@@ -424,11 +417,12 @@ Main =
<%= html(' [report]') %>
isThisPageLegit: ->
- # 404 error page or similar.
+ # not 404 error page or similar.
unless 'thisPageIsLegit' of Main
- Main.thisPageIsLegit = location.hostname is 'boards.4chan.org' and
- !$('link[href*="favicon-status.ico"]', d.head) and
- d.title not in ['4chan - Temporarily Offline', '4chan - Error', '504 Gateway Time-out']
+ Main.thisPageIsLegit = if Site.isThisPageLegit
+ Site.isThisPageLegit()
+ else
+ !/^[45]\d\d\b/.test(document.title)
Main.thisPageIsLegit
ready: (cb) ->
diff --git a/src/platform/$.coffee b/src/platform/$.coffee
index ad866219c..8407d0efd 100644
--- a/src/platform/$.coffee
+++ b/src/platform/$.coffee
@@ -384,6 +384,10 @@ $.oneItemSugar = (fn) ->
$.syncing = {}
+$.securityCheck = (data) ->
+ if location.protocol isnt 'https:'
+ delete data['Redirect to HTTPS']
+
<% if (type === 'crx') { %>
# https://developer.chrome.com/extensions/storage.html
$.oldValue =
@@ -485,6 +489,7 @@ do ->
$.set = $.oneItemSugar (data, cb) ->
return unless $.crxWorking()
+ $.securityCheck data
$.extend items.local, data
setArea 'local', cb
@@ -536,6 +541,7 @@ if GM?.deleteValue? and window.BroadcastChannel and not GM_addValueChangeListene
cb items
$.set = $.oneItemSugar (items, cb) ->
+ $.securityCheck items
Promise.all(GM.setValue(g.NAMESPACE + key, JSON.stringify(val)) for key, val of items).then ->
$.syncChannel.postMessage items
cb?()
@@ -655,6 +661,7 @@ else
cb items
$.set = $.oneItemSugar (items, cb) ->
+ $.securityCheck items
$.queueTask ->
for key, value of items
$.setValue(g.NAMESPACE + key, JSON.stringify value)
diff --git a/src/site/SW.js b/src/site/SW.js
new file mode 100644
index 000000000..120a971ba
--- /dev/null
+++ b/src/site/SW.js
@@ -0,0 +1 @@
+SW = {};
diff --git a/src/site/SW.tinyboard.coffee b/src/site/SW.tinyboard.coffee
new file mode 100644
index 000000000..d98eb95ac
--- /dev/null
+++ b/src/site/SW.tinyboard.coffee
@@ -0,0 +1,123 @@
+SW.tinyboard =
+ isOPContainerThread: true
+
+ disabledFeatures: [
+ 'Board Configuration'
+ 'Normalize URL'
+ 'Captcha Configuration'
+ 'Image Host Rewriting'
+ 'Index Generator'
+ 'Announcement Hiding'
+ 'Fourchan thingies'
+ 'Custom CSS'
+ 'Resurrect Quotes'
+ 'Quick Reply Personas'
+ 'Quick Reply'
+ 'Cooldown'
+ 'Pass Link'
+ 'Index Generator (Menu)'
+ 'Edit Link'
+ 'Archive Link'
+ 'Quote Inlining'
+ 'Quote Previewing'
+ 'Quote Backlinks'
+ 'File Info Formatting'
+ 'Image Expansion'
+ 'Image Expansion (Menu)'
+ 'Comment Expansion'
+ 'Thread Expansion'
+ 'Thread Stats'
+ 'Thread Updater'
+ 'Mark New IPs'
+ 'Banner'
+ 'Flash Features'
+ 'Reply Pruning'
+ <% if (readJSON('/.tests_enabled')) { %>
+ 'Build Test'
+ <% } %>
+ ]
+
+ detect: ->
+ for script in $$ 'script:not([src])', d.head
+ return true if /\bvar configRoot=".*?"/.test(script.textContent)
+ false
+
+ selectors:
+ board: 'form[name="postcontrols"]'
+ thread: 'div[id^="thread_"]'
+ postContainer: '.reply' # postContainer is thread for OP
+ infoRoot: '.intro'
+ info:
+ subject: '.subject'
+ name: '.name'
+ email: '.email'
+ tripcode: '.trip'
+ uniqueID: '.poster_id'
+ capcode: '.capcode'
+ flag: '.flag'
+ date: 'time'
+ nameBlock: 'label'
+ quote: 'a[href*="#q"]'
+ reply: 'a[href*="/res/"]:not([href*="#"])'
+ icons:
+ isSticky: '.fa-thumb-tack'
+ isClosed: '.fa-lock'
+ file:
+ text: '.fileinfo'
+ link: '.fileinfo > a'
+ thumb: 'a > .post-image'
+ comment: '.body'
+ spoiler: '.spoiler'
+ quotelink: 'a[onclick^="highlightReply("]'
+ boardList: '.boardlist'
+
+ xpath:
+ thread: 'div[starts-with(@id,"thread_")]'
+ postContainer: 'div[starts-with(@id,"reply_") or starts-with(@id,"thread_")]'
+
+ regexp:
+ quotelink:
+ ///
+ /
+ ([^/]+) # boardID
+ /res/
+ (\d+) # threadID
+ \.html#
+ (\d+) # postID
+ $
+ ///
+
+ bgColoredEl: ->
+ $.el 'div', className: 'post reply'
+
+ parseNodes: (post, nodes) ->
+ # Add vichan's span.poster_id around the ID if not already present.
+ return if nodes.uniqueID
+ nodes.info.normalize()
+ {nextSibling} = nodes.nameBlock
+ if nextSibling.nodeType is 3 and (m = nextSibling.textContent.match /(\s*ID:\s*)(\S+)/)
+ nextSibling = nextSibling.splitText m[1].length
+ nextSibling.splitText m[2].length
+ nodes.uniqueID = uniqueID = $.el 'span', {className: 'poster_id'}
+ $.replace nextSibling, uniqueID
+ $.add uniqueID, nextSibling
+
+ parseFile: (post, file) ->
+ {text, link, thumb} = file
+ return false if $.x("ancestor::#{Site.xpath.postContainer}[1]", text) isnt post.nodes.root # file belongs to a reply
+ return false if not (infoNode = link.nextElementSibling)
+ return false if not (info = infoNode.textContent.match /\((Spoiler Image, )?([\d.]+ [KMG]?B).*\)/)
+ nameNode = $ '.postfilename', text
+ $.extend file,
+ name: if nameNode then (nameNode.title or nameNode.textContent) else link.pathname.match(/[^/]*$/)[0]
+ size: info[2]
+ dimensions: info[0].match(/\d+x\d+/)?[0]
+ if thumb
+ $.extend file,
+ thumbURL: if '/static/' in thumb.src then link.href else thumb.src
+ isSpoiler: !!info[1]
+ true
+
+ isThumbExpanded: (file) ->
+ # Detect old Tinyboard image expansion that changes src attribute on thumbnail.
+ $.hasClass file.thumb.parentNode, 'expanded'
diff --git a/src/site/SW.yotsuba.coffee b/src/site/SW.yotsuba.coffee
new file mode 100644
index 000000000..a781b6815
--- /dev/null
+++ b/src/site/SW.yotsuba.coffee
@@ -0,0 +1,135 @@
+SW.yotsuba =
+ isOPContainerThread: false
+
+ selectors:
+ board: '.board'
+ thread: '.thread'
+ postContainer: '.postContainer'
+ sideArrows: '.sideArrows'
+ post: '.post'
+ infoRoot: '.postInfo'
+ info:
+ subject: '.subject'
+ name: '.name'
+ email: '.useremail'
+ tripcode: '.postertrip'
+ uniqueIDRoot: '.posteruid'
+ uniqueID: '.posteruid > .hand'
+ capcode: '.capcode.hand'
+ pass: '.n-pu'
+ flag: '.flag, .countryFlag'
+ date: '.dateTime'
+ nameBlock: '.nameBlock'
+ quote: '.postNum > a:nth-of-type(2)'
+ reply: '.replylink'
+ icons:
+ isSticky: '.stickyIcon'
+ isClosed: '.closedIcon'
+ isArchived: '.archivedIcon'
+ file:
+ text: '.file > :first-child'
+ link: '.fileText > a'
+ thumb: 'a.fileThumb > [data-md5]'
+ comment: '.postMessage'
+ spoiler: 's'
+ quotelink: ':not(pre) > .quotelink' # XXX https://github.com/4chan/4chan-JS/issues/77: 4chan currently creates quote links inside [code] tags; ignore them
+ boardList: '#boardNavDesktop > .boardList'
+
+ xpath:
+ thread: 'div[contains(concat(" ",@class," ")," thread ")]'
+ postContainer: 'div[contains(@class,"postContainer")]'
+
+ regexp:
+ quotelink:
+ ///
+ ^https?://boards\.4chan\.org/+
+ ([^/]+) # boardID
+ /+thread/+
+ (\d+) # threadID
+ (?:[/?][^#]*)?
+ (?:#p
+ (\d+) # postID
+ )?
+ $
+ ///
+
+ bgColoredEl: ->
+ $.el 'div', className: 'reply'
+
+ isThisPageLegit: ->
+ # not 404 error page or similar.
+ location.hostname is 'boards.4chan.org' and
+ !$('link[href*="favicon-status.ico"]', d.head) and
+ d.title not in ['4chan - Temporarily Offline', '4chan - Error', '504 Gateway Time-out']
+
+ is404: ->
+ # XXX Sometimes threads don't 404 but are left over as stubs containing one garbage reply post.
+ d.title in ['4chan - Temporarily Offline', '4chan - 404 Not Found'] or (g.VIEW is 'thread' and $('.board') and not $('.opContainer'))
+
+ isIncomplete: ->
+ return g.VIEW in ['index', 'thread'] and not $('.board + *')
+
+ isAuxiliaryPage: ->
+ location.hostname isnt 'boards.4chan.org'
+
+ scriptData: ->
+ for script in $$ 'script:not([src])', d.head
+ return script.textContent if /\bcooldowns *=/.test script.textContent
+ ''
+
+ parseThreadMetadata: (thread) ->
+ scriptData = @scriptData()
+ thread.postLimit = /\bbumplimit *= *1\b/.test scriptData
+ thread.fileLimit = /\bimagelimit *= *1\b/.test scriptData
+ thread.ipCount = if (m = scriptData.match /\bunique_ips *= *(\d+)\b/) then +m[1]
+
+ if g.BOARD.ID is 'f' and thread.OP.file
+ {file} = thread.OP
+ $.ajax "#{location.protocol}//a.4cdn.org/f/thread/#{thread}.json",
+ timeout: $.MINUTE
+ onloadend: ->
+ if @response
+ file.text.dataset.md5 = file.MD5 = @response.posts[0].md5
+
+ parseNodes: (post, nodes) ->
+ # Add CSS classes to sticky/closed icons on /f/ to match other boards.
+ if post.boardID is 'f'
+ for type in ['Sticky', 'Closed'] when (icon = $ "img[alt=#{type}]", nodes.info)
+ $.addClass icon, "#{type.toLowerCase()}Icon", 'retina'
+
+ parseFile: (post, file) ->
+ {text, link, thumb} = file
+ return false if not (info = link.nextSibling?.textContent.match /\(([\d.]+ [KMG]?B).*\)/)
+ $.extend file,
+ name: text.title or link.title or link.textContent
+ size: info[1]
+ dimensions: info[0].match(/\d+x\d+/)?[0]
+ tag: info[0].match(/,[^,]*, ([a-z]+)\)/i)?[1]
+ MD5: text.dataset.md5
+ if thumb
+ $.extend file,
+ thumbURL: thumb.src
+ MD5: thumb.dataset.md5
+ isSpoiler: $.hasClass thumb.parentNode, 'imgspoiler'
+ if file.isSpoiler
+ file.thumbURL = if (m = link.href.match /\d+(?=\.\w+$)/) then "#{location.protocol}//#{ImageHost.thumbHost()}/#{post.board}/#{m[0]}s.jpg"
+ true
+
+ cleanComment: (bq) ->
+ if (abbr = $ '.abbr', bq) # 'Comment too long' or 'EXIF data available'
+ for node in $$ '.abbr + br, .exif', bq
+ $.rm node
+ for i in [0...2]
+ $.rm br if (br = abbr.previousSibling) and br.nodeName is 'BR'
+ $.rm abbr
+
+ cleanCommentDisplay: (bq) ->
+ $.rm b if (b = $ 'b', bq) and /^Rolled /.test(b.textContent)
+ $.rm $('.fortune', bq)
+
+ insertTags: (bq) ->
+ for node in $$ 's, .removed-spoiler', bq
+ $.replace node, [$.tn('[spoiler]'), node.childNodes..., $.tn '[/spoiler]']
+ for node in $$ '.prettyprint', bq
+ $.replace node, [$.tn('[code]'), node.childNodes..., $.tn '[/code]']
+ return
diff --git a/src/site/Site.coffee b/src/site/Site.coffee
new file mode 100644
index 000000000..d8fadf8fa
--- /dev/null
+++ b/src/site/Site.coffee
@@ -0,0 +1,24 @@
+Site =
+ init: (cb) ->
+ swDict = {}
+ for line in Conf['siteSoftware'].split('\n') when line[0] isnt '#'
+ [hostname, software] = line.split(' ')
+ swDict[hostname] = software if software of SW
+ {hostname} = location
+ while hostname and hostname not of swDict
+ hostname = hostname.replace(/^[^.]*\.?/, '')
+ if hostname
+ @set hostname, swDict[hostname]
+ cb()
+ else
+ $.onExists doc, 'body', =>
+ for software of SW
+ if SW[software].detect?()
+ @set location.hostname.replace(/^www\./, ''), software
+ Conf['siteSoftware'] += "\n#{@hostname} #{@software}"
+ $.set 'siteSoftware', Conf['siteSoftware']
+ cb()
+ return
+
+ set: (@hostname, @software) ->
+ $.extend @, SW[@software]