'
for quote in $$ '.quotelink', container
href = quote.getAttribute 'href'
continue if href[0] is '/' # Cross-board quote, or board link
quote.href = "/#{board}/res/#{href}" # Fix pathnames
container
Get =
postFromRoot: (root) ->
link = $ 'a[title="Highlight this post"]', root
board = link.pathname.split('/')[1]
postID = link.hash[2..]
index = root.dataset.clone
post = g.posts["#{board}.#{postID}"]
if index then post.clones[index] else post
postDataFromLink: (link) ->
if link.host is 'boards.4chan.org'
path = link.pathname.split '/'
board = path[1]
threadID = path[3]
postID = link.hash[2..]
else # resurrected quote
board = link.dataset.board
threadID = ''
postID = link.dataset.postid
return {
board: board
threadID: threadID
postID: postID
}
postClone: (board, threadID, postID, root, context) ->
if post = g.posts["#{board}.#{postID}"]
Get.insert post, root, context
return
root.textContent = "Loading post No.#{postID}..."
if threadID
$.cache "//api.4chan.org/#{board}/res/#{threadID}.json", ->
Get.fetchedPost @, board, threadID, postID, root, context
else if url = Redirect.post board, postID
$.cache url, ->
Get.archivedPost @, board, postID, root, context
insert: (post, root, context) ->
# Stop here if the container has been removed while loading.
return unless root.parentNode
clone = post.addClone context
Main.callbackNodes Post, [clone]
# Get rid of the side arrows.
{nodes} = clone
nodes.root.innerHTML = null
$.add nodes.root, nodes.post
root.innerHTML = null
$.add root, nodes.root
fetchedPost: (req, board, threadID, postID, root, context) ->
# In case of multiple callbacks for the same request,
# don't parse the same original post more than once.
if post = g.posts["#{board}.#{postID}"]
Get.insert post, root, context
return
{status} = req
if status isnt 200
# The thread can die by the time we check a quote.
if url = Redirect.post board, postID
$.cache url, ->
Get.archivedPost @, board, postID, root, context
else
$.addClass root, 'warning'
root.textContent =
if status is 404
"Thread No.#{threadID} 404'd."
else
"Error #{req.status}: #{req.statusText}."
return
posts = JSON.parse(req.response).posts
if spoilerRange = posts[0].custom_spoiler
Build.spoilerRange[board] = spoilerRange
postID = +postID
for post in posts
break if post.no is postID # we found it!
if post.no > postID
# The post can be deleted by the time we check a quote.
if url = Redirect.post board, postID
$.cache url, ->
Get.parseArchivedPost @, board, postID, root, context
else
$.addClass root, 'warning'
root.textContent = "Post No.#{postID} was not found."
return
board = g.boards[board] or
new Board board
thread = g.threads["#{board}.#{threadID}"] or
new Thread threadID, board
post = new Post Build.postFromObject(post, board), thread, board
Main.callbackNodes Post, [post]
Get.insert post, root, context
archivedPost: (req, board, postID, root, context) ->
# In case of multiple callbacks for the same request,
# don't parse the same original post more than once.
if post = g.posts["#{board}.#{postID}"]
Get.insert post, root, context
return
data = JSON.parse req.response
if data.error
$.addClass root, 'warning'
root.textContent = data.error
return
# convert comment to html
bq = $.el 'blockquote', textContent: data.comment # set this first to convert text to HTML entities
# https://github.com/eksopl/fuuka/blob/master/Board/Yotsuba.pm#L413-452
# https://github.com/eksopl/asagi/blob/master/src/main/java/net/easymodo/asagi/Yotsuba.java#L109-138
bq.innerHTML = bq.innerHTML.replace ///
\n
| \[/?b\]
| \[/?spoiler\]
| \[/?code\]
| \[/?moot\]
| \[/?banned\]
///g, (text) ->
switch text
when '\n'
' '
when '[b]'
''
when '[/b]'
''
when '[spoiler]'
''
when '[/spoiler]'
''
when '[code]'
'
'
when '[/code]'
'
'
when '[moot]'
'
'
when '[/moot]'
'
'
when '[banned]'
''
when '[/banned]'
''
# greentext
comment = bq.innerHTML.replace /(^|>)(>[^<$]+)(<|$)/g, '$1$2$3'
threadID = data.thread_num
o =
# id
postID: postID
threadID: threadID
board: board
# info
name: data.name_processed
capcode: switch data.capcode
when 'M' then 'mod'
when 'A' then 'admin'
when 'D' then 'developer'
tripcode: data.trip
uniqueID: data.poster_hash
email: if data.email then encodeURIComponent data.email else ''
subject: data.title_processed
flagCode: data.poster_country
flagName: data.poster_country_name_processed
date: data.fourchan_date
dateUTC: data.timestamp
comment: comment
# file
if data.media?.media_filename
o.file =
name: data.media.media_filename_processed
timestamp: data.media.media_orig
url: data.media.media_link or data.media.remote_media_link
height: data.media.media_h
width: data.media.media_w
MD5: data.media.media_hash
size: data.media.media_size
turl: data.media.thumb_link or "//thumbs.4chan.org/#{board}/thumb/#{data.media.preview_orig}"
theight: data.media.preview_h
twidth: data.media.preview_w
isSpoiler: data.media.spoiler is '1'
board = g.boards[board] or
new Board board
thread = g.threads["#{board}.#{threadID}"] or
new Thread threadID, board
post = new Post Build.post(o, true), thread, board,
isArchived: true
Main.callbackNodes Post, [post]
Get.insert post, root, context
Quotify =
init: ->
Post::callbacks.push
name: 'Resurrect Quotes'
cb: @node
node: ->
return if @isClone
# XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE === 6
# Get all the text nodes that are not inside an anchor.
snapshot = d.evaluate './/text()[not(parent::a)]', @nodes.comment, null, 6, null
for i in [0...snapshot.snapshotLength]
node = snapshot.snapshotItem i
data = node.data
# Only accept nodes with potentially valid links
continue unless quotes = data.match />>(>\/[a-z\d]+\/)?\d+/g
nodes = []
for quote in quotes
index = data.indexOf quote
if text = data[...index]
# Potential text before this valid quote.
nodes.push $.tn text
ID = quote.match(/\d+$/)[0]
board =
if m = quote.match /^>>>\/([a-z\d]+)/
m[1]
else
@board.ID
quoteID = "#{board}.#{ID}"
# \u00A0 is nbsp
if post = g.posts[quoteID]
if post.isDead
a = $.el 'a',
href: Redirect.thread board, 0, ID
className: 'quotelink deadlink'
textContent: "#{quote}\u00A0(Dead)"
target: '_blank'
else
# Don't (Dead) when quotifying in an archived post,
# and we know the post still exists.
a = $.el 'a',
href: "/#{board}/#{post.thread}/res/#p#{ID}"
className: 'quotelink'
textContent: quote
else
a = $.el 'a',
href: Redirect.thread board, 0, ID
className: 'deadlink'
target: '_blank'
# Don't (Dead) when quotifying in an archived post,
# and we don't know anything about the post.
textContent: if @isDead then quote else "#{quote}\u00A0(Dead)"
if Redirect.post board, ID
$.addClass a, 'quotelink'
a.setAttribute 'data-board', board
a.setAttribute 'data-postid', ID
if @quotes.indexOf(quoteID) is -1
@quotes.push quoteID
@nodes.quotelinks.push a
nodes.push a
data = data[index + quote.length..]
if data
# Potential text after the last valid quote.
nodes.push $.tn data
$.replace node, nodes
return
QuoteInline =
init: ->
Post::callbacks.push
name: 'Quote Inline'
cb: @node
node: ->
for link in @nodes.quotelinks
$.on link, 'click', QuoteInline.toggle
for link in @nodes.backlinks
$.on link, 'click', QuoteInline.toggle
return
toggle: (e) ->
return if e.shiftKey or e.altKey or e.ctrlKey or e.metaKey or e.button isnt 0
e.preventDefault()
{board, threadID, postID} = Get.postDataFromLink @
if $.hasClass @, 'inlined'
QuoteInline.rm @, board, threadID, postID
else
return if $.x "ancestor::div[@id='p#{postID}']", @
QuoteInline.add @, board, threadID, postID
@classList.toggle 'inlined'
add: (quotelink, board, threadID, postID) ->
inline = $.el 'div',
id: "i#{postID}"
className: 'inline'
root =
if isBacklink = $.hasClass quotelink, 'backlink'
quotelink.parentNode.parentNode
else
$.x 'ancestor-or-self::*[parent::blockquote][1]', quotelink
context = Get.postFromRoot $.x 'ancestor::div[parent::div[@class="thread"]][1]', quotelink
$.after root, inline
Get.postClone board, threadID, postID, inline, context
return unless board is g.BOARD.ID and $.x "ancestor::div[@id='t#{threadID}']", quotelink
post = g.posts["#{board}.#{postID}"]
# Hide forward post if it's a backlink of a post in this thread.
# Will only unhide if there's no inlined backlinks of it anymore.
if isBacklink and Conf['Forward Hiding']
$.addClass post.nodes.root, 'forwarded'
post.forwarded++ or post.forwarded = 1
# Decrease the unread count if this post is in the array of unread reply.
# XXX
# if (i = Unread.replies.indexOf el) isnt -1
# Unread.replies.splice i, 1
# Unread.update true
rm: (quotelink, board, threadID, postID) ->
# Select the corresponding inlined quote, and remove it.
root =
if $.hasClass quotelink, 'backlink'
quotelink.parentNode.parentNode
else
$.x 'ancestor-or-self::*[parent::blockquote][1]', quotelink
root = $.x "following-sibling::div[@id='i#{postID}'][1]", root
$.rm root
# Stop if it only contains text.
return unless el = root.firstElementChild
# Dereference clone.
post = g.posts["#{board}.#{postID}"]
post.rmClone el.dataset.clone
inThreadID = $.x('ancestor::div[@class="thread"]', quotelink).id[1..]
# Decrease forward count and unhide.
if Conf['Forward Hiding'] and
board is g.BOARD.ID and
threadID is inThreadID and
$.hasClass quotelink, 'backlink'
unless --post.forwarded
delete post.forwarded
$.rmClass post.nodes.root, 'forwarded'
# Repeat.
inlines = $$ '.inlined', el
for inline in inlines
{board, threadID, postID} = Get.postDataFromLink inline
root =
if $.hasClass inline, 'backlink'
inline.parentNode.parentNode
else
$.x 'ancestor-or-self::*[parent::blockquote][1]', inline
root = $.x "following-sibling::div[@id='i#{postID}'][1]", root
continue unless el = root.firstElementChild
post = g.posts["#{board}.#{postID}"]
post.rmClone el.dataset.clone
if Conf['Forward Hiding'] and
board is g.BOARD.ID and
threadID is inThreadID and
$.hasClass inline, 'backlink'
unless --post.forwarded
delete post.forwarded
$.rmClass post.nodes.root, 'forwarded'
return
QuotePreview =
init: ->
Post::callbacks.push
name: 'Quote Preview'
cb: @node
node: ->
for link in @nodes.quotelinks
$.on link, 'mouseover', QuotePreview.mouseover
for link in @nodes.backlinks
$.on link, 'mouseover', QuotePreview.mouseover
return
mouseover: (e) ->
return if $.hasClass @, 'inlined'
# Don't stop other elements from dragging
return if UI.el
{board, threadID, postID} = Get.postDataFromLink @
qp = UI.el = $.el 'div',
id: 'qp'
className: 'reply dialog'
UI.hover e
context = Get.postFromRoot $.x 'ancestor::div[parent::div[@class="thread"]][1]', @
$.add d.body, qp
Get.postClone board, threadID, postID, qp, context
$.on @, 'mousemove', UI.hover
$.on @, 'mouseout click', QuotePreview.mouseout
return unless origin = g.posts["#{board}.#{postID}"]
if Conf['Quote Highlighting']
posts = [origin].concat origin.clones
# Remove the clone that's in the qp from the array.
posts.pop()
for post in posts
$.addClass post.nodes.post, 'qphl'
quoterID = $.x('ancestor::*[@id][1]', @).id.match(/\d+$/)[0]
clone = Get.postFromRoot qp.firstChild
for quote in clone.nodes.quotelinks
if quote.hash[2..] is quoterID
$.addClass quote, 'forwardlink'
for quote in clone.nodes.backlinks
if quote.hash[2..] is quoterID
$.addClass quote, 'forwardlink'
return
mouseout: (e) ->
root = UI.el.firstElementChild
UI.hoverend()
$.off @, 'mousemove', UI.hover
$.off @, 'mouseout click', QuotePreview.mouseout
# Stop if it only contains text.
return unless root
clone = Get.postFromRoot root
post = clone.origin
post.rmClone root.dataset.clone
return unless Conf['Quote Highlighting']
for post in [post].concat post.clones
$.rmClass post.nodes.post, 'qphl'
return
QuoteBacklink =
# Backlinks appending need to work for:
# - previous, same, and following posts.
# - existing and yet-to-exist posts.
# - newly fetched posts.
# - in copies.
# XXX what about order for fetched posts?
#
# First callback creates backlinks and add them to relevant containers.
# Second callback adds relevant containers into posts.
# This is is so that fetched posts can get their backlinks,
# and that as much backlinks are appended in the background as possible.
init: ->
format = Conf['backlink'].replace /%id/g, "' + id + '"
@funk = Function 'id', "return '#{format}'"
@containers = {}
Post::callbacks.push
name: 'Quote Backlinking Part 1'
cb: @firstNode
Post::callbacks.push
name: 'Quote Backlinking Part 2'
cb: @secondNode
firstNode: ->
return if @isClone or !@quotes.length
a = $.el 'a',
href: "/#{@board}/res/#{@thread}#p#{@}"
# XXX className: if post.el.hidden then 'filtered backlink' else 'backlink'
className: 'backlink'
textContent: QuoteBacklink.funk @ID
for quote in @quotes
containers = [QuoteBacklink.getContainer quote]
if post = g.posts[quote]
for clone in post.clones
containers.push clone.nodes.backlinkContainer
for container in containers
link = a.cloneNode true
if Conf['Quote Preview']
$.on link, 'mouseover', QuotePreview.mouseover
if Conf['Quote Inline']
$.on link, 'click', QuoteInline.toggle
$.add container, [$.tn(' '), link]
return
secondNode: ->
if @isClone and @origin.nodes.backlinkContainer
@nodes.backlinkContainer = $ '.container', @nodes.info
return
# Don't backlink the OP.
return unless Conf['OP Backlinks'] or @isReply
container = QuoteBacklink.getContainer "#{@board}.#{@}"
@nodes.backlinkContainer = container
$.add @nodes.info, container
getContainer: (id) ->
@containers[id] or=
$.el 'span', className: 'container'
QuoteOP =
init: ->
# \u00A0 is nbsp
@text = '\u00A0(OP)'
Post::callbacks.push
name: 'Indicate OP Quotes'
cb: @node
node: ->
# Stop there if it's a clone of a post in the same thread.
return if @isClone and @thread is @context.thread
# Stop there if there's no quotes in that post.
return unless (quotes = @quotes).length
quotelinks = @nodes.quotelinks
# rm (OP) from cross-thread quotes.
if @isClone and -1 < quotes.indexOf "#{@board}.#{@thread}"
for quote in quotelinks
quote.textContent = quote.textContent.replace QuoteOP.text, ''
{board, thread} = if @isClone then @context else @
op = "#{board}.#{thread}"
# add (OP) to quotes quoting this context's OP.
return unless -1 < quotes.indexOf op
for quote in quotelinks
if "#{quote.pathname.split('/')[1]}.#{quote.hash[2..]}" is op
$.add quote, $.tn QuoteOP.text
return
QuoteCT =
init: ->
# \u00A0 is nbsp
@text = '\u00A0(Cross-thread)'
Post::callbacks.push
name: 'Indicate Cross-thread Quotes'
cb: @node
node: ->
# Stop there if it's a clone of a post in the same thread.
return if @isClone and @thread is @context.thread
# Stop there if there's no quotes in that post.
return unless (quotes = @quotes).length
quotelinks = @nodes.quotelinks
{board, thread} = if @isClone then @context else @
for quote in quotelinks
continue if $.hasClass quote, 'deadlink'
path = quote.pathname.split '/'
qBoard = path[1]
qThread = path[3]
if @isClone and qBoard is @board.ID and +qThread isnt @thread.ID
quote.textContent = quote.textContent.replace QuoteCT.text, ''
if qBoard is board.ID and +qThread isnt thread.ID
$.add quote, $.tn QuoteCT.text
return
Time =
init: ->
@funk = @createFunc Conf['time']
Post::callbacks.push
name: 'Time Formatting'
cb: @node
node: ->
return if @isClone
@nodes.date.textContent = Time.funk Time, @info.date
createFunc: (format) ->
code = format.replace /%([A-Za-z])/g, (s, c) ->
if c of Time.formatters
"' + Time.formatters.#{c}.call(date) + '"
else
s
Function 'Time', 'date', "return '#{code}'"
day: [
'Sunday'
'Monday'
'Tuesday'
'Wednesday'
'Thursday'
'Friday'
'Saturday'
]
month: [
'January'
'February'
'March'
'April'
'May'
'June'
'July'
'August'
'September'
'October'
'November'
'December'
]
zeroPad: (n) -> if n < 10 then "0#{n}" else n
formatters:
a: -> Time.day[@getDay()][...3]
A: -> Time.day[@getDay()]
b: -> Time.month[@getMonth()][...3]
B: -> Time.month[@getMonth()]
d: -> Time.zeroPad @getDate()
e: -> @getDate()
H: -> Time.zeroPad @getHours()
I: -> Time.zeroPad @getHours() % 12 or 12
k: -> @getHours()
l: -> @getHours() % 12 or 12
m: -> Time.zeroPad @getMonth() + 1
M: -> Time.zeroPad @getMinutes()
p: -> if @getHours() < 12 then 'AM' else 'PM'
P: -> if @getHours() < 12 then 'am' else 'pm'
S: -> Time.zeroPad @getSeconds()
y: -> @getFullYear() - 2000
FileInfo =
init: ->
@funk = @createFunc Conf['fileInfo']
Post::callbacks.push
name: 'File Info Formatting'
cb: @node
node: ->
return if !@file or @isClone
@file.text.innerHTML = FileInfo.funk FileInfo, @
createFunc: (format) ->
code = format.replace /%([BKlLMnNprs])/g, (s, c) ->
if c of FileInfo.formatters
"' + FileInfo.formatters.#{c}.call(post) + '"
else
s
Function 'FileInfo', 'post', "return '#{code}'"
convertUnit: (size, unit) ->
if unit is 'B'
return "#{size.toFixed()} Bytes"
i = 1 + ['KB', 'MB'].indexOf unit
size /= 1024 while i--
size =
if unit is 'MB'
Math.round(size * 100) / 100
else
size.toFixed()
"#{size} #{unit}"
escape: (name) ->
name.replace /<|>/g, (c) ->
c is '<' and '<' or '>'
formatters:
l: -> "#{FileInfo.formatters.n.call @}"
L: -> "#{FileInfo.formatters.N.call @}"
n: ->
fullname = @file.name
shortname = Build.shortFilename @file.name, @isReply
if fullname is shortname
FileInfo.escape fullname
else
"#{FileInfo.escape shortname}#{FileInfo.escape fullname}"
N: -> FileInfo.escape @file.name
p: -> if @file.isSpoiler then 'Spoiler, ' else ''
s: -> $.bytesToString @file.size
B: -> FileInfo.convertUnit @file.size, 'B'
K: -> FileInfo.convertUnit @file.size, 'KB'
M: -> FileInfo.convertUnit @file.size, 'MB'
r: -> if @file.isImage then @file.dimensions else 'PDF'
Sauce =
init: ->
links = []
for link in Conf['sauces'].split '\n'
continue if link[0] is '#'
# XXX .trim() is there to fix Opera reading two different line breaks.
links.push @createSauceLink link.trim()
return unless links.length
@links = links
Post::callbacks.push
name: 'Sauce'
cb: @node
createSauceLink: (link) ->
link = link.replace /\$(turl|url|md5|board)/g, (parameter) ->
switch parameter
when '$turl'
"' + post.file.thumbURL + '"
when '$url'
"' + post.file.URL + '"
when '$md5'
"' + encodeURIComponent(post.file.MD5) + '"
when '$board'
"' + post.board + '"
else
parameter
text = if m = link.match(/;text:(.+)$/) then m[1] else link.match(/(\w+)\.\w+\//)[1]
link = link.replace /;text:.+$/, ''
href = Function 'post', "return '#{link}'"
el = $.el 'a',
target: '_blank'
textContent: text
(post) ->
a = el.cloneNode true
a.href = href post
a
node: ->
return if @isClone or !@file
nodes = []
for link in Sauce.links
# \u00A0 is nbsp
nodes.push $.tn('\u00A0'), link @
$.add @file.info, nodes
RevealSpoilers =
init: ->
Post::callbacks.push
name: 'Reveal Spoilers'
cb: @node
node: ->
return if @isClone or !@file?.isSpoiler
{thumb} = @file
thumb.removeAttribute 'style'
thumb.src = @file.thumbURL
AutoGIF =
init: ->
return if g.BOARD.ID in ['gif', 'wsg']
Post::callbacks.push
name: 'Auto-GIF'
cb: @node
node: ->
# XXX return if @hidden?
return if @isClone or !@file?.isImage
{thumb, URL} = @file
return unless /gif$/.test(URL) and !/spoiler/.test thumb.src
if @file.isSpoiler
# Revealed spoilers do not have height/width set, this fixes auto-gifs dimensions.
{style} = thumb
style.maxHeight = style.maxWidth = if @isReply then '125px' else '250px'
gif = $.el 'img'
$.on gif, 'load', ->
# Replace the thumbnail once the GIF has finished loading.
thumb.src = URL
gif.src = URL
ImageHover =
init: ->
return if g.BOARD.ID in ['gif', 'wsg']
Post::callbacks.push
name: 'Auto-GIF'
cb: @node
node: ->
return unless @file?.isImage
$.on @file.thumb, 'mouseover', ImageHover.mouseover
mouseover: ->
# Don't stop other elements from dragging
return if UI.el
el = UI.el = $.el 'img'
id: 'ihover'
src: @parentNode.href
$.add d.body, el
$.on el, 'load', ImageHover.load
$.on el, 'error', ImageHover.error
$.on @, 'mousemove', UI.hover
$.on @, 'mouseout', ImageHover.mouseout
load: ->
return unless @parentNode
# 'Fake' mousemove event by giving required values.
{style} = @
UI.hover
clientX: - 45 + parseInt style.left
clientY: 120 + parseInt style.top
error: ->
return unless @parentNode
src = @src.split '/'
unless src[2] is 'images.4chan.org' and url = Redirect.image src[3], src[5]
return if g.DEAD
url = "//images.4chan.org/#{src[3]}/src/#{src[5]}"
return if $.engine isnt 'webkit' and url.split('/')[2] is 'images.4chan.org'
timeoutID = setTimeout (=> @src = url), 3000
# Only Chrome let userscripts do cross domain requests.
# Don't check for 404'd status in the archivers.
return if $.engine isnt 'webkit' or url.split('/')[2] isnt 'images.4chan.org'
$.ajax url, onreadystatechange: (-> clearTimeout timeoutID if @status is 404),
type: 'head'
mouseout: ->
UI.hoverend()
$.off @, 'mousemove', UI.hover
$.off @, 'mouseout', ImageHover.mouseout
Main.init()