'
if Conf['Remember QR size'] and $.engine is 'gecko'
$.on ta = $('textarea', QR.el), 'mouseup', ->
$.set 'QR.size', @style.cssText
ta.style.cssText = $.get 'QR.size', ''
# Allow only this board's supported files.
mimeTypes = $('ul.rules').firstElementChild.textContent.trim().match(/: (.+)/)[1].toLowerCase().replace /\w+/g, (type) ->
switch type
when 'jpg'
'image/jpeg'
when 'pdf'
'application/pdf'
when 'swf'
'application/x-shockwave-flash'
else
"image/#{type}"
QR.mimeTypes = mimeTypes.split ', '
# Add empty mimeType to avoid errors with URLs selected in Window's file dialog.
QR.mimeTypes.push ''
fileInput = $ 'input[type=file]', QR.el
fileInput.max = $('input[name=MAX_FILE_SIZE]').value
fileInput.accept = mimeTypes if $.engine isnt 'presto' # Opera's accept attribute is fucked up
QR.spoiler = !!$ 'input[name=spoiler]'
spoiler = $ '#spoilerLabel', QR.el
spoiler.hidden = !QR.spoiler
QR.charaCounter = $ '#charCount', QR.el
ta = $ 'textarea', QR.el
unless g.REPLY
# Make a list with visible threads and an option to create a new one.
threads = ''
for thread in $$ '.thread'
id = thread.id[1..]
threads += ""
$.prepend $('.move > span', QR.el), $.el 'select'
innerHTML: threads
title: 'Create a new thread / Reply to a thread'
$.on $('select', QR.el), 'mousedown', (e) -> e.stopPropagation()
$.on $('#autohide', QR.el), 'change', QR.toggleHide
$.on $('.close', QR.el), 'click', QR.close
$.on $('#dump', QR.el), 'click', -> QR.el.classList.toggle 'dump'
$.on $('#addReply', QR.el), 'click', -> new QR.reply().select()
$.on $('form', QR.el), 'submit', QR.submit
$.on ta, 'input', -> QR.selected.el.lastChild.textContent = @value
$.on ta, 'input', QR.characterCount
$.on fileInput, 'change', QR.fileInput
$.on fileInput, 'click', (e) -> if e.shiftKey then QR.selected.rmFile() or e.preventDefault()
$.on spoiler.firstChild, 'change', -> $('input', QR.selected.el).click()
$.on $('.warning', QR.el), 'click', QR.cleanError
new QR.reply().select()
# save selected reply's data
for name in ['name', 'email', 'sub', 'com']
# The input event replaces keyup, change and paste events.
$.on $("[name=#{name}]", QR.el), 'input', ->
QR.selected[@name] = @value
# Disable auto-posting if you're typing in the first reply
# during the last 5 seconds of the cooldown.
if QR.cooldown.auto and QR.selected is QR.replies[0] and 0 < QR.cooldown.seconds < 6
QR.cooldown.auto = false
QR.status.input = $ 'input[type=submit]', QR.el
QR.status()
QR.cooldown.init()
QR.captcha.init()
$.add d.body, QR.el
# Create a custom event when the QR dialog is first initialized.
# Use it to extend the QR's functionalities, or for XTRM RICE.
$.event QR.el, new CustomEvent 'QRDialogCreation',
bubbles: true
submit: (e) ->
e?.preventDefault()
if QR.cooldown.seconds
QR.cooldown.auto = !QR.cooldown.auto
QR.status()
return
QR.abort()
reply = QR.replies[0]
threadID = g.THREAD_ID or $('select', QR.el).value
# prevent errors
unless threadID is 'new' and reply.file or threadID isnt 'new' and (reply.com or reply.file)
err = 'No file selected.'
else if QR.captchaIsEnabled
# get oldest valid captcha
captchas = $.get 'captchas', []
# remove old captchas
while (captcha = captchas[0]) and captcha.time < Date.now()
captchas.shift()
if captcha = captchas.shift()
challenge = captcha.challenge
response = captcha.response
else
challenge = QR.captcha.img.alt
if response = QR.captcha.input.value then QR.captcha.reload()
$.set 'captchas', captchas
QR.captcha.count captchas.length
unless response
err = 'No valid captcha.'
if err
# stop auto-posting
QR.cooldown.auto = false
QR.status()
QR.error err
return
QR.cleanError()
# Enable auto-posting if we have stuff to post, disable it otherwise.
QR.cooldown.auto = QR.replies.length > 1
if Conf['Auto Hide QR'] and not QR.cooldown.auto
QR.hide()
if Conf['Thread Watcher'] and Conf['Auto Watch Reply'] and threadID isnt 'new'
Watcher.watch threadID
if not QR.cooldown.auto and $.x 'ancestor::div[@id="qr"]', d.activeElement
# Unfocus the focused element if it is one within the QR and we're not auto-posting.
d.activeElement.blur()
# Starting to upload might take some time.
# Provide some feedback that we're starting to submit.
QR.status progress: '...'
post =
resto: threadID
name: reply.name
email: reply.email
sub: reply.sub
com: reply.com
upfile: reply.file
spoiler: reply.spoiler
mode: 'regist'
pwd: if m = d.cookie.match(/4chan_pass=([^;]+)/) then decodeURIComponent m[1] else $('input[name=pwd]').value
recaptcha_challenge_field: challenge
recaptcha_response_field: response + ' '
callbacks =
onload: ->
QR.response @response
onerror: ->
# Connection error, or
# CORS disabled error on www.4chan.org/banned
QR.status()
QR.error $.el 'a',
href: '//www.4chan.org/banned'
target: '_blank'
textContent: 'Connection error, or you are banned.'
opts =
form: $.formData post
upCallbacks:
onload: ->
# Upload done, waiting for response.
QR.status progress: '...'
onprogress: (e) ->
# Uploading...
QR.status progress: "#{Math.round e.loaded / e.total * 100}%"
QR.ajax = $.ajax $.id('postForm').parentNode.action, callbacks, opts
response: (html) ->
doc = d.implementation.createHTMLDocument ''
doc.documentElement.innerHTML = html
if doc.title is '4chan - Banned' # Ban/warn check
bs = $$ 'b', doc
err = $.el 'span',
innerHTML:
if /^You were issued a warning/.test $('.boxcontent', doc).textContent.trim()
"You were issued a warning on #{bs[0].innerHTML} as #{bs[3].innerHTML}. Warning reason: #{bs[1].innerHTML}"
else
"You are banned! ;_; Please click HERE to see the reason."
else if msg = doc.getElementById 'errmsg' # error!
err = msg.textContent
if msg.firstChild.tagName # duplicate image link
err = msg.firstChild
err.target = '_blank'
else unless msg = $ 'b', doc
err = 'Connection error with sys.4chan.org.'
if err
if /captcha|verification/i.test(err) or err is 'Connection error with sys.4chan.org.'
# Enable auto-post if we have some cached captchas.
QR.cooldown.auto = !!$.get('captchas', []).length
# Too many frequent mistyped captchas will auto-ban you!
# On connection error, the post most likely didn't go through.
QR.cooldown.set 2
else # stop auto-posting
QR.cooldown.auto = false
QR.status()
QR.error err
return
reply = QR.replies[0]
persona = $.get 'QR.persona', {}
persona =
name: reply.name
email: if /^sage$/.test reply.email then persona.email else reply.email
sub: if Conf['Remember Subject'] then reply.sub else null
$.set 'QR.persona', persona
[_, threadID, postID] = msg.lastChild.textContent.match /thread:(\d+),no:(\d+)/
# Post/upload confirmed as successful.
$.event QR.el, new CustomEvent 'QRPostSuccessful',
detail:
threadID: threadID
postID: postID
if threadID is '0' # new thread
if Conf['Thread Watcher'] and Conf['Auto Watch']
$.set 'autoWatch', postID
# auto-noko
location.pathname = "/#{g.BOARD}/res/#{postID}"
else
# Enable auto-posting if we have stuff to post, disable it otherwise.
QR.cooldown.auto = QR.replies.length > 1
QR.cooldown.set if /sage/i.test reply.email then 60 else 30
if Conf['Open Reply in New Tab'] && !g.REPLY && !QR.cooldown.auto
$.open "//boards.4chan.org/#{g.BOARD}/res/#{threadID}#p#{postID}"
if Conf['Persistent QR'] or QR.cooldown.auto
reply.rm()
else
QR.close()
if g.REPLY and (Conf['Unread Count'] or Conf['Unread Favicon'])
Unread.foresee.push postID
if g.REPLY and Conf['Thread Updater'] and Conf['Auto Update This']
Updater.update()
QR.status()
QR.resetFileInput()
abort: ->
QR.ajax?.abort()
delete QR.ajax
QR.status()
Options =
init: ->
for home in [$.id('navtopr'), $.id('navbotr')]
a = $.el 'a',
textContent: '4chan X Settings'
href: 'javascript:;'
$.on a, 'click', Options.dialog
$.replace home.firstElementChild, a
unless $.get 'firstrun'
# Prevent race conditions
Favicon.init() unless Favicon.el
$.set 'firstrun', true
Options.dialog()
dialog: ->
dialog = $.el 'div'
id: 'options'
className: 'reply dialog'
innerHTML: '
'
#main
for key, obj of Config.main
ul = $.el 'ul',
textContent: key
for key, arr of obj
checked = if $.get(key, Conf[key]) then 'checked' else ''
description = arr[1]
li = $.el 'li',
innerHTML: ": #{description}"
$.on $('input', li), 'click', $.cb.checked
$.add ul, li
$.add $('#main_tab + div', dialog), ul
hiddenThreads = $.get "hiddenThreads/#{g.BOARD}/", {}
hiddenNum = Object.keys(g.hiddenReplies).length + Object.keys(hiddenThreads).length
li = $.el 'li',
innerHTML: " : Forget all hidden posts. Useful if you accidentally hide a post and have \"Show Stubs\" disabled."
$.on $('button', li), 'click', Options.clearHidden
$.add $('ul:nth-child(2)', dialog), li
#filter
filter = $ 'select[name=filter]', dialog
$.on filter, 'change', Options.filter
#sauce
sauce = $ '#sauces', dialog
sauce.value = $.get sauce.name, Conf[sauce.name]
$.on sauce, 'change', $.cb.value
#rice
(back = $ '[name=backlink]', dialog).value = $.get 'backlink', Conf['backlink']
(time = $ '[name=time]', dialog).value = $.get 'time', Conf['time']
(fileInfo = $ '[name=fileInfo]', dialog).value = $.get 'fileInfo', Conf['fileInfo']
$.on back, 'input', $.cb.value
$.on back, 'input', Options.backlink
$.on time, 'input', $.cb.value
$.on time, 'input', Options.time
$.on fileInfo, 'input', $.cb.value
$.on fileInfo, 'input', Options.fileInfo
favicon = $ 'select[name=favicon]', dialog
favicon.value = $.get 'favicon', Conf['favicon']
$.on favicon, 'change', $.cb.value
$.on favicon, 'change', Options.favicon
#keybinds
for key, arr of Config.hotkeys
tr = $.el 'tr',
innerHTML: "
#{arr[1]}
"
input = $ 'input', tr
input.value = $.get key, Conf[key]
$.on input, 'keydown', Options.keybind
$.add $('#keybinds_tab + div tbody', dialog), tr
#indicate if the settings require a feature to be enabled
indicators = {}
for indicator in $$ '.warning', dialog
key = indicator.firstChild.textContent
indicator.hidden = $.get key, Conf[key]
indicators[key] = indicator
$.on $("[name='#{key}']", dialog), 'click', ->
indicators[@name].hidden = @checked
overlay = $.el 'div', id: 'overlay'
$.on overlay, 'click', Options.close
$.on dialog, 'click', (e) -> e.stopPropagation()
$.add overlay, dialog
$.add d.body, overlay
d.body.style.setProperty 'width', "#{d.body.clientWidth}px", null
$.addClass d.body, 'unscroll'
Options.filter.call filter
Options.backlink.call back
Options.time.call time
Options.fileInfo.call fileInfo
Options.favicon.call favicon
close: ->
$.rm this
d.body.style.removeProperty 'width'
$.rmClass d.body, 'unscroll'
clearHidden: ->
#'hidden' might be misleading; it's the number of IDs we're *looking* for,
# not the number of posts actually hidden on the page.
$.delete "hiddenReplies/#{g.BOARD}/"
$.delete "hiddenThreads/#{g.BOARD}/"
@textContent = "hidden: 0"
g.hiddenReplies = {}
keybind: (e) ->
return if e.keyCode is 9
e.preventDefault()
e.stopPropagation()
return unless (key = Keybinds.keyCode e)?
@value = key
$.cb.value.call @
filter: ->
el = @nextSibling
if (name = @value) isnt 'guide'
ta = $.el 'textarea',
name: name
className: 'field'
value: $.get name, Conf[name]
$.on ta, 'change', $.cb.value
$.replace el, ta
return
$.rm el if el
$.after @, $.el 'article',
innerHTML: '
Use regular expressions, one per line.
Lines starting with a # will be ignored.
For example, /weeaboo/i will filter posts containing the string `weeaboo`, case-insensitive.
You can use these settings with each regular expression, separate them with semicolons:
Per boards, separate them with commas. It is global if not specified.
For example: boards:a,jp;.
Filter OPs only along with their threads (`only`), replies only (`no`, this is default), or both (`yes`).
For example: op:only;, op:no; or op:yes;.
Overrule the `Show Stubs` setting if specified: create a stub (`yes`) or not (`no`).
For example: stub:yes; or stub:no;.
Highlight instead of hiding. You can specify a class name to use with a userstyle.
For example: highlight; or highlight:wallpaper;.
Highlighted OPs will have their threads put on top of board pages by default.
For example: top:yes; or top:no;.
"
{checkbox} = Config.updater
for name of checkbox
title = checkbox[name][1]
checked = if Conf[name] then 'checked' else ''
html += ""
checked = if Conf['Auto Update'] then 'checked' else ''
html += "
"
dialog = UI.dialog 'updater', 'bottom: 0; right: 0;', html
@count = $ '#count', dialog
@timer = $ '#timer', dialog
@thread = $.id "t#{g.THREAD_ID}"
for input in $$ 'input', dialog
if input.type is 'checkbox'
$.on input, 'click', $.cb.checked
if input.name is 'Scroll BG'
$.on input, 'click', @cb.scrollBG
@cb.scrollBG.call input
if input.name is 'Verbose'
$.on input, 'click', @cb.verbose
@cb.verbose.call input
else if input.name is 'Auto Update This'
$.on input, 'click', @cb.autoUpdate
@cb.autoUpdate.call input
# Required for the QR's update after posting.
Conf[input.name] = input.checked
else if input.name is 'Interval'
$.on input, 'input', @cb.interval
else if input.type is 'button'
$.on input, 'click', @update
$.add d.body, dialog
@retryCoef = 10
@lastModified = 0
cb:
interval: ->
val = parseInt @value, 10
@value = if val > 0 then val else 1
$.cb.value.call @
verbose: ->
if Conf['Verbose']
Updater.count.textContent = '+0'
Updater.timer.hidden = false
else
$.extend Updater.count,
className: ''
textContent: 'Thread Updater'
Updater.timer.hidden = true
autoUpdate: ->
if @checked
Updater.timeoutID = setTimeout Updater.timeout, 1000
else
clearTimeout Updater.timeoutID
scrollBG: ->
Updater.scrollBG =
if @checked
-> true
else
-> !(d.hidden or d.oHidden or d.mozHidden or d.webkitHidden)
update: ->
if @status is 404
Updater.timer.textContent = ''
Updater.count.textContent = 404
Updater.count.className = 'warning'
clearTimeout Updater.timeoutID
g.dead = true
if Conf['Unread Count']
Unread.title = Unread.title.match(/^.+-/)[0] + ' 404'
else
d.title = d.title.match(/^.+-/)[0] + ' 404'
Unread.update true
QR.abort()
return
if @status isnt 200 and @status isnt 304
Updater.retryCoef += 10 * (Updater.retryCoef < 120)
if Conf['Verbose']
Updater.count.textContent = @statusText
Updater.count.className = 'warning'
return
Updater.retryCoef = 10
Updater.timer.textContent = "-#{Conf['Interval']}"
###
Status Code 304: Not modified
By sending the `If-Modified-Since` header we get a proper status code, and no response.
This saves bandwidth for both the user and the servers, avoid unnecessary computation,
and won't load images and scripts when parsing the response.
###
if @status is 304
if Conf['Verbose']
Updater.count.textContent = '+0'
Updater.count.className = null
return
Updater.lastModified = @getResponseHeader 'Last-Modified'
doc = d.implementation.createHTMLDocument ''
doc.documentElement.innerHTML = @response
lastPost = Updater.thread.lastElementChild
id = lastPost.id[2..]
nodes = []
for reply in $$('.replyContainer', doc).reverse()
break if reply.id[2..] <= id #make sure to not insert older posts
nodes.push reply
count = nodes.length
scroll = Conf['Scrolling'] && Updater.scrollBG() && count &&
lastPost.getBoundingClientRect().bottom - d.documentElement.clientHeight < 25
if Conf['Verbose']
Updater.count.textContent = "+#{count}"
Updater.count.className = if count then 'new' else null
$.add Updater.thread, nodes.reverse()
if scroll
nodes[0].scrollIntoView()
timeout: ->
Updater.timeoutID = setTimeout Updater.timeout, 1000
n = 1 + Number Updater.timer.textContent
if n is 0
Updater.update()
else if n is Updater.retryCoef
Updater.retryCoef += 10 * (Updater.retryCoef < 120)
Updater.retry()
else
Updater.timer.textContent = n
retry: ->
@count.textContent = 'Retry'
@count.className = null
@update()
update: ->
Updater.timer.textContent = 0
Updater.request?.abort()
#fool the cache
url = location.pathname + '?' + Date.now()
Updater.request = $.ajax url, onload: Updater.cb.update,
headers: 'If-Modified-Since': Updater.lastModified
Watcher =
init: ->
html = '
Thread Watcher
'
@dialog = UI.dialog 'watcher', 'top: 50px; left: 0px;', html
$.add d.body, @dialog
#add watch buttons
for input in $$ '.op input'
favicon = $.el 'img',
className: 'favicon'
$.on favicon, 'click', @cb.toggle
$.before input, favicon
if g.THREAD_ID is $.get 'autoWatch', 0
@watch g.THREAD_ID
$.delete 'autoWatch'
else
#populate watcher, display watch buttons
@refresh()
$.sync 'watched', @refresh
refresh: (watched) ->
watched or= $.get 'watched', {}
nodes = []
for board of watched
for id, props of watched[board]
x = $.el 'a',
textContent: '×'
href: 'javascript:;'
$.on x, 'click', Watcher.cb.x
link = $.el 'a', props
link.title = link.textContent
div = $.el 'div'
$.add div, [x, $.tn(' '), link]
nodes.push div
for div in $$ 'div:not(.move)', Watcher.dialog
$.rm div
$.add Watcher.dialog, nodes
watchedBoard = watched[g.BOARD] or {}
for favicon in $$ '.favicon'
id = favicon.nextSibling.name
if id of watchedBoard
favicon.src = Favicon.default
else
favicon.src = Favicon.empty
return
cb:
toggle: ->
Watcher.toggle @parentNode
x: ->
thread = @nextElementSibling.pathname.split '/'
Watcher.unwatch thread[3], thread[1]
toggle: (thread) ->
id = $('.favicon + input', thread).name
Watcher.watch(id) or Watcher.unwatch id, g.BOARD
unwatch: (id, board) ->
watched = $.get 'watched', {}
delete watched[board][id]
$.set 'watched', watched
Watcher.refresh()
watch: (id) ->
thread = $.id "t#{id}"
return false if $('.favicon', thread).src is Favicon.default
watched = $.get 'watched', {}
watched[g.BOARD] or= {}
watched[g.BOARD][id] =
href: "/#{g.BOARD}/res/#{id}"
textContent: Get.title thread
$.set 'watched', watched
Watcher.refresh()
true
Anonymize =
init: ->
Main.callbacks.push @node
node: (post) ->
return if post.isInlined and not post.isCrosspost
name = $ '.postInfo .name', post.el
name.textContent = 'Anonymous'
if (trip = name.nextElementSibling) and trip.className is 'postertrip'
$.rm trip
if (parent = name.parentNode).className is 'useremail' and not /^mailto:sage$/i.test parent.href
$.replace parent, name
Sauce =
init: ->
return if g.BOARD is 'f'
@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
Main.callbacks.push @node
createSauceLink: (link) ->
link = link.replace /(\$\d)/g, (parameter) ->
switch parameter
when '$1'
"' + (isArchived ? img.firstChild.src : 'http://thumbs.4chan.org' + img.pathname.replace(/src(\\/\\d+).+$/, 'thumb$1s.jpg')) + '"
when '$2'
"' + img.href + '"
when '$3'
"' + encodeURIComponent(img.firstChild.dataset.md5) + '"
when '$4'
g.BOARD
else
parameter
domain = if m = link.match(/;text:(.+)$/) then m[1] else link.match(/(\w+)\.\w+\//)[1]
href = link.replace /;text:.+$/, ''
href = Function 'img', 'isArchived', "return '#{href}'"
el = $.el 'a',
target: '_blank'
textContent: domain
(img, isArchived) ->
a = el.cloneNode true
a.href = href img, isArchived
a
node: (post) ->
{img} = post
return if post.isInlined and not post.isCrosspost or not img
img = img.parentNode
nodes = []
for link in Sauce.links
# \u00A0 is nbsp
nodes.push $.tn('\u00A0'), link img, post.isArchived
$.add post.fileInfo, nodes
RevealSpoilers =
init: ->
Main.callbacks.push @node
node: (post) ->
{img} = post
if not (img and /^Spoiler/.test img.alt) or post.isInlined and not post.isCrosspost or post.isArchived
return
img.removeAttribute 'style'
# revealed spoilers do not have height/width set, this fixes auto-gifs dimensions.
s = img.style
s.maxHeight = s.maxWidth = if /\bop\b/.test post.class then '250px' else '125px'
img.src = "//thumbs.4chan.org#{img.parentNode.pathname.replace /src(\/\d+).+$/, 'thumb$1s.jpg'}"
Time =
init: ->
Time.foo()
Main.callbacks.push @node
node: (post) ->
return if post.isInlined and not post.isCrosspost
node = $ '.postInfo > .dateTime', post.el
Time.date = new Date node.dataset.utc * 1000
node.textContent = Time.funk Time
foo: ->
code = Conf['time'].replace /%([A-Za-z])/g, (s, c) ->
if c of Time.formatters
"' + Time.formatters.#{c}() + '"
else
s
Time.funk = Function 'Time', "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[Time.date.getDay()][...3]
A: -> Time.day[Time.date.getDay()]
b: -> Time.month[Time.date.getMonth()][...3]
B: -> Time.month[Time.date.getMonth()]
d: -> Time.zeroPad Time.date.getDate()
e: -> Time.date.getDate()
H: -> Time.zeroPad Time.date.getHours()
I: -> Time.zeroPad Time.date.getHours() % 12 or 12
k: -> Time.date.getHours()
l: -> Time.date.getHours() % 12 or 12
m: -> Time.zeroPad Time.date.getMonth() + 1
M: -> Time.zeroPad Time.date.getMinutes()
p: -> if Time.date.getHours() < 12 then 'AM' else 'PM'
P: -> if Time.date.getHours() < 12 then 'am' else 'pm'
S: -> Time.zeroPad Time.date.getSeconds()
y: -> Time.date.getFullYear() - 2000
FileInfo =
init: ->
return if g.BOARD is 'f'
@setFormats()
Main.callbacks.push @node
node: (post) ->
return if post.isInlined and not post.isCrosspost or not post.fileInfo
node = post.fileInfo.firstElementChild
alt = post.img.alt
span = $ 'span', node
FileInfo.data =
link: post.img.parentNode.href
spoiler: /^Spoiler/.test alt
size: alt.match(/\d+\.?\d*/)[0]
unit: alt.match(/\w+$/)[0]
resolution: span.previousSibling.textContent.match(/\d+x\d+|PDF/)[0]
fullname: span.title
shortname: span.textContent
# XXX GM/Scriptish
node.setAttribute 'data-filename', span.title
node.innerHTML = FileInfo.funk FileInfo
setFormats: ->
code = Conf['fileInfo'].replace /%([BKlLMnNprs])/g, (s, c) ->
if c of FileInfo.formatters
"' + f.formatters.#{c}() + '"
else
s
@funk = Function 'f', "return '#{code}'"
convertUnit: (unitT) ->
size = @data.size
unitF = @data.unit
if unitF isnt unitT
units = ['B', 'KB', 'MB']
i = units.indexOf(unitF) - units.indexOf unitT
unitT = 'Bytes' if unitT is 'B'
if i > 0
size *= 1024 while i-- > 0
else if i < 0
size /= 1024 while i++ < 0
if size < 1 and size.toString().length > size.toFixed(2).length
size = size.toFixed 2
"#{size} #{unitT}"
formatters:
l: -> "#{@n()}"
L: -> "#{@N()}"
n: ->
if FileInfo.data.fullname is FileInfo.data.shortname
FileInfo.data.fullname
else
"#{FileInfo.data.shortname}#{FileInfo.data.fullname}"
N: -> FileInfo.data.fullname
p: -> if FileInfo.data.spoiler then 'Spoiler, ' else ''
s: -> "#{FileInfo.data.size} #{FileInfo.data.unit}"
B: -> FileInfo.convertUnit 'B'
K: -> FileInfo.convertUnit 'KB'
M: -> FileInfo.convertUnit 'MB'
r: -> FileInfo.data.resolution
Get =
post: (board, threadID, postID, root, cb) ->
if board is g.BOARD and post = $.id "pc#{postID}"
$.add root, Get.cleanPost post.cloneNode true
return
root.textContent = "Loading post No.#{postID}..."
if threadID
$.cache "/#{board}/res/#{threadID}", ->
Get.parsePost @, board, threadID, postID, root, cb
else if url = Redirect.post board, postID
$.cache url, ->
Get.parseArchivedPost @, board, postID, root, cb
parsePost: (req, board, threadID, postID, root, cb) ->
{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.parseArchivedPost @, board, postID, root, cb
else
root.textContent =
if status is 404
"Thread No.#{threadID} has not been found."
else
"Error #{req.status}: #{req.statusText}."
return
doc = d.implementation.createHTMLDocument ''
doc.documentElement.innerHTML = req.response
unless pc = doc.getElementById "pc#{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, cb
else
root.textContent = "Post No.#{postID} has not been found."
return
pc = Get.cleanPost d.importNode pc, true
for quote in $$ '.quotelink', pc
href = quote.getAttribute 'href'
continue if href[0] is '/' # Cross-board quote, or board link
quote.href = "/#{board}/res/#{href}" # Fix pathnames
link = $ '.postNum > a[title="Highlight this post"]', pc
link.href = "/#{board}/res/#{threadID}#p#{postID}"
link.nextSibling.href = "/#{board}/res/#{threadID}#q#{postID}"
$.replace root.firstChild, pc
cb() if cb
parseArchivedPost: (req, board, postID, root, cb) ->
data = JSON.parse req.response
$.addClass root, 'archivedPost'
if data.error
root.textContent = data.error
return
threadID = data.thread_num
isOP = postID is threadID
{name, trip, timestamp} = data
subject = data.title
# post info (mobile)
piM = $.el 'div',
id: "pim#{postID}"
className: 'postInfoM mobile'
innerHTML: " #{data.fourchan_date} No.#{postID}"
$('.name', piM).textContent = name
$('.subject', piM).textContent = subject
br = $ 'br', piM
if trip
$.before br, [$.tn(' '), $.el 'span',
className: 'postertrip'
textContent: trip
]
{capcode} = data
if capcode isnt 'N' # 'A'dmin or 'M'od
$.addClass br.parentNode, if capcode is 'A' then 'capcodeAdmin' else 'capcodeMod'
$.before br, [
$.tn(' '),
$.el('strong',
className: 'capcode',
textContent: if capcode is 'A' then '## Admin' else '## Mod'
),
$.tn(' '),
$.el('img',
src: if capcode is 'A' then '//static.4chan.org/image/adminicon.gif' else '//static.4chan.org/image/modicon.gif',
alt: if capcode is 'A' then 'This user is the 4chan Administrator.' else 'This user is a 4chan Moderator.',
title: if capcode is 'A' then 'This user is the 4chan Administrator.' else 'This user is a 4chan Moderator.',
className: 'identityIcon'
)
]
# post info
pi = $.el 'div',
id: "pi#{postID}"
className: 'postInfo desktop'
innerHTML: " data.fourchan_dateNo.#{postID}#{if isOP then ' ' else ''} "
# subject
$('.subject', pi).textContent = subject
nameBlock = $ '.nameBlock', pi
if data.email
email = $.el 'a',
className: 'useremail'
href: "mailto:#{data.email}"
$.add nameBlock, email
nameBlock = email
$.add nameBlock, $.el 'span',
className: 'name'
textContent: data.name
if trip
$.add nameBlock, [$.tn(' '), $.el('span', className: 'postertrip', textContent: trip)]
if capcode isnt 'N' # 'A'dmin or 'M'od
$.add nameBlock, [
$.tn(' '),
$.el('strong',
className: if capcode is 'A' then 'capcode capcodeAdmin' else 'capcode',
textContent: if capcode is 'A' then '## Admin' else '## Mod'
)
]
nameBlock = $ '.nameBlock', pi
$.addClass nameBlock, if capcode is 'A' then 'capcodeAdmin' else 'capcodeMod'
$.add nameBlock, [
$.tn(' '),
$.el('img',
src: if capcode is 'A' then '//static.4chan.org/image/adminicon.gif' else '//static.4chan.org/image/modicon.gif',
alt: if capcode is 'A' then 'This user is the 4chan Administrator.' else 'This user is a 4chan Moderator.',
title: if capcode is 'A' then 'This user is the 4chan Administrator.' else 'This user is a 4chan Moderator.',
className: 'identityIcon'
)
]
# comment
bq = $.el 'blockquote',
id: "m#{postID}"
className: 'postMessage'
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
bq.innerHTML = bq.innerHTML.replace /(^|>)(>[^<$]+)(<|$)/g, '$1$2$3'
# post container
pc = $.el 'div',
id: "pc#{postID}"
className: "postContainer #{if isOP then 'op' else 'reply'}Container"
innerHTML: ""
$.add pc.firstChild, [piM, pi, bq]
# file
if filename = data.media_filename
file = $.el 'div',
id: "f#{postID}"
className: 'file'
spoiler = data.spoiler is '1'
filesize = $.bytesToString data.media_size
$.add file, $.el 'div',
className: 'fileInfo'
innerHTML: "File: #{data.media_orig}-(#{if spoiler then 'Spoiler Image, ' else ''}#{filesize}, #{data.media_w}x#{data.media_h}, )"
span = $ 'span[title]', file
span.title = filename
max = if isOP then 40 else 30
span.textContent =
# FILENAME SHORTENING SCIENCE:
# OPs have +10 characters max.
# The file extension is not taken into account.
# abcdefghijklmnopqrstuvwxyz_1234.jpg is shortened.
# abcdefghijklmnopqrstuvwxyz_123.jpg is not shortened.
if filename.replace(/\.\w+$/, '').length > max
"#{filename[...max]}(...)#{filename.match(/\.\w+$/)}"
else
filename
thumb_src = if data.media_status is 'available' then "src=#{data.thumb_link}" else ''
$.add file, $.el 'a',
className: if spoiler then 'fileThumb imgspoiler' else 'fileThumb'
href: data.media_link or data.remote_media_link
target: '_blank'
innerHTML: ""
$.after (if isOP then piM else pi), file
$.replace root.firstChild, Get.cleanPost pc
cb() if cb
cleanPost: (root) ->
post = $ '.post', root
for child in Array::slice.call root.childNodes
$.rm child unless child is post
# Remove inlined posts inside of this post.
for inline in $$ '.inline', post
$.rm inline
for inlined in $$ '.inlined', post
$.rmClass inlined, 'inlined'
# Don't mess with other features
now = Date.now()
els = $$ '[id]', root
els.push root
for el in els
el.id = "#{now}_#{el.id}"
$.rmClass root, 'forwarded'
$.rmClass root, 'qphl' # op
$.rmClass post, 'highlight'
$.rmClass post, 'qphl' # reply
root.hidden = post.hidden = false
root
title: (thread) ->
op = $ '.op', thread
el = $ '.subject', op
unless el.textContent
el = $ 'blockquote', op
unless el.textContent
el = $ '.nameBlock', op
span = $.el 'span', innerHTML: el.innerHTML.replace / /g, ' '
"/#{g.BOARD}/ - #{span.textContent.trim()}"
TitlePost =
init: ->
d.title = Get.title()
QuoteBacklink =
init: ->
format = Conf['backlink'].replace /%id/g, "' + id + '"
@funk = Function 'id', "return '#{format}'"
Main.callbacks.push @node
node: (post) ->
return if post.isInlined
quotes = {}
for quote in post.quotes
# Don't process >>>/b/.
if qid = quote.hash[2..]
# Duplicate quotes get overwritten.
quotes[qid] = true
a = $.el 'a',
href: "/#{g.BOARD}/res/#{post.threadID}#p#{post.ID}"
className: if post.el.hidden then 'filtered backlink' else 'backlink'
textContent: QuoteBacklink.funk post.ID
for qid of quotes
# Don't backlink the OP.
continue if !(el = $.id "pi#{qid}") or !Conf['OP Backlinks'] and /\bop\b/.test el.parentNode.className
link = a.cloneNode true
if Conf['Quote Preview']
$.on link, 'mouseover', QuotePreview.mouseover
if Conf['Quote Inline']
$.on link, 'click', QuoteInline.toggle
else
link.setAttribute 'onclick', "replyhl('#{post.ID}');"
unless container = $.id "blc#{qid}"
container = $.el 'span',
className: 'container'
id: "blc#{qid}"
$.add el, container
$.add container, [$.tn(' '), link]
return
QuoteInline =
init: ->
Main.callbacks.push @node
node: (post) ->
for quote in post.quotes
continue unless quote.hash or /\bdeadlink\b/.test quote.className
quote.removeAttribute 'onclick'
$.on quote, 'click', QuoteInline.toggle
for quote in post.backlinks
$.on quote, '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()
id = @dataset.id or @hash[2..]
if /\binlined\b/.test @className
QuoteInline.rm @, id
else
return if $.x "ancestor::div[contains(@id,'p#{id}')]", @
QuoteInline.add @, id
@classList.toggle 'inlined'
add: (q, id) ->
if q.host is 'boards.4chan.org'
path = q.pathname.split '/'
board = path[1]
threadID = path[3]
postID = id
else
board = q.dataset.board
threadID = 0
postID = q.dataset.id
el = if board is g.BOARD then $.id "p#{postID}" else false
inline = $.el 'div',
id: "i#{postID}"
className: if el then 'inline' else 'inline crosspost'
root =
if isBacklink = /\bbacklink\b/.test q.className
q.parentNode
else
$.x 'ancestor-or-self::*[parent::blockquote][1]', q
$.after root, inline
Get.post board, threadID, postID, inline
return unless el
# Will only unhide if there's no inlined backlinks of it anymore.
if isBacklink and Conf['Forward Hiding']
$.addClass el.parentNode, 'forwarded'
++el.dataset.forwarded or el.dataset.forwarded = 1
# Decrease the unread count if this post is in the array of unread reply.
if (i = Unread.replies.indexOf el) isnt -1
Unread.replies.splice i, 1
Unread.update true
rm: (q, id) ->
# select the corresponding inlined quote or loading quote
div = $.x "following::div[@id='i#{id}']", q
$.rm div
return unless Conf['Forward Hiding']
for inlined in $$ '.backlink.inlined', div
div = $.id inlined.hash[1..]
$.rmClass div.parentNode, 'forwarded' unless --div.dataset.forwarded
if /\bbacklink\b/.test q.className
div = $.id "p#{id}"
$.rmClass div.parentNode, 'forwarded' unless --div.dataset.forwarded
QuotePreview =
init: ->
Main.callbacks.push @node
node: (post) ->
for quote in post.quotes
$.on quote, 'mouseover', QuotePreview.mouseover if quote.hash or /\bdeadlink\b/.test quote.className
for quote in post.backlinks
$.on quote, 'mouseover', QuotePreview.mouseover
return
mouseover: (e) ->
return if /\binlined\b/.test @className
# Make sure to remove the previous qp
# in case it got stuck. Opera-only bug?
if qp = $.id 'qp'
if qp is UI.el
delete UI.el
$.rm qp
# Don't stop other elements from dragging
return if UI.el
if @host is 'boards.4chan.org'
path = @pathname.split '/'
board = path[1]
threadID = path[3]
postID = @hash[2..]
else
board = @dataset.board
threadID = 0
postID = @dataset.id
qp = UI.el = $.el 'div',
id: 'qp'
className: 'reply dialog'
UI.hover e
$.add d.body, qp
el = $.id "p#{postID}" if board is g.BOARD
Get.post board, threadID, postID, qp, ->
bq = $ 'blockquote', qp
Main.prettify bq
post =
el: qp
blockquote: bq
isArchived: /\barchivedPost\b/.test qp.className
if img = $ 'img[data-md5]', qp
post.fileInfo = img.parentNode.previousElementSibling
post.img = img
if Conf['Reveal Spoilers']
RevealSpoilers.node post
if Conf['Image Auto-Gif']
AutoGif.node post
if Conf['Time Formatting']
Time.node post
if Conf['File Info Formatting']
FileInfo.node post
if Conf['Resurrect Quotes']
Quotify.node post
$.on @, 'mousemove', UI.hover
$.on @, 'mouseout click', QuotePreview.mouseout
return unless el
if Conf['Quote Highlighting']
if /\bop\b/.test el.className
$.addClass el.parentNode, 'qphl'
else
$.addClass el, 'qphl'
quoterID = $.x('ancestor::*[@id][1]', @).id.match(/\d+$/)[0]
for quote in $$ '.quotelink, .backlink', qp
if quote.hash[2..] is quoterID
$.addClass quote, 'forwardlink'
return
mouseout: (e) ->
UI.hoverend()
if el = $.id @hash[1..]
$.rmClass el, 'qphl' # reply
$.rmClass el.parentNode, 'qphl' # op
$.off @, 'mousemove', UI.hover
$.off @, 'mouseout click', QuotePreview.mouseout
QuoteOP =
init: ->
Main.callbacks.push @node
node: (post) ->
return if post.isInlined and not post.isCrosspost
for quote in post.quotes
if quote.hash[2..] is post.threadID
# \u00A0 is nbsp
$.add quote, $.tn '\u00A0(OP)'
return
QuoteCT =
init: ->
Main.callbacks.push @node
node: (post) ->
return if post.isInlined and not post.isCrosspost
for quote in post.quotes
unless quote.hash
# Make sure this isn't a link to the board we're on.
continue
path = quote.pathname.split '/'
# If quote leads to a different thread id and is located on the same board.
if path[1] is g.BOARD and path[3] isnt post.threadID
# \u00A0 is nbsp
$.add quote, $.tn '\u00A0(Cross-thread)'
return
Quotify =
init: ->
Main.callbacks.push @node
node: (post) ->
return if post.isInlined and not post.isCrosspost
# XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE is 6
# Get all the text nodes that are not inside an anchor.
snapshot = d.evaluate './/text()[not(parent::a)]', post.blockquote, null, 6, null
for i in [0...snapshot.snapshotLength]
node = snapshot.snapshotItem i
data = node.data
unless quotes = data.match />>(>\/[a-z\d]+\/)?\d+/g
# Only accept nodes with potentially valid links
continue
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
# Get the post's board, whether it's inlined or not.
$('.postNum > a[title="Highlight this post"]', post.el).pathname.split('/')[1]
nodes.push a = $.el 'a',
# \u00A0 is nbsp
textContent: "#{quote}\u00A0(Dead)"
if board is g.BOARD and $.id "p#{id}"
a.href = "#p#{id}"
a.className = 'quotelink'
a.setAttribute 'onclick', "replyhl('#{id}');"
else
a.href = Redirect.thread board, 0, id
a.className = 'deadlink'
a.target = '_blank'
if Redirect.post board, id
$.addClass a, 'quotelink'
# XXX WTF Scriptish/Greasemonkey?
# Setting dataset attributes that way doesn't affect the HTML,
# but are, I suspect, kept as object key/value pairs and GC'd later.
# a.dataset.board = board
# a.dataset.id = id
a.setAttribute 'data-board', board
a.setAttribute 'data-id', id
data = data[index + quote.length..]
if data
# Potential text after the last valid quote.
nodes.push $.tn data
$.replace node, nodes
return
DeleteLink =
init: ->
a = $.el 'a',
className: 'delete_link'
href: 'javascript:;'
Menu.addEntry
el: a
open: (post) ->
if post.isArchived
return false
a.textContent = 'Delete this post'
$.on a, 'click', DeleteLink.delete
true
delete: ->
$.off @, 'click', DeleteLink.delete
@textContent = 'Deleting...'
pwd =
if m = d.cookie.match /4chan_pass=([^;]+)/
decodeURIComponent m[1]
else
$.id('delPassword').value
id = @parentNode.dataset.id
board = $('.postNum > a[title="Highlight this post"]',
$.id @parentNode.dataset.rootid).pathname.split('/')[1]
self = this
form =
mode: 'usrdel'
pwd: pwd
form[id] = 'delete'
$.ajax $.id('delform').action.replace("/#{g.BOARD}/", "/#{board}/"), {
onload: -> DeleteLink.load self, @response
onerror: -> DeleteLink.error self
}, {
form: $.formData form
}
load: (self, html) ->
doc = d.implementation.createHTMLDocument ''
doc.documentElement.innerHTML = html
if doc.title is '4chan - Banned' # Ban/warn check
s = 'Banned!'
else if msg = doc.getElementById 'errmsg' # error!
s = msg.textContent
$.on self, 'click', DeleteLink.delete
else
s = 'Deleted'
self.textContent = s
error: (self) ->
self.textContent = 'Connection error, please retry.'
$.on self, 'click', DeleteLink.delete
ReportLink =
init: ->
a = $.el 'a',
className: 'report_link'
href: 'javascript:;'
textContent: 'Report this post'
$.on a, 'click', @report
Menu.addEntry
el: a
open: (post) ->
post.isArchived is false
report: ->
a = $ '.postNum > a[title="Highlight this post"]', $.id @parentNode.dataset.rootid
url = "//sys.4chan.org/#{a.pathname.split('/')[1]}/imgboard.php?mode=report&no=#{@parentNode.dataset.id}"
id = Date.now()
set = "toolbar=0,scrollbars=0,location=0,status=1,menubar=0,resizable=1,width=685,height=200"
window.open url, id, set
DownloadLink =
init: ->
# Test for download feature support.
return if $.el('a').download is undefined
a = $.el 'a',
className: 'download_link'
textContent: 'Download file'
Menu.addEntry
el: a
open: (post) ->
unless post.img
return false
a.href = post.img.parentNode.href
fileText = post.fileInfo.firstElementChild
a.download =
if Conf['File Info Formatting']
fileText.dataset.filename
else
$('span', fileText).title
true
ArchiveLink =
init: ->
a = $.el 'a',
className: 'archive_link'
target: '_blank'
textContent: 'Archived post'
Menu.addEntry
el: a
open: (post) ->
path = $('.postNum > a[title="Highlight this post"]', post.el).pathname.split '/'
if (href = Redirect.thread path[1], path[3], post.ID) is "//boards.4chan.org/#{path[1]}/"
return false
a.href = href
true
ThreadStats =
init: ->
dialog = UI.dialog 'stats', 'bottom: 0; left: 0;', '
0 / 0
'
dialog.className = 'dialog'
$.add d.body, dialog
@posts = @images = 0
@imgLimit =
switch g.BOARD
when 'a', 'b', 'v', 'co', 'mlp'
251
when 'vg'
501
else
151
Main.callbacks.push @node
node: (post) ->
return if post.isInlined
$.id('postcount').textContent = ++ThreadStats.posts
return unless post.img
imgcount = $.id 'imagecount'
imgcount.textContent = ++ThreadStats.images
if ThreadStats.images > ThreadStats.imgLimit
$.addClass imgcount, 'warning'
Unread =
init: ->
@title = d.title
@update()
$.on window, 'scroll', Unread.scroll
Main.callbacks.push @node
replies: []
foresee: []
node: (post) ->
if (index = Unread.foresee.indexOf post.ID) isnt -1
Unread.foresee.splice index, 1
return
{el} = post
return if el.hidden or /\bop\b/.test(post.class) or post.isInlined
count = Unread.replies.push el
Unread.update count is 1
scroll: ->
height = d.documentElement.clientHeight
for reply, i in Unread.replies
{bottom} = reply.getBoundingClientRect()
if bottom > height #post is not completely read
break
return if i is 0
Unread.replies = Unread.replies[i..]
Unread.update Unread.replies.length is 0
setTitle: (count) ->
if @scheduled
clearTimeout @scheduled
delete Unread.scheduled
@setTitle count
return
@scheduled = setTimeout (->
d.title = "(#{count}) #{Unread.title}"
), 5
update: (updateFavicon) ->
return unless g.REPLY
count = @replies.length
if Conf['Unread Count']
@setTitle count
unless Conf['Unread Favicon'] and updateFavicon
return
if $.engine is 'presto'
$.rm Favicon.el
Favicon.el.href =
if g.dead
if count
Favicon.unreadDead
else
Favicon.dead
else
if count
Favicon.unread
else
Favicon.default
if g.dead
$.addClass Favicon.el, 'dead'
else
$.rmClass Favicon.el, 'dead'
if count
$.addClass Favicon.el, 'unread'
else
$.rmClass Favicon.el, 'unread'
# `favicon.href = href` doesn't work on Firefox
# `favicon.href = href` isn't enough on Opera
# Opera won't always update the favicon if the href didn't change
unless $.engine is 'webkit'
$.add d.head, Favicon.el
Favicon =
init: ->
return if @el # Prevent race condition with options first run
@el = $ 'link[rel="shortcut icon"]', d.head
@el.type = 'image/x-icon'
{href} = @el
@SFW = /ws.ico$/.test href
@default = href
@switch()
switch: ->
switch Conf['favicon']
when 'ferongr'
@unreadDead = 'data:image/gif;base64,R0lGODlhEAAQAOMHAOgLAnMFAL8AAOgLAukMA/+AgP+rq////////////////////////////////////yH5BAEKAAcALAAAAAAQABAAAARZ8MhJ6xwDWIBv+AM1fEEIBIVRlNKYrtpIECuGzuwpCLg974EYiXUYkUItjGbC6VQ4omXFiKROA6qSy0A8nAo9GS3YCswIWnOvLAi0be23Z1QtdSUaqXcviQAAOw=='
@unreadSFW = 'data:image/gif;base64,R0lGODlhEAAQAOMHAADX8QBwfgC2zADX8QDY8nnl8qLp8v///////////////////////////////////yH5BAEKAAcALAAAAAAQABAAAARZ8MhJ6xwDWIBv+AM1fEEIBIVRlNKYrtpIECuGzuwpCLg974EYiXUYkUItjGbC6VQ4omXFiKROA6qSy0A8nAo9GS3YCswIWnOvLAi0be23Z1QtdSUaqXcviQAAOw=='
@unreadNSFW = 'data:image/gif;base64,R0lGODlhEAAQAOMHAFT+ACh5AEncAFT+AFX/Acz/su7/5v///////////////////////////////////yH5BAEKAAcALAAAAAAQABAAAARZ8MhJ6xwDWIBv+AM1fEEIBIVRlNKYrtpIECuGzuwpCLg974EYiXUYkUItjGbC6VQ4omXFiKROA6qSy0A8nAo9GS3YCswIWnOvLAi0be23Z1QtdSUaqXcviQAAOw=='
when 'xat-'
@unreadDead = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAA2ElEQVQ4y61TQQrCMBDMQ8WDIEV6LbT2A4og2Hq0veo7fIAH04dY9N4xmyYlpGmI2MCQTWYy3Wy2DAD7B2wWAzWgcTgVeZKlZRxHNYFi2jM18oBh0IcKtC6ixf22WT4IFLs0owxswXu9egm0Ls6bwfCFfNsJYJKfqoEkd3vgUgFVLWObtzNgVKyruC+ljSzr5OEnBzjvjcQecaQhbZgBb4CmGQw+PoMkTUtdbd8VSEPakcGxPOcsoIgUKy0LecY29BmdBrqRfjIwZ93KLs5loHvBnL3cLH/jF+C/+z5dgUysAAAAAElFTkSuQmCC'
@unreadSFW = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAA30lEQVQ4y2P4//8/AyWYgSoGQMF/GJ7Y11VVUVoyKTM9ey4Ig9ggMWQ1YA1IBvzXm34YjkH8mPyJB+Nqlp8FYRAbmxoMF6ArSNrw6T0Qf8Amh9cFMEWVR/7/A+L/uORxhgEIt5/+/3/2lf//5wAxiI0uj+4CBlBgxVUvOwtydgXQZpDmi2/+/7/0GmIQSAwkB1IDUkuUAZeABlx+g2zAZ9wGlAOjChba+LwAUgNSi2HA5Am9VciBhSsQQWyoWgZiovEDsdGI1QBYQiLJAGQalpSxyWEzAJYWkGm8clTJjQCZ1hkoVG0CygAAAABJRU5ErkJggg=='
@unreadNSFW = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAA4ElEQVQ4y2P4//8/AyWYgSoGQMF/GJ7YNbGqrKRiUnp21lwQBrFBYshqwBqQDPifdsYYjkH8mInxB+OWx58FYRAbmxoMF6ArKPmU9B6IP2CTw+sCmKKe/5X/gPg/LnmcYQDCs/63/1/9fzYQzwGz0eXRXcAACqy4ZfFnQc7u+V/xD6T55v+LQHwJbBBIDCQHUgNSS5QBt4Cab/2/jDDgMx4DykrKJ8FCG58XQGpAajEMmNw7uQo5sHAFIogNVctATDR+IDYasRoAS0gkGYBMw5IyNjlsBsDSAjKNV44quREAx58Mr9vt5wQAAAAASUVORK5CYII='
when 'Mayhem'
@unreadDead = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABIUlEQVQ4jZ2ScWuDMBDFgw4pIkU0WsoQkWAYIkXZH4N9/+/V3dmfXSrKYIFHwt17j8vdGWNMIkgFuaDgzgQnwRs4EQs5KdolUQtagRN0givEDBTEOjgtGs0Zq8F7cKqqusVxrMQLaDUWcjBSrXkn8gs51tpJSWLk9b3HUa0aNIL5gPBR1/V4kJvR7lTwl8GmAm1Gf9+c3S+89qBHa8502AsmSrtBaEBPbIbj0ah2madlNAPEccdgJDfAtWifBjqWKShRBT6KoiH8QlEUn/qt0CCjnNdmPUwmFWzj9Oe6LpKuZXcwqq88z78Pch3aZU3dPwwc2sWlfZKCW5tWluV8kGvXClLm6dYN4/aUqfCbnEOzNDGhGZbNargvxCzvMGfRJD8UaDVvgkzo6QAAAABJRU5ErkJggg=='
@unreadSFW = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABCElEQVQ4jZ2S4crCMAxF+0OGDJEPKYrIGKOsiJSx/fJRfSAfTJNyKqXfiuDg0C25N2RJjTGmEVrhTzhw7oStsIEtsVzT4o2Jo9ALThiEM8IdHIgNaHo8mjNWg6/ske8bohPo+63QOLzmooHp8fyAICBSQkVz0QKdsFQEV6WSW/D+7+BbgbIDHcb4Kp61XyjyI16zZ8JemGltQtDBSGxB4/GoN+7TpkkjDCsFArm0IYv3U0BbnYtf8BCy+JytsE0X6VyuKhPPK/GAJ14kvZZDZVV3pZIb8MZr6n4o4PDGKn0S5SdDmyq5PnXQsk+Xbhinp03FFzmHJw6xYRiWm9VxnohZ3vOcxdO8ARmXRvbWdtzQAAAAAElFTkSuQmCC'
@unreadNSFW = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABCklEQVQ4jZ2S0WrDMAxF/TBCCKWMYhZKCSGYmFJMSNjD/mhf239qJXNcjBdTWODgRLpXKJKNMaYROuFTOHEehFb4gJZYrunwxsSXMApOmIQzwgOciE1oRjyaM1aDj+yR7xuiHvT9VmgcXnPRwO/9+wWCgEgJFc1FCwzCVhFclUpuw/u3g3cFyg50GPOjePZ+ocjPeM2RCXthpbUFwQAzsQ2Nx6PeuE+bJo0w7BQI5NKGLN5XAW11LX7BQ8jia7bCLl2kc7mqTLzuxAOeeJH0Wk6VVf0oldyEN15T948CDm+sMiZRfjK0pZIbUwcd+3TphnF62lR8kXN44hAbhmG5WQNnT8zynucsnuYJhFpBfkMzqD4AAAAASUVORK5CYII='
when 'Original'
@unreadDead = 'data:image/gif;base64,R0lGODlhEAAQAKECAAAAAP8AAP///////yH5BAEKAAMALAAAAAAQABAAAAI/nI95wsqygIRxDgGCBhTrwF3Zxowg5H1cSopS6FrGQ82PU1951ckRmYKJVCXizLRC9kAnT0aIiR6lCFT1cigAADs='
@unreadSFW = 'data:image/gif;base64,R0lGODlhEAAQAKECAAAAAC6Xw////////yH5BAEKAAMALAAAAAAQABAAAAI/nI95wsqygIRxDgGCBhTrwF3Zxowg5H1cSopS6FrGQ82PU1951ckRmYKJVCXizLRC9kAnT0aIiR6lCFT1cigAADs='
@unreadNSFW = 'data:image/gif;base64,R0lGODlhEAAQAKECAAAAAGbMM////////yH5BAEKAAMALAAAAAAQABAAAAI/nI95wsqygIRxDgGCBhTrwF3Zxowg5H1cSopS6FrGQ82PU1951ckRmYKJVCXizLRC9kAnT0aIiR6lCFT1cigAADs='
@unread = if @SFW then @unreadSFW else @unreadNSFW
empty: 'data:image/gif;base64,R0lGODlhEAAQAJEAAAAAAP///9vb2////yH5BAEAAAMALAAAAAAQABAAAAIvnI+pq+D9DBAUoFkPFnbs7lFZKIJOJJ3MyraoB14jFpOcVMpzrnF3OKlZYsMWowAAOw=='
dead: 'data:image/gif;base64,R0lGODlhEAAQAKECAAAAAP8AAP///////yH5BAEKAAIALAAAAAAQABAAAAIvlI+pq+D9DAgUoFkPDlbs7lFZKIJOJJ3MyraoB14jFpOcVMpzrnF3OKlZYsMWowAAOw=='
Redirect =
image: (board, filename) ->
# Do not use g.BOARD, the image url can originate from a cross-quote.
switch board
when 'a', 'jp', 'm', 'sp', 'tg', 'vg'
"//archive.foolz.us/#{board}/full_image/#{filename}"
when 'u'
"//nsfw.foolz.us/#{board}/full_image/#{filename}"
# these will work whenever https://github.com/eksopl/fuuka/issues/23 is done
# when 'cgl', 'g', 'w'
# "//archive.rebeccablacktech.com/#{board}/full_image/#{filename}"
# when 'an', 'toy', 'x'
# "http://archive.maidlab.jp/#{board}/full_image/#{filename}"
# when 'e'
# "https://md401.homelinux.net/4chan/cgi-board.pl/#{board}/full_image/#{filename}"
post: (board, postID) ->
switch board
when 'a', 'co', 'jp', 'm', 'sp', 'tg', 'tv', 'v', 'vg', 'dev', 'foolz'
"//archive.foolz.us/api/chan/post/board/#{board}/num/#{postID}/format/json"
when 'u', 'kuku'
"//nsfw.foolz.us/api/chan/post/board/#{board}/num/#{postID}/format/json"
thread: (board, threadID, postID) ->
# keep the number only if the location.hash was sent f.e.
postID = postID.match(/\d+/)[0] if postID
path =
if threadID
"#{board}/thread/#{threadID}"
else
"#{board}/post/#{postID}"
switch board
when 'a', 'co', 'jp', 'm', 'sp', 'tg', 'tv', 'v', 'vg', 'dev', 'foolz'
url = "//archive.foolz.us/#{path}/"
if threadID and postID
url += "##{postID}"
when 'u', 'kuku'
url = "//nsfw.foolz.us/#{path}/"
if threadID and postID
url += "##{postID}"
when 'ck', 'lit'
url = "//fuuka.warosu.org/#{path}"
if threadID and postID
url += "#p#{postID}"
when 'diy', 'g', 'k', 'sci'
url = "//archive.installgentoo.net/#{path}"
if threadID and postID
url += "#p#{postID}"
when 'cgl', 'mu', 'soc', 'w'
url = "//archive.rebeccablacktech.com/#{path}"
if threadID and postID
url += "#p#{postID}"
when 'an', 'r9k', 'toy', 'x'
url = "http://archive.maidlab.jp/#{path}"
if threadID and postID
url += "#p#{postID}"
when 'e'
url = "https://md401.homelinux.net/4chan/cgi-board.pl/#{path}"
if threadID and postID
url += "#p#{postID}"
else
if threadID
url = "//boards.4chan.org/#{board}/"
url or null
ImageHover =
init: ->
Main.callbacks.push @node
node: (post) ->
return unless post.img
$.on post.img, 'mouseover', ImageHover.mouseover
mouseover: ->
# Make sure to remove the previous image hover
# in case it got stuck. Opera-only bug?
if el = $.id 'ihover'
if el is UI.el
delete UI.el
$.rm el
# 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: ->
src = @src.replace(/\?\d+$/, '').split '/'
unless src[2] is 'images.4chan.org' and url = Redirect.image src[3], src[5]
return if g.dead
# This will fool CloudFlare's cache.
url = "//images.4chan.org/#{src[3]}/src/#{src[5]}?#{Date.now()}"
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
AutoGif =
init: ->
return if g.BOARD in ['gif', 'wsg']
Main.callbacks.push @node
node: (post) ->
{img} = post
return if post.el.hidden or not img
src = img.parentNode.href
if /gif$/.test(src) and !/spoiler/.test img.src
gif = $.el 'img'
$.on gif, 'load', ->
# Replace the thumbnail once the GIF has finished loading.
img.src = src
gif.src = src
ImageExpand =
init: ->
Main.callbacks.push @node
@dialog()
node: (post) ->
return unless post.img
a = post.img.parentNode
$.on a, 'click', ImageExpand.cb.toggle
if ImageExpand.on and !post.el.hidden
ImageExpand.expand post.img
cb:
toggle: (e) ->
return if e.shiftKey or e.altKey or e.ctrlKey or e.metaKey or e.button isnt 0
e.preventDefault()
ImageExpand.toggle @
all: ->
ImageExpand.on = @checked
if ImageExpand.on #expand
thumbs = $$ 'img[data-md5]'
if Conf['Expand From Current']
for thumb, i in thumbs
if thumb.getBoundingClientRect().top > 0
break
thumbs = thumbs[i...]
for thumb in thumbs
ImageExpand.expand thumb
else #contract
for thumb in $$ 'img[data-md5][hidden]'
ImageExpand.contract thumb
return
typeChange: ->
switch @value
when 'full'
klass = ''
when 'fit width'
klass = 'fitwidth'
when 'fit height'
klass = 'fitheight'
when 'fit screen'
klass = 'fitwidth fitheight'
$.id('delform').className = klass
if /\bfitheight\b/.test klass
$.on window, 'resize', ImageExpand.resize
unless ImageExpand.style
ImageExpand.style = $.addStyle ''
ImageExpand.resize()
else if ImageExpand.style
$.off window, 'resize', ImageExpand.resize
toggle: (a) ->
thumb = a.firstChild
if thumb.hidden
rect = a.getBoundingClientRect()
if $.engine is 'webkit'
d.body.scrollTop += rect.top - 42 if rect.top < 0
d.body.scrollLeft += rect.left if rect.left < 0
else
d.documentElement.scrollTop += rect.top - 42 if rect.top < 0
d.documentElement.scrollLeft += rect.left if rect.left < 0
ImageExpand.contract thumb
else
ImageExpand.expand thumb
contract: (thumb) ->
thumb.hidden = false
thumb.nextSibling.hidden = true
$.rmClass thumb.parentNode.parentNode.parentNode, 'image_expanded'
expand: (thumb, url) ->
# Do not expand images of hidden/filtered replies, or already expanded pictures.
return if $.x 'ancestor-or-self::*[@hidden]', thumb
thumb.hidden = true
$.addClass thumb.parentNode.parentNode.parentNode, 'image_expanded'
if img = thumb.nextSibling
# Expand already loaded picture
img.hidden = false
return
a = thumb.parentNode
img = $.el 'img',
src: url or a.href
$.on img, 'error', ImageExpand.error
$.add a, img
error: ->
thumb = @previousSibling
ImageExpand.contract thumb
$.rm @
src = @src.replace(/\?\d+$/, '').split '/'
unless src[2] is 'images.4chan.org' and url = Redirect.image src[3], src[5]
return if g.dead
# This will fool CloudFlare's cache.
url = "//images.4chan.org/#{src[3]}/src/#{src[5]}?#{Date.now()}"
return if $.engine isnt 'webkit' and url.split('/')[2] is 'images.4chan.org'
timeoutID = setTimeout ImageExpand.expand, 10000, thumb, url
# 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'
dialog: ->
controls = $.el 'div',
id: 'imgControls'
innerHTML:
""
imageType = $.get 'imageType', 'full'
select = $ 'select', controls
select.value = imageType
ImageExpand.cb.typeChange.call select
$.on select, 'change', $.cb.value
$.on select, 'change', ImageExpand.cb.typeChange
$.on $('input', controls), 'click', ImageExpand.cb.all
$.prepend $.id('delform'), controls
resize: ->
ImageExpand.style.textContent = ".fitheight img[data-md5] + img {max-height:#{d.documentElement.clientHeight}px;}"
Main =
init: ->
Main.flatten null, Config
path = location.pathname
pathname = path[1..].split '/'
[g.BOARD, temp] = pathname
if temp is 'res'
g.REPLY = true
g.THREAD_ID = pathname[2]
# Load values from localStorage.
for key, val of Conf
Conf[key] = $.get key, val
switch location.hostname
when 'sys.4chan.org'
if /report/.test location.search
$.ready ->
$.on $.id('recaptcha_response_field'), 'keydown', (e) ->
window.location = 'javascript:Recaptcha.reload()' if e.keyCode is 8 and not e.target.value
return
when 'images.4chan.org'
$.ready ->
if /^4chan - 404/.test(d.title) and Conf['404 Redirect']
path = location.pathname.split '/'
url = Redirect.image path[1], path[3]
location.href = url if url
return
$.ready Options.init
if Conf['Quick Reply'] and Conf['Hide Original Post Form']
Main.css += '#postForm { display: none; }'
Main.addStyle()
now = Date.now()
if Conf['Check for Updates'] and $.get('lastUpdate', 0) < now - 6*$.HOUR
$.ready ->
$.on window, 'message', Main.message
$.set 'lastUpdate', now
$.add d.head, $.el 'script',
src: 'https://github.com/MayhemYDG/4chan-x/raw/master/latest.js'
g.hiddenReplies = $.get "hiddenReplies/#{g.BOARD}/", {}
if $.get('lastChecked', 0) < now - 1*$.DAY
$.set 'lastChecked', now
cutoff = now - 7*$.DAY
hiddenThreads = $.get "hiddenThreads/#{g.BOARD}/", {}
for id, timestamp of hiddenThreads
if timestamp < cutoff
delete hiddenThreads[id]
for id, timestamp of g.hiddenReplies
if timestamp < cutoff
delete g.hiddenReplies[id]
$.set "hiddenThreads/#{g.BOARD}/", hiddenThreads
$.set "hiddenReplies/#{g.BOARD}/", g.hiddenReplies
#major features
if Conf['Filter']
Filter.init()
if Conf['Reply Hiding']
ReplyHiding.init()
if Conf['Filter'] or Conf['Reply Hiding']
StrikethroughQuotes.init()
if Conf['Anonymize']
Anonymize.init()
if Conf['Time Formatting']
Time.init()
if Conf['File Info Formatting']
FileInfo.init()
if Conf['Sauce']
Sauce.init()
if Conf['Reveal Spoilers']
RevealSpoilers.init()
if Conf['Image Auto-Gif']
AutoGif.init()
if Conf['Image Hover']
ImageHover.init()
if Conf['Menu']
Menu.init()
if Conf['Report Link']
ReportLink.init()
if Conf['Delete Link']
DeleteLink.init()
if Conf['Download Link']
DownloadLink.init()
if Conf['Archive Link']
ArchiveLink.init()
if Conf['Resurrect Quotes']
Quotify.init()
if Conf['Quote Inline']
QuoteInline.init()
if Conf['Quote Preview']
QuotePreview.init()
if Conf['Quote Backlinks']
QuoteBacklink.init()
if Conf['Indicate OP quote']
QuoteOP.init()
if Conf['Indicate Cross-thread Quotes']
QuoteCT.init()
$.ready Main.ready
ready: ->
if /^4chan - 404/.test d.title
if Conf['404 Redirect'] and /^\d+$/.test g.THREAD_ID
location.href = Redirect.thread g.BOARD, g.THREAD_ID, location.hash
return
unless $.id 'navtopr'
return
$.addClass d.body, $.engine
$.addClass d.body, 'fourchan_x'
for nav in ['boardNavDesktop', 'boardNavDesktopFoot']
if a = $ "a[href$='/#{g.BOARD}/']", $.id nav
# Gotta make it work in temporary boards.
$.addClass a, 'current'
Favicon.init()
# Major features.
if Conf['Quick Reply']
QR.init()
if Conf['Image Expansion']
ImageExpand.init()
if Conf['Thread Watcher']
setTimeout -> Watcher.init()
if Conf['Keybinds']
setTimeout -> Keybinds.init()
if g.REPLY
if Conf['Thread Updater']
setTimeout -> Updater.init()
if Conf['Thread Stats']
ThreadStats.init()
if Conf['Reply Navigation']
setTimeout -> Nav.init()
if Conf['Post in Title']
TitlePost.init()
if Conf['Unread Count'] or Conf['Unread Favicon']
Unread.init()
else #not reply
if Conf['Thread Hiding']
ThreadHiding.init()
if Conf['Thread Expansion']
setTimeout -> ExpandThread.init()
if Conf['Comment Expansion']
setTimeout -> ExpandComment.init()
if Conf['Index Navigation']
setTimeout -> Nav.init()
board = $ '.board'
nodes = []
for node in $$ '.postContainer', board
nodes.push Main.preParse node
Main.node nodes, true
# Execute these scripts on inserted posts, not page init.
Main.hasCodeTags = !! $ 'script[src="//static.4chan.org/js/prettify/prettify.js"]'
if MutationObserver = window.WebKitMutationObserver or window.MozMutationObserver or window.OMutationObserver or window.MutationObserver
observer = new MutationObserver Main.observer
observer.observe board,
childList: true
subtree: true
else
$.on board, 'DOMNodeInserted', Main.listener
return
flatten: (parent, obj) ->
if obj instanceof Array
Conf[parent] = obj[0]
else if typeof obj is 'object'
for key, val of obj
Main.flatten key, val
else # string or number
Conf[parent] = obj
return
addStyle: ->
$.off d, 'DOMNodeInserted', Main.addStyle
if d.head
$.addStyle Main.css
else # XXX fox
$.on d, 'DOMNodeInserted', Main.addStyle
message: (e) ->
{version} = e.data
if version and version isnt Main.version and confirm 'An updated version of 4chan X is available, would you like to install it now?'
window.location = "https://raw.github.com/mayhemydg/4chan-x/#{version}/4chan_x.user.js"
preParse: (node) ->
parentClass = node.parentNode.className
el = $ '.post', node
post =
root: node
el: el
class: el.className
ID: el.id.match(/\d+$/)[0]
threadID: g.THREAD_ID or $.x('ancestor::div[parent::div[@class="board"]]', node).id.match(/\d+$/)[0]
isArchived: /\barchivedPost\b/.test parentClass
isInlined: /\binline\b/.test parentClass
isCrosspost: /\bcrosspost\b/.test parentClass
blockquote: el.lastElementChild
quotes: el.getElementsByClassName 'quotelink'
backlinks: el.getElementsByClassName 'backlink'
fileInfo: false
img: false
if img = $ 'img[data-md5]', el
# Make sure to not add deleted images,
# those do not have a data-md5 attribute.
post.fileInfo = img.parentNode.previousElementSibling
post.img = img
Main.prettify post.blockquote
post
node: (nodes, notify) ->
for callback in Main.callbacks
try
callback node for node in nodes
catch err
alert "4chan X (#{Main.version}) error: #{err.message}\nReport the bug at mayhemydg.github.com/4chan-x/#bug-report\n\nURL: #{window.location}\n#{err.stack}" if notify
return
observer: (mutations) ->
nodes = []
for mutation in mutations
for addedNode in mutation.addedNodes
if /\bpostContainer\b/.test addedNode.className
nodes.push Main.preParse addedNode
Main.node nodes if nodes.length
listener: (e) ->
{target} = e
if /\bpostContainer\b/.test target.className
Main.node [Main.preParse target]
prettify: (bq) ->
return unless Main.hasCodeTags
code = ->
for pre in document.getElementById('_id_').getElementsByClassName 'prettyprint'
pre.innerHTML = prettyPrintOne pre.innerHTML.replace /\s/g, ' '
return
$.globalEval "(#{code})()".replace '_id_', bq.id
namespace: '4chan_x.'
version: '2.33.7'
callbacks: []
css: '
/* dialog styling */
.dialog.reply {
display: block;
border: 1px solid rgba(0,0,0,.25);
padding: 0;
}
.move {
cursor: move;
}
label, .favicon {
cursor: pointer;
}
a[href="javascript:;"] {
text-decoration: none;
}
.warning {
color: red;
}
.hide_thread_button:not(.hidden_thread) {
float: left;
}
.thread > .hidden_thread ~ *,
[hidden],
#content > [name=tab]:not(:checked) + div,
#updater:not(:hover) > :not(.move),
.autohide:not(:hover) > form,
#qp input, #qp .inline, .forwarded {
display: none !important;
}
#menu {
position: absolute;
outline: none;
}
.entry {
border-bottom: 1px solid rgba(0, 0, 0, .25);
display: block;
outline: none;
padding: 3px 7px;
text-decoration: none;
}
.entry:last-child {
border: none;
}
.focused.entry {
background: rgba(255, 255, 255, .33);
}
h1 {
text-align: center;
}
#qr > .move {
min-width: 300px;
overflow: hidden;
box-sizing: border-box;
-moz-box-sizing: border-box;
padding: 0 2px;
}
#qr > .move > span {
float: right;
}
#autohide, .close, #qr select, #dump, .remove, .captchaimg, #qr div.warning {
cursor: pointer;
}
#qr select,
#qr > form {
margin: 0;
}
#dump {
background: -webkit-linear-gradient(#EEE, #CCC);
background: -moz-linear-gradient(#EEE, #CCC);
background: -o-linear-gradient(#EEE, #CCC);
background: linear-gradient(#EEE, #CCC);
width: 10%;
padding: -moz-calc(1px) 0 2px;
}
#dump:hover, #dump:focus {
background: -webkit-linear-gradient(#FFF, #DDD);
background: -moz-linear-gradient(#FFF, #DDD);
background: -o-linear-gradient(#FFF, #DDD);
background: linear-gradient(#FFF, #DDD);
}
#dump:active, .dump #dump:not(:hover):not(:focus) {
background: -webkit-linear-gradient(#CCC, #DDD);
background: -moz-linear-gradient(#CCC, #DDD);
background: -o-linear-gradient(#CCC, #DDD);
background: linear-gradient(#CCC, #DDD);
}
#qr:not(.dump) #replies, .dump > form > label {
display: none;
}
#replies {
display: block;
height: 100px;
position: relative;
-webkit-user-select: none;
-moz-user-select: none;
-o-user-select: none;
user-select: none;
}
#replies > div {
counter-reset: thumbnails;
top: 0; right: 0; bottom: 0; left: 0;
margin: 0; padding: 0;
overflow: hidden;
position: absolute;
white-space: pre;
}
#replies > div:hover {
bottom: -10px;
overflow-x: auto;
z-index: 1;
}
.thumbnail {
background-color: rgba(0,0,0,.2) !important;
background-position: 50% 20% !important;
background-size: cover !important;
border: 1px solid #666;
box-sizing: border-box;
-moz-box-sizing: border-box;
cursor: move;
display: inline-block;
height: 90px; width: 90px;
margin: 5px; padding: 2px;
opacity: .5;
outline: none;
overflow: hidden;
position: relative;
text-shadow: 0 1px 1px #000;
-webkit-transition: opacity .25s ease-in-out;
-moz-transition: opacity .25s ease-in-out;
-o-transition: opacity .25s ease-in-out;
transition: opacity .25s ease-in-out;
vertical-align: top;
}
.thumbnail:hover, .thumbnail:focus {
opacity: .9;
}
.thumbnail#selected {
opacity: 1;
}
.thumbnail::before {
counter-increment: thumbnails;
content: counter(thumbnails);
color: #FFF;
font-weight: 700;
padding: 3px;
position: absolute;
top: 0;
right: 0;
text-shadow: 0 0 3px #000, 0 0 8px #000;
}
.thumbnail.drag {
box-shadow: 0 0 10px rgba(0,0,0,.5);
}
.thumbnail.over {
border-color: #FFF;
}
.thumbnail > span {
color: #FFF;
}
.remove {
background: none;
color: #E00;
font-weight: 700;
padding: 3px;
}
.remove:hover::after {
content: " Remove";
}
.thumbnail > label {
background: rgba(0,0,0,.5);
color: #FFF;
right: 0; bottom: 0; left: 0;
position: absolute;
text-align: center;
}
.thumbnail > label > input {
margin: 0;
}
#addReply {
color: #333;
font-size: 3.5em;
line-height: 100px;
}
#addReply:hover, #addReply:focus {
color: #000;
}
.field {
border: 1px solid #CCC;
box-sizing: border-box;
-moz-box-sizing: border-box;
color: #333;
font: 13px sans-serif;
margin: 0;
padding: 2px 4px 3px;
-webkit-transition: color .25s, border .25s;
-moz-transition: color .25s, border .25s;
-o-transition: color .25s, border .25s;
transition: color .25s, border .25s;
}
.field:-moz-placeholder,
.field:hover:-moz-placeholder {
color: #AAA;
}
.field:hover, .field:focus {
border-color: #999;
color: #000;
outline: none;
}
#qr > form > div:first-child > .field:not(#dump) {
width: 30%;
}
#qr textarea.field {
display: -webkit-box;
min-height: 120px;
min-width: 100%;
}
.textarea {
position: relative;
}
#charCount {
color: #000;
background: hsla(0, 0%, 100%, .5);
position: absolute;
top: 100%;
right: 0;
}
#charCount.warning {
color: red;
}
.captchainput > .field {
min-width: 100%;
}
.captchaimg {
background: #FFF;
outline: 1px solid #CCC;
outline-offset: -1px;
text-align: center;
}
.captchaimg > img {
display: block;
height: 57px;
width: 300px;
}
#qr [type=file] {
margin: 1px 0;
width: 70%;
}
#qr [type=submit] {
margin: 1px 0;
padding: 1px; /* not Gecko */
padding: 0 -moz-calc(1px); /* Gecko does not respect box-sizing: border-box */
width: 30%;
}
.fileText:hover .fntrunc,
.fileText:not(:hover) .fnfull {
display: none;
}
.fitwidth img[data-md5] + img {
max-width: 100%;
}
.gecko .fitwidth img[data-md5] + img,
.presto .fitwidth img[data-md5] + img {
width: 100%;
}
#qr, #qp, #updater, #stats, #ihover, #overlay, #navlinks {
position: fixed;
}
#ihover {
max-height: 97%;
max-width: 75%;
padding-bottom: 18px;
}
#navlinks {
font-size: 16px;
top: 25px;
right: 5px;
}
body {
box-sizing: border-box;
-moz-box-sizing: border-box;
}
body.unscroll {
overflow: hidden;
}
#overlay {
top: 0;
right: 0;
left: 0;
bottom: 0;
text-align: center;
background: rgba(0,0,0,.5);
z-index: 1;
}
#overlay::after {
content: "";
display: inline-block;
height: 100%;
vertical-align: middle;
}
#options {
display: inline-block;
padding: 5px;
text-align: left;
vertical-align: middle;
width: 600px;
}
#credits {
float: right;
}
#options ul {
padding: 0;
}
#options article li {
margin: 10px 0 10px 2em;
}
#options code {
background: hsla(0, 0%, 100%, .5);
color: #000;
padding: 0 1px;
}
#options label {
text-decoration: underline;
}
#content {
height: 450px;
overflow: auto;
}
#content textarea {
font-family: monospace;
min-height: 350px;
resize: vertical;
width: 100%;
}
#updater {
text-align: right;
}
#updater:not(:hover) {
border: none;
background: transparent;
}
.new {
background: lime;
}
#watcher {
padding-bottom: 5px;
position: absolute;
overflow: hidden;
white-space: nowrap;
}
#watcher:not(:hover) {
max-height: 220px;
}
#watcher > div {
max-width: 200px;
overflow: hidden;
padding-left: 5px;
padding-right: 5px;
text-overflow: ellipsis;
}
#watcher > .move {
padding-top: 5px;
text-decoration: underline;
}
#qp {
padding: 2px 2px 5px;
}
#qp .post {
border: none;
margin: 0;
padding: 0;
}
#qp img {
max-height: 300px;
max-width: 500px;
}
.qphl {
outline: 2px solid rgba(216, 94, 49, .7);
}
.inlined {
opacity: .5;
}
.inline {
background-color: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(128, 128, 128, 0.5);
display: table;
margin: 2px;
padding: 2px;
}
.inline .post {
background: none;
border: none;
margin: 0;
padding: 0;
}
div.opContainer {
display: block !important;
}
.opContainer.filter_highlight {
box-shadow: inset 5px 0 rgba(255,0,0,0.5);
}
.filter_highlight > .reply {
box-shadow: -5px 0 rgba(255,0,0,0.5);
}
.filtered {
text-decoration: underline line-through;
}
.quotelink.forwardlink,
.backlink.forwardlink {
text-decoration: none;
border-bottom: 1px dashed;
}
'
Main.init()