"""
$.on $('.export', section), 'click', Settings.export
$.on $('.import', section), 'click', Settings.import
$.on $('input', section), 'change', Settings.onImport
for key, obj of Config.main
fs = $.el 'fieldset',
innerHTML: ""
for key, arr of obj
checked = if $.get(key, Conf[key]) then 'checked' else ''
description = arr[1]
div = $.el 'div',
innerHTML: ": #{description}"
$.on $('input', div), 'click', $.cb.checked
$.add fs, div
$.add section, fs
hiddenNum = 0
for ID, thread of ThreadHiding.getHiddenThreads().threads
hiddenNum++
for ID, thread of ReplyHiding.getHiddenPosts().threads
for ID, post of thread
hiddenNum++
div = $.el 'div',
innerHTML: ": Clear manually hidden threads and posts on /#{g.BOARD}/."
$.on $('button', div), 'click', ->
@textContent = 'Hidden: 0'
$.delete "hiddenThreads.#{g.BOARD}"
$.delete "hiddenPosts.#{g.BOARD}"
$.after $('input[name="Stubs"]', section).parentNode.parentNode, div
export: ->
now = Date.now()
data =
version: g.VERSION
date: now
Conf: Conf
WatchedThreads: $.get('WatchedThreads', {})
a = $.el 'a',
className: 'warning'
textContent: 'Save me!'
download: "<%= meta.name %> v#{g.VERSION}-#{now}.json"
href: "data:application/json;base64,#{btoa unescape encodeURIComponent JSON.stringify data, null, 2}"
target: '_blank'
if $.engine isnt 'gecko'
a.click()
return
# XXX Firefox won't let us download automatically.
output = @parentNode.nextElementSibling
output.innerHTML = null
$.add output, a
import: ->
@nextElementSibling.click()
onImport: ->
return unless file = @files[0]
output = @parentNode.nextElementSibling
unless confirm 'Your current settings will be entirely overwritten, are you sure?'
output.textContent = 'Import aborted.'
return
reader = new FileReader()
reader.onload = (e) ->
try
data = JSON.parse decodeURIComponent escape e.target.result
Settings.loadSettings data
if confirm 'Import successful. Refresh now?'
window.location.reload()
catch err
output.textContent = 'Import failed due to an error.'
c.log err.stack
reader.readAsText file
loadSettings: (data) ->
version = data.version.split '.'
if version[0] is '2'
data = Settings.convertSettings data,
# General confs
'Disable 4chan\'s extension': ''
'Catalog Links': ''
'Reply Navigation': ''
'Show Stubs': 'Stubs'
'Image Auto-Gif': 'Auto-GIF'
'Expand From Current': ''
'Unread Favicon': 'Unread Tab Icon'
'Post in Title': 'Thread Excerpt'
'Auto Hide QR': ''
'Open Reply in New Tab': ''
'Remember QR size': ''
'Quote Inline': 'Quote Inlining'
'Quote Preview': 'Quote Previewing'
'Indicate OP quote': 'Mark OP Quotes'
'Indicate Cross-thread Quotes': 'Mark Cross-thread Quotes'
# filter
'uniqueid': 'uniqueID'
'mod': 'capcode'
'country': 'flag'
'md5': 'MD5'
# keybinds
'openEmptyQR': 'Open empty QR'
'openQR': 'Open QR'
'openOptions': 'Open settings'
'close': 'Close'
'spoiler': 'Spoiler tags'
'code': 'Code tags'
'submit': 'Submit QR'
'watch': 'Watch'
'update': 'Update'
'unreadCountTo0': ''
'expandAllImages': 'Expand images'
'expandImage': 'Expand image'
'zero': 'Front page'
'nextPage': 'Next page'
'previousPage': 'Previous page'
'nextThread': 'Next thread'
'previousThread': 'Previous thread'
'expandThread': 'Expand thread'
'openThreadTab': 'Open thread'
'openThread': 'Open thread tab'
'nextReply': 'Next reply'
'previousReply': 'Previous reply'
'hide': 'Hide'
# updater
'Scrolling': 'Auto Scroll'
'Verbose': ''
data.Conf.sauces = data.Conf.sauces.replace /\$\d/g, (c) ->
switch c
when '$1'
'%TURL'
when '$2'
'%URL'
when '$3'
'%MD5'
when '$4'
'%board'
else
c
for key, val of Config.hotkeys
continue unless key of data.Conf
data.Conf[key] = data.Conf[key].replace(/ctrl|alt|meta/g, (s) -> "#{s[0].toUpperCase()}#{s[1..]}").replace /(^|.+\+)[A-Z]$/g, (s) ->
"Shift+#{s[0...-1]}#{s[-1..].toLowerCase()}"
for key, val of data.Conf
$.set key, val
$.set 'WatchedThreads', data.WatchedThreads
convertSettings: (data, map) ->
for prevKey, newKey of map
data.Conf[newKey] = data.Conf[prevKey] if newKey
delete data.Conf[prevKey]
data
filter: (section) ->
section.innerHTML = """
"""
select = $ 'select', section
$.on select, 'change', Settings.selectFilter
Settings.selectFilter.call select
selectFilter: ->
div = @nextElementSibling
if (name = @value) isnt 'guide'
div.innerHTML = null
ta = $.el 'textarea',
name: name
className: 'field'
value: $.get name, Conf[name]
spellcheck: false
$.on ta, 'change', $.cb.value
$.add div, ta
return
div.innerHTML = """
Filter is disabled.
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.
MD5 filtering uses exact string matching, not regular expressions.
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`), or both (`yes`, this is default).
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;.
"""
sauce: (section) ->
section.innerHTML = """
Sauce is disabled.
Lines starting with a # will be ignored.
You can specify a display text by appending ;text:[text] to the URL.
These parameters will be replaced by their corresponding values:
%TURL: Thumbnail URL.
%URL: Full image URL.
%MD5: MD5 hash.
%board: Current board.
"""
sauce = $ 'textarea', section
sauce.value = $.get 'sauces', Conf['sauces']
$.on sauce, 'change', $.cb.value
rice: (section) ->
section.innerHTML = """
"""
for name in ['boardnav', 'time', 'backlink', 'fileInfo', 'favicon', 'usercss']
input = $ "[name=#{name}]", section
input.value = $.get name, Conf[name]
event = if name in ['favicon', 'usercss']
'change'
else
'input'
$.on input, event, $.cb.value
unless name in ['usercss']
$.on input, event, Settings[name]
Settings[name].call input
$.on $.id('apply-css'), 'click', Settings.usercss
boardnav: ->
Header.generateBoardList @value
time: ->
funk = Time.createFunc @value
@nextElementSibling.textContent = funk Time, new Date()
backlink: ->
@nextElementSibling.textContent = Conf['backlink'].replace /%id/, '123456789'
fileInfo: ->
data =
isReply: true
file:
URL: '//images.4chan.org/g/src/1334437723720.jpg'
name: 'd9bb2efc98dd0df141a94399ff5880b7.jpg'
size: '276 KB'
sizeInBytes: 276 * 1024
dimensions: '1280x720'
isImage: true
isSpoiler: true
funk = FileInfo.createFunc @value
@nextElementSibling.innerHTML = funk FileInfo, data
favicon: ->
Favicon.switch()
Unread.update() if g.VIEW is 'thread' and Conf['Unread Tab Icon']
@nextElementSibling.innerHTML = """
"""
usercss: ->
if Conf['Custom CSS']
CustomCSS.update()
else
CustomCSS.rmStyle()
keybinds: (section) ->
section.innerHTML = """
'
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 =
threadExcerpt: (thread) ->
{OP} = thread
excerpt = OP.info.subject?.trim() or
OP.info.comment.replace(/\n+/g, ' // ') or
Conf['Anonymize'] and 'Anonymous' or
$('.nameBlock', OP.nodes.info).textContent.trim()
"/#{thread.board}/ - #{excerpt}"
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
postFromNode: (root) ->
Get.postFromRoot $.x 'ancestor::div[contains(@class,"postContainer")][1]', root
contextFromLink: (quotelink) ->
Get.postFromRoot $.x 'ancestor::div[parent::div[@class="thread"]][1]', quotelink
postDataFromLink: (link) ->
if link.hostname 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 = link.dataset.threadid or 0
postID = link.dataset.postid
return {
board: board
threadID: +threadID
postID: +postID
}
allQuotelinksLinkingTo: (post) ->
# Get quotelinks & backlinks linking to the given post.
quotelinks = []
# First:
# In every posts,
# if it did quote this post,
# get all their backlinks.
for ID, quoterPost of g.posts
if post.fullID in quoterPost.quotes
for quoterPost in [quoterPost].concat quoterPost.clones
quotelinks.push.apply quotelinks, quoterPost.nodes.quotelinks
# Second:
# If we have quote backlinks:
# in all posts this post quoted
# and their clones,
# get all of their backlinks.
if Conf['Quote Backlinks']
for quote in post.quotes
continue unless quotedPost = g.posts[quote]
for quotedPost in [quotedPost].concat quotedPost.clones
quotelinks.push.apply quotelinks, [quotedPost.nodes.backlinks...]
# Third:
# Filter out irrelevant quotelinks.
quotelinks.filter (quotelink) ->
{board, postID} = Get.postDataFromLink quotelink
board is post.board.ID and postID is post.ID
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 not in [200, 304]
# 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.statusText} (#{req.status})."
return
posts = JSON.parse(req.response).posts
Build.spoilerRange[board] = posts[0].custom_spoiler
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.archivedPost @, 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]'
''
comment = bq.innerHTML
# greentext
.replace(/(^|>)(>[^<$]*)(<|$)/g, '$1$2$3')
# quotes
.replace /((>){2}(>\/[a-z\d]+\/)?\d+)/g, '$1'
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 encodeURI 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
Misc = # super semantic
clearThreads: (key) ->
return unless data = $.get key
unless Object.keys(data.threads).length
$.delete key
return
return if data.lastChecked > Date.now() - 12 * $.HOUR
$.ajax "//api.4chan.org/#{g.BOARD}/threads.json", onload: ->
threads = {}
for page in JSON.parse @response
for thread in page.threads
if thread.no of data.threads
threads[thread.no] = data.threads[thread.no]
unless Object.keys(threads).length
$.delete key
return
data.threads = threads
data.lastChecked = Date.now()
$.set key, data
Quotify =
init: ->
return if g.VIEW is 'catalog' or !Conf['Resurrect Quotes']
Post::callbacks.push
name: 'Resurrect Quotes'
cb: @node
node: ->
return if @isClone
for deadlink in $$ '.deadlink', @nodes.comment
if deadlink.parentNode.className is 'prettyprint'
# Don't quotify deadlinks inside code tags,
# un-`span` them.
$.replace deadlink, [deadlink.childNodes...]
continue
quote = deadlink.textContent
continue unless 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]
unless post.isDead
# 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
# Replace the .deadlink span if we can redirect.
a = $.el 'a',
href: "/#{board}/#{post.thread}/res/#p#{ID}"
className: 'quotelink deadlink'
target: '_blank'
textContent: "#{quote}\u00A0(Dead)"
a.setAttribute 'data-board', board
a.setAttribute 'data-threadid', post.thread.ID
a.setAttribute 'data-postid', ID
else if redirect = Redirect.to {board, threadID: 0, postID: ID}
# Replace the .deadlink span if we can redirect.
a = $.el 'a',
href: redirect
className: 'deadlink'
target: '_blank'
textContent: "#{quote}\u00A0(Dead)"
if Redirect.post board, ID
# Make it function as a normal quote if we can fetch the post.
$.addClass a, 'quotelink'
a.setAttribute 'data-board', board
a.setAttribute 'data-postid', ID
unless quoteID in @quotes
@quotes.push quoteID
unless a
deadlink.textContent += "\u00A0(Dead)"
continue
$.replace deadlink, a
if $.hasClass a, 'quotelink'
@nodes.quotelinks.push a
return
QuoteInline =
init: ->
return if g.VIEW is 'catalog' or !Conf['Quote Inlining']
Post::callbacks.push
name: 'Quote Inlining'
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 @
context = Get.contextFromLink @
if $.hasClass @, 'inlined'
QuoteInline.rm @, board, threadID, postID, context
else
return if $.x "ancestor::div[@id='p#{postID}']", @
QuoteInline.add @, board, threadID, postID, context
@classList.toggle 'inlined'
findRoot: (quotelink, isBacklink) ->
if isBacklink
quotelink.parentNode.parentNode
else
$.x 'ancestor-or-self::*[parent::blockquote][1]', quotelink
add: (quotelink, board, threadID, postID, context) ->
isBacklink = $.hasClass quotelink, 'backlink'
inline = $.el 'div',
id: "i#{postID}"
className: 'inline'
$.after QuoteInline.findRoot(quotelink, isBacklink), inline
Get.postClone board, threadID, postID, inline, context
return unless (post = g.posts["#{board}.#{postID}"]) and
context.thread is post.thread
# 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 posts.
if Unread.posts and (i = Unread.posts.indexOf post) isnt -1
Unread.posts.splice i, 1
Unread.update()
rm: (quotelink, board, threadID, postID, context) ->
isBacklink = $.hasClass quotelink, 'backlink'
# Select the corresponding inlined quote, and remove it.
root = QuoteInline.findRoot quotelink, isBacklink
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
# Decrease forward count and unhide.
if Conf['Forward Hiding'] and
isBacklink and
context.thread is g.threads["#{board}.#{threadID}"] and
not --post.forwarded
delete post.forwarded
$.rmClass post.nodes.root, 'forwarded'
# Repeat.
while inlined = $ '.inlined', el
{board, threadID, postID} = Get.postDataFromLink inlined
QuoteInline.rm inlined, board, threadID, postID, context
$.rmClass inlined, 'inlined'
return
QuotePreview =
init: ->
return if g.VIEW is 'catalog' or !Conf['Quote Previewing']
Post::callbacks.push
name: 'Quote Previewing'
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'
{board, threadID, postID} = Get.postDataFromLink @
qp = $.el 'div',
id: 'qp'
className: 'dialog'
$.add d.body, qp
Get.postClone board, threadID, postID, qp, Get.contextFromLink @
UI.hover
root: @
el: qp
latestEvent: e
endEvents: 'mouseout click'
cb: QuotePreview.mouseout
asapTest: -> qp.firstElementChild
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.root, '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: ->
# Stop if it only contains text.
return unless root = @el.firstElementChild
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.root, '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: ->
return if g.VIEW is 'catalog' or !Conf['Quote Backlinks']
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#{@}"
className: if @isHidden then 'filtered backlink' else 'backlink'
textContent: QuoteBacklink.funk @ID
for quote in @quotes
containers = [QuoteBacklink.getContainer quote]
if (post = g.posts[quote]) and post.nodes.backlinkContainer
# Don't add OP clones when OP Backlinks is disabled,
# as the clones won't have the backlink containers.
for clone in post.clones
containers.push clone.nodes.backlinkContainer
for container in containers
link = a.cloneNode true
if Conf['Quote Previewing']
$.on link, 'mouseover', QuotePreview.mouseover
if Conf['Quote Inlining']
$.on link, 'click', QuoteInline.toggle
$.add container, [$.tn(' '), link]
return
secondNode: ->
if @isClone and (@origin.isReply or Conf['OP Backlinks'])
@nodes.backlinkContainer = $ '.container', @nodes.info
return
# Don't backlink the OP.
return unless @isReply or Conf['OP Backlinks']
container = QuoteBacklink.getContainer @fullID
@nodes.backlinkContainer = container
$.add @nodes.info, container
getContainer: (id) ->
@containers[id] or=
$.el 'span', className: 'container'
QuoteYou =
init: ->
return if g.VIEW is 'catalog' or !Conf['Mark Quotes of You'] or !Conf['Quick Reply']
# \u00A0 is nbsp
@text = '\u00A0(You)'
Post::callbacks.push
name: 'Mark Quotes of You'
cb: @node
node: ->
# Stop there if it's a clone.
return if @isClone
# Stop there if there's no quotes in that post.
return unless (quotes = @quotes).length
{quotelinks} = @nodes
for quotelink in quotelinks
{threadID, postID} = Get.postDataFromLink quotelink
if (thread = QR.yourPosts.threads[threadID]) and postID in thread
$.add quotelink, $.tn QuoteYou.text
return
QuoteOP =
init: ->
return if g.VIEW is 'catalog' or !Conf['Mark OP Quotes']
# \u00A0 is nbsp
@text = '\u00A0(OP)'
Post::callbacks.push
name: 'Mark 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
# rm (OP) from cross-thread quotes.
if @isClone and @thread.fullID in quotes
for quotelink in quotelinks
quotelink.textContent = quotelink.textContent.replace QuoteOP.text, ''
op = (if @isClone then @context else @).thread.fullID
# add (OP) to quotes quoting this context's OP.
return unless op in quotes
for quotelink in quotelinks
{board, postID} = Get.postDataFromLink quotelink
if "#{board}.#{postID}" is op
$.add quotelink, $.tn QuoteOP.text
return
QuoteCT =
init: ->
return if g.VIEW is 'catalog' or !Conf['Mark Cross-thread Quotes']
# \u00A0 is nbsp
@text = '\u00A0(Cross-thread)'
Post::callbacks.push
name: 'Mark 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
{board, thread} = if @isClone then @context else @
for quotelink in quotelinks
data = Get.postDataFromLink quotelink
continue unless data.threadID # deadlink
if @isClone
quotelink.textContent = quotelink.textContent.replace QuoteCT.text, ''
if data.board is @board.ID and data.threadID isnt thread.ID
$.add quotelink, $.tn QuoteCT.text
return
Anonymize =
init: ->
return if g.VIEW is 'catalog' or !Conf['Anonymize']
Post::callbacks.push
name: 'Anonymize'
cb: @node
node: ->
return if @info.capcode or @isClone
{name, tripcode, email} = @nodes
if @info.name isnt 'Anonymous'
name.textContent = 'Anonymous'
if tripcode
$.rm tripcode
delete @nodes.tripcode
if @info.email
if /sage/i.test @info.email
email.href = 'mailto:sage'
else
$.replace email, name
delete @nodes.email
Time =
init: ->
return if g.VIEW is 'catalog' or !Conf['Time Formatting']
@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
RelativeDates =
INTERVAL: $.MINUTE / 2
init: ->
return if g.VIEW is 'catalog' or !Conf['Relative Post Dates']
# Flush when page becomes visible again or when the thread updates.
$.on d, 'visibilitychange ThreadUpdate', @flush
# Start the timeout.
@flush()
Post::callbacks.push
name: 'Relative Post Dates'
cb: @node
node: ->
return if @isClone
# Show original absolute time as tooltip so users can still know exact times
# Since "Time Formatting" runs its `node` before us, the title tooltip will
# pick up the user-formatted time instead of 4chan time when enabled.
dateEl = @nodes.date
dateEl.title = dateEl.textContent
RelativeDates.setUpdate @
# diff is milliseconds from now.
relative: (diff, now, date) ->
unit = if (number = (diff / $.DAY)) >= 1
years = now.getYear() - date.getYear()
months = now.getMonth() - date.getMonth()
days = now.getDate() - date.getDate()
if years > 1
number = years - (months < 0 or months is 0 and days < 0)
'year'
else if years is 1 and (months > 0 or months is 0 and days >= 0)
number = years
'year'
else if (months = (months+12)%12 ) > 1
number = months - (days < 0)
'month'
else if months is 1 and days >= 0
number = months
'month'
else
'day'
else if (number = (diff / $.HOUR)) >= 1
'hour'
else if (number = (diff / $.MINUTE)) >= 1
'minute'
else
# prevent "-1 seconds ago"
number = Math.max(0, diff) / $.SECOND
'second'
rounded = Math.round number
unit += 's' if rounded isnt 1 # pluralize
"#{rounded} #{unit} ago"
# Changing all relative dates as soon as possible incurs many annoying
# redraws and scroll stuttering. Thus, sacrifice accuracy for UX/CPU economy,
# and perform redraws when the DOM is otherwise being manipulated (and scroll
# stuttering won't be noticed), falling back to INTERVAL while the page
# is visible.
#
# Each individual dateTime element will add its update() function to the stale list
# when it is to be called.
stale: []
flush: ->
# No point in changing the dates until the user sees them.
return if d.hidden
now = new Date()
update now for update in RelativeDates.stale
RelativeDates.stale = []
# Reset automatic flush.
clearTimeout RelativeDates.timeout
RelativeDates.timeout = setTimeout RelativeDates.flush, RelativeDates.INTERVAL
# Create function `update()`, closed over post, that, when called
# from `flush()`, updates the elements, and re-calls `setOwnTimeout()` to
# re-add `update()` to the stale list later.
setUpdate: (post) ->
setOwnTimeout = (diff) ->
delay = if diff < $.MINUTE
$.SECOND - (diff + $.SECOND / 2) % $.SECOND
else if diff < $.HOUR
$.MINUTE - (diff + $.MINUTE / 2) % $.MINUTE
else if diff < $.DAY
$.HOUR - (diff + $.HOUR / 2) % $.HOUR
else
$.DAY - (diff + $.DAY / 2) % $.DAY
setTimeout markStale, delay
update = (now) ->
{date} = post.info
diff = now - date
relative = RelativeDates.relative diff, now, date
for singlePost in [post].concat post.clones
singlePost.nodes.date.textContent = relative
setOwnTimeout diff
markStale = -> RelativeDates.stale.push update
# Kick off initial timeout.
update new Date()
FileInfo =
init: ->
return if g.VIEW is 'catalog' or !Conf['File Info Formatting']
@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 /%(.)/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:
t: -> @file.URL.match(/\d+\..+$/)[0]
T: -> "#{FileInfo.formatters.t.call @}"
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: -> @file.size
B: -> FileInfo.convertUnit @file.sizeInBytes, 'B'
K: -> FileInfo.convertUnit @file.sizeInBytes, 'KB'
M: -> FileInfo.convertUnit @file.sizeInBytes, 'MB'
r: -> if @file.isImage then @file.dimensions else 'PDF'
Sauce =
init: ->
return if g.VIEW is 'catalog' or !Conf['Sauce']
links = []
for link in Conf['sauces'].split '\n'
continue if link[0] is '#'
links.push @createSauceLink link.trim()
return unless links.length
@links = links
@link = $.el 'a', target: '_blank'
Post::callbacks.push
name: 'Sauce'
cb: @node
createSauceLink: (link) ->
link = link.replace /%(T?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:.+$/, ''
Function 'post', 'a', """
a.href = '#{link}';
a.textContent = '#{text}';
return a;
"""
node: ->
return if @isClone or !@file
nodes = []
for link in Sauce.links
# \u00A0 is nbsp
nodes.push $.tn('\u00A0'), link @, Sauce.link.cloneNode true
$.add @file.info, nodes
ImageExpand =
init: ->
return if g.VIEW is 'catalog' or !Conf['Image Expansion']
@EAI = $.el 'a',
className: 'expand-all-shortcut'
textContent: 'EAI'
title: 'Expand All Images'
href: 'javascript:;'
$.on @EAI, 'click', ImageExpand.cb.toggleAll
Header.addShortcut @EAI
Post::callbacks.push
name: 'Image Expansion'
cb: @node
node: ->
return unless @file and @file.isImage
$.on @file.thumb.parentNode, 'click', ImageExpand.cb.toggle
if ImageExpand.on and !@isHidden
ImageExpand.expand @
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 Get.postFromNode @
toggleAll: ->
$.event 'CloseMenu'
if ImageExpand.on = $.hasClass ImageExpand.EAI, 'expand-all-shortcut'
ImageExpand.EAI.className = 'contract-all-shortcut'
ImageExpand.EAI.title = 'Contract All Images'
func = ImageExpand.expand
else
ImageExpand.EAI.className = 'expand-all-shortcut'
ImageExpand.EAI.title = 'Expand All Images'
func = ImageExpand.contract
for ID, post of g.posts
for post in [post].concat post.clones
{file} = post
continue unless file and file.isImage and doc.contains post.nodes.root
if ImageExpand.on and
(!Conf['Expand spoilers'] and file.isSpoiler or
Conf['Expand from here'] and file.thumb.getBoundingClientRect().top < 0)
continue
$.queueTask func, post
return
setFitness: ->
{checked} = @
(if checked then $.addClass else $.rmClass) doc, @name.toLowerCase().replace /\s+/g, '-'
return unless @name is 'Fit height'
if checked
$.on window, 'resize', ImageExpand.resize
unless ImageExpand.style
ImageExpand.style = $.addStyle null
ImageExpand.resize()
else
$.off window, 'resize', ImageExpand.resize
toggle: (post) ->
{thumb} = post.file
unless post.file.isExpanded or $.hasClass thumb, 'expanding'
ImageExpand.expand post
return
rect = thumb.parentNode.getBoundingClientRect()
if rect.bottom > 0 # Should be at least partially visible.
# Scroll back to the thumbnail when contracting the image
# to avoid being left miles away from the relevant post.
postRect = post.nodes.root.getBoundingClientRect()
headRect = Header.bar.getBoundingClientRect()
top = postRect.top - headRect.top - headRect.height - 2
root = if $.engine is 'webkit'
d.body
else
doc
root.scrollTop += top if rect.top < 0
root.scrollLeft = 0 if rect.left < 0
ImageExpand.contract post
contract: (post) ->
$.rmClass post.nodes.root, 'expanded-image'
$.rmClass post.file.thumb, 'expanding'
post.file.isExpanded = false
expand: (post, src) ->
# Do not expand images of hidden/filtered replies, or already expanded pictures.
{thumb} = post.file
return if post.isHidden or post.file.isExpanded or $.hasClass thumb, 'expanding'
$.addClass thumb, 'expanding'
if post.file.fullImage
# Expand already-loaded/ing picture.
$.asap (-> post.file.fullImage.naturalHeight), ->
ImageExpand.completeExpand post
return
post.file.fullImage = img = $.el 'img',
className: 'full-image'
src: src or post.file.URL
$.on img, 'error', ImageExpand.error
$.asap (-> post.file.fullImage.naturalHeight), ->
ImageExpand.completeExpand post
$.after thumb, img
completeExpand: (post) ->
{thumb} = post.file
return unless $.hasClass thumb, 'expanding' # contracted before the image loaded
rect = post.nodes.root.getBoundingClientRect()
$.addClass post.nodes.root, 'expanded-image'
$.rmClass post.file.thumb, 'expanding'
if rect.top + rect.height <= 0
root = if $.engine is 'webkit'
d.body
else
doc
root.scrollTop += post.nodes.root.clientHeight - rect.height
post.file.isExpanded = true
error: ->
post = Get.postFromNode @
$.rm @
delete post.file.fullImage
unless $.hasClass post.file.thumb, 'expanding'
# Don't try to re-expend if it was already contracted.
return
ImageExpand.contract post
src = @src.split '/'
if src[2] is 'images.4chan.org'
if URL = Redirect.image src[3], src[5]
setTimeout ImageExpand.expand, 10000, post, URL
return
if g.DEAD or post.isDead or post.file.isDead
return
timeoutID = setTimeout ImageExpand.expand, 10000, post
# XXX CORS for images.4chan.org WHEN?
$.ajax "//api.4chan.org/#{post.board}/res/#{post.thread}.json", onload: ->
return if @status isnt 200
for postObj in JSON.parse(@response).posts
break if postObj.no is post.ID
if postObj.no isnt post.ID
clearTimeout timeoutID
post.kill()
else if postObj.filedeleted
clearTimeout timeoutID
post.kill true
menu:
init: ->
return if g.VIEW is 'catalog' or !Conf['Image Expansion']
el = $.el 'span',
textContent: 'Image Expansion'
className: 'image-expansion-link'
{createSubEntry} = ImageExpand.menu
subEntries = []
for key, conf of Config.imageExpansion
subEntries.push createSubEntry key, conf
$.event 'AddMenuEntry',
type: 'header'
el: el
order: 80
subEntries: subEntries
createSubEntry: (type, config) ->
label = $.el 'label',
innerHTML: " #{type}"
input = label.firstElementChild
if type in ['Fit width', 'Fit height']
$.on input, 'change', ImageExpand.cb.setFitness
if config
label.title = config[1]
input.checked = Conf[type]
$.event 'change', null, input
$.on input, 'change', $.cb.checked
el: label
resize: ->
ImageExpand.style.textContent = ":root.fit-height .full-image {max-height:#{doc.clientHeight}px}"
RevealSpoilers =
init: ->
return if g.VIEW is 'catalog' or !Conf['Reveal Spoilers']
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.VIEW is 'catalog' or !Conf['Auto-GIF'] or g.BOARD.ID in ['gif', 'wsg']
Post::callbacks.push
name: 'Auto-GIF'
cb: @node
node: ->
return if @isClone or @isHidden or @thread.isHidden 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.VIEW is 'catalog' or !Conf['Image Hover']
Post::callbacks.push
name: 'Image Hover'
cb: @node
node: ->
return unless @file?.isImage
$.on @file.thumb, 'mouseover', ImageHover.mouseover
mouseover: (e) ->
post = Get.postFromNode @
el = $.el 'img',
id: 'ihover'
src: post.file.URL
el.setAttribute 'data-fullid', post.fullID
$.add d.body, el
UI.hover
root: @
el: el
latestEvent: e
endEvents: 'mouseout click'
asapTest: -> el.naturalHeight
$.on el, 'error', ImageHover.error
error: ->
return unless doc.contains @
post = g.posts[@dataset.fullid]
src = @src.split '/'
if src[2] is 'images.4chan.org'
if URL = Redirect.image src[3], src[5].replace /\?.+$/, ''
@src = URL
return
if g.DEAD or post.isDead or post.file.isDead
return
timeoutID = setTimeout (=> @src = post.file.URL + '?' + Date.now()), 3000
# XXX CORS for images.4chan.org WHEN?
$.ajax "//api.4chan.org/#{post.board}/res/#{post.thread}.json", onload: ->
return if @status isnt 200
for postObj in JSON.parse(@response).posts
break if postObj.no is post.ID
if postObj.no isnt post.ID
clearTimeout timeoutID
post.kill()
else if postObj.filedeleted
clearTimeout timeoutID
post.kill true
ExpandComment =
init: ->
return if g.VIEW isnt 'index' or !Conf['Comment Expansion']
Post::callbacks.push
name: 'Comment Expansion'
cb: @node
node: ->
if a = $ '.abbr > a', @nodes.comment
$.on a, 'click', ExpandComment.cb
cb: (e) ->
e.preventDefault()
post = Get.postFromNode @
ExpandComment.expand post
expand: (post) ->
if post.nodes.longComment
$.replace post.nodes.shortComment, post.nodes.longComment
post.nodes.comment = post.nodes.longComment
return
return unless a = $ '.abbr > a', post.nodes.comment
a.textContent = "Post No.#{post} Loading..."
$.cache "//api.4chan.org#{a.pathname}.json", -> ExpandComment.parse @, a, post
contract: (post) ->
return unless post.nodes.shortComment
a = $ '.abbr > a', post.nodes.shortComment
a.textContent = 'here'
$.replace post.nodes.longComment, post.nodes.shortComment
post.nodes.comment = post.nodes.shortComment
parse: (req, a, post) ->
{status} = req
if status not in [200, 304]
a.textContent = "Error #{req.statusText} (#{status})"
return
posts = JSON.parse(req.response).posts
if spoilerRange = posts[0].custom_spoiler
Build.spoilerRange[g.BOARD] = spoilerRange
for postObj in posts
break if postObj.no is post.ID
if postObj.no isnt post.ID
a.textContent = "Post No.#{post} not found."
return
{comment} = post.nodes
clone = comment.cloneNode false
clone.innerHTML = postObj.com
for quote in $$ '.quotelink', clone
href = quote.getAttribute 'href'
continue if href[0] is '/' # Cross-board quote, or board link
quote.href = "/#{post.board}/res/#{href}" # Fix pathnames
post.nodes.shortComment = comment
$.replace comment, clone
post.nodes.comment = post.nodes.longComment = clone
post.parseComment()
post.parseQuotes()
if Conf['Resurrect Quotes']
Quotify.node.call post
if Conf['Quote Previewing']
QuotePreview.node.call post
if Conf['Quote Inlining']
QuoteInline.node.call post
if Conf['Mark OP Quotes']
QuoteOP.node.call post
if Conf['Mark Cross-thread Quotes']
QuoteCT.node.call post
if g.BOARD.ID is 'g'
Fourchan.code.call post
if g.BOARD.ID is 'sci'
Fourchan.math.call post
ExpandThread =
init: ->
return if g.VIEW isnt 'index' or !Conf['Thread Expansion']
Thread::callbacks.push
name: 'Thread Expansion'
cb: @node
node: ->
return unless span = $ '.summary', @OP.nodes.root.parentNode
a = $.el 'a',
textContent: "+ #{span.textContent}"
className: 'summary'
href: 'javascript:;'
$.on a, 'click', ExpandThread.cbToggle
$.replace span, a
cbToggle: ->
op = Get.postFromRoot @previousElementSibling
ExpandThread.toggle op.thread
toggle: (thread) ->
threadRoot = thread.OP.nodes.root.parentNode
url = "//api.4chan.org/#{thread.board}/res/#{thread}.json"
a = $ '.summary', threadRoot
text = a.textContent
switch text[0]
when '+'
a.textContent = text.replace '+', '× Loading...'
$.cache url, -> ExpandThread.parse @, thread, a
for post in $$ '.thread > .postContainer', threadRoot
ExpandComment.expand Get.postFromRoot post
when '×'
a.textContent = text.replace '× Loading...', '+'
when '-'
a.textContent = text.replace '-', '+'
#goddamit moot
num = if thread.isSticky
1
else switch g.BOARD
# XXX boards config
when 'b', 'vg', 'q' then 3
when 't' then 1
else 5
replies = $$('.thread > .replyContainer', threadRoot)[...-num]
for reply in replies
if Conf['Quote Inlining']
# rm clones
inlined.click() while inlined = $ '.inlined', reply
$.rm reply
for post in $$ '.thread > .postContainer', threadRoot
ExpandComment.contract Get.postFromRoot post
return
parse: (req, thread, a) ->
return if a.textContent[0] is '+'
{status} = req
if status not in [200, 304]
a.textContent = "Error #{req.statusText} (#{status})"
$.off a, 'click', ExpandThread.cb.toggle
return
a.textContent = a.textContent.replace '× Loading...', '-'
posts = JSON.parse(req.response).posts
if spoilerRange = posts[0].custom_spoiler
Build.spoilerRange[g.BOARD] = spoilerRange
replies = posts[1..]
posts = []
nodes = []
for reply in replies
if post = thread.posts[reply.no]
nodes.push post.nodes.root
continue
node = Build.postFromObject reply, thread.board
post = new Post node, thread, thread.board
link = $ 'a[title="Highlight this post"]', node
link.href = "res/#{thread}#p#{post}"
link.nextSibling.href = "res/#{thread}#q#{post}"
posts.push post
nodes.push node
Main.callbackNodes Post, posts
$.after a, nodes
# Enable 4chan features.
if Conf['Enable 4chan\'s Extension']
$.unsafeWindow.Parser.parseThread thread.ID, 1, nodes.length
else
Fourchan.parseThread thread.ID, 1, nodes.length
ThreadExcerpt =
init: ->
return if g.VIEW isnt 'thread' or !Conf['Thread Excerpt']
Thread::callbacks.push
name: 'Thread Excerpt'
cb: @node
node: ->
d.title = Get.threadExcerpt @
Unread =
init: ->
return if g.VIEW isnt 'thread' or !Conf['Unread Count'] and !Conf['Unread Tab Icon']
Unread.hr = $.el 'hr',
id: 'unread-line'
Misc.clearThreads "lastReadPosts.#{g.BOARD}"
Thread::callbacks.push
name: 'Unread'
cb: @node
node: ->
Unread.thread = @
Unread.lastReadPost = $.get("lastReadPosts.#{@board}", threads: {}).threads[@] or 0
Unread.posts = []
Unread.postsQuotingYou = []
Unread.title = d.title
posts = []
for ID, post of @posts
posts.push post if post.isReply
Unread.addPosts posts
if Unread.posts.length
# Scroll to before the first unread post.
$.x('preceding-sibling::div[contains(@class,"postContainer")][1]', Unread.posts[0].nodes.root).scrollIntoView false
else if posts.length
# Scroll to the last read post.
posts[posts.length - 1].nodes.root.scrollIntoView()
$.on d, 'ThreadUpdate', Unread.onUpdate
$.on d, 'scroll visibilitychange', Unread.read
$.on d, 'visibilitychange', Unread.setLine if Conf['Unread Line']
addPosts: (newPosts) ->
if Conf['Quick Reply']
{yourPosts} = QR
youInThisThread = yourPosts.threads[Unread.thread]
for post in newPosts
{ID} = post
if ID <= Unread.lastReadPost or post.isHidden or youInThisThread and ID in youInThisThread
continue
Unread.posts.push post
Unread.addPostQuotingYou post, yourPosts if yourPosts
if Conf['Unread Line']
# Force line on visible threads if there were no unread posts previously.
Unread.setLine Unread.posts[0] in newPosts
Unread.read()
Unread.update()
addPostQuotingYou: (post, yourPosts) ->
for quote in post.quotes
[board, quoteID] = quote.split '.'
continue unless board is Unread.thread.board.ID
for thread, postIDs of yourPosts.threads
if +quoteID in postIDs
Unread.postsQuotingYou.push post
return
onUpdate: (e) ->
if e.detail[404]
Unread.update()
else
Unread.addPosts e.detail.newPosts
read: (e) ->
return if d.hidden or !Unread.posts.length
height = doc.clientHeight
for post, i in Unread.posts
{bottom} = post.nodes.root.getBoundingClientRect()
break if bottom > height # post is not completely read
return unless i
Unread.lastReadPost = Unread.posts[i - 1].ID
Unread.saveLastReadPost()
Unread.posts = Unread.posts[i..]
for post, i in Unread.postsQuotingYou
break if post.ID > Unread.lastReadPost
Unread.postsQuotingYou = Unread.postsQuotingYou[i..]
Unread.update() if e
saveLastReadPost: $.debounce($.SECOND, ->
lastReadPosts = $.get "lastReadPosts.#{Unread.thread.board}", threads: {}
lastReadPosts.threads[Unread.thread] = Unread.lastReadPost
$.set "lastReadPosts.#{Unread.thread.board}", lastReadPosts
)
setLine: (force) ->
return unless d.hidden or force is true
if post = Unread.posts[0]
{root} = post.nodes
if root isnt $ '.thread > .replyContainer', root.parentNode # not the first reply
$.before root, Unread.hr
else if Unread.hr.parentNode
$.rm Unread.hr
update: ->
count = Unread.posts.length
if Conf['Unread Count']
d.title = if g.DEAD
"(#{Unread.posts.length}) /#{g.BOARD}/ - 404"
else
"(#{Unread.posts.length}) #{Unread.title}"
return unless Conf['Unread Tab Icon']
Favicon.el.href =
if g.DEAD
if Unread.postsQuotingYou.length
Favicon.unreadDeadY
else if count
Favicon.unreadDead
else
Favicon.dead
else
if Unread.postsQuotingYou.length
Favicon.unreadY
else if count
Favicon.unread
else
Favicon.default
# `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.
$.add d.head, Favicon.el
Favicon =
init: ->
$.ready ->
Favicon.el = $ 'link[rel="shortcut icon"]', d.head
Favicon.el.type = 'image/x-icon'
{href} = Favicon.el
Favicon.SFW = /ws\.ico$/.test href
Favicon.default = href
Favicon.switch()
switch: ->
switch Conf['favicon']
when 'ferongr'
Favicon.unreadDead = 'data:image/gif;base64,<%= grunt.file.read("img/favicons/ferongr/unreadDead.gif", {encoding: "base64"}) %>'
Favicon.unreadDeadY = 'data:image/png;base64,<%= grunt.file.read("img/favicons/ferongr/unreadDeadY.png", {encoding: "base64"}) %>'
Favicon.unreadSFW = 'data:image/gif;base64,<%= grunt.file.read("img/favicons/ferongr/unreadSFW.gif", {encoding: "base64"}) %>'
Favicon.unreadSFWY = 'data:image/png;base64,<%= grunt.file.read("img/favicons/ferongr/unreadSFWY.png", {encoding: "base64"}) %>'
Favicon.unreadNSFW = 'data:image/gif;base64,<%= grunt.file.read("img/favicons/ferongr/unreadNSFW.gif", {encoding: "base64"}) %>'
Favicon.unreadNSFWY = 'data:image/png;base64,<%= grunt.file.read("img/favicons/ferongr/unreadNSFWY.png", {encoding: "base64"}) %>'
when 'xat-'
Favicon.unreadDead = 'data:image/png;base64,<%= grunt.file.read("img/favicons/xat-/unreadDead.png", {encoding: "base64"}) %>'
Favicon.unreadDeadY = 'data:image/png;base64,<%= grunt.file.read("img/favicons/xat-/unreadDeadY.png", {encoding: "base64"}) %>'
Favicon.unreadSFW = 'data:image/png;base64,<%= grunt.file.read("img/favicons/xat-/unreadSFW.png", {encoding: "base64"}) %>'
Favicon.unreadSFWY = 'data:image/png;base64,<%= grunt.file.read("img/favicons/xat-/unreadSFWY.png", {encoding: "base64"}) %>'
Favicon.unreadNSFW = 'data:image/png;base64,<%= grunt.file.read("img/favicons/xat-/unreadNSFW.png", {encoding: "base64"}) %>'
Favicon.unreadNSFWY = 'data:image/png;base64,<%= grunt.file.read("img/favicons/xat-/unreadNSFWY.png", {encoding: "base64"}) %>'
when 'Mayhem'
Favicon.unreadDead = 'data:image/png;base64,<%= grunt.file.read("img/favicons/Mayhem/unreadDead.png", {encoding: "base64"}) %>'
Favicon.unreadDeadY = 'data:image/png;base64,<%= grunt.file.read("img/favicons/Mayhem/unreadDeadY.png", {encoding: "base64"}) %>'
Favicon.unreadSFW = 'data:image/png;base64,<%= grunt.file.read("img/favicons/Mayhem/unreadSFW.png", {encoding: "base64"}) %>'
Favicon.unreadSFWY = 'data:image/png;base64,<%= grunt.file.read("img/favicons/Mayhem/unreadSFWY.png", {encoding: "base64"}) %>'
Favicon.unreadNSFW = 'data:image/png;base64,<%= grunt.file.read("img/favicons/Mayhem/unreadNSFW.png", {encoding: "base64"}) %>'
Favicon.unreadNSFWY = 'data:image/png;base64,<%= grunt.file.read("img/favicons/Mayhem/unreadNSFWY.png", {encoding: "base64"}) %>'
when 'Original'
Favicon.unreadDead = 'data:image/gif;base64,<%= grunt.file.read("img/favicons/Original/unreadDead.gif", {encoding: "base64"}) %>'
Favicon.unreadDeadY = 'data:image/png;base64,<%= grunt.file.read("img/favicons/Original/unreadDeadY.png", {encoding: "base64"}) %>'
Favicon.unreadSFW = 'data:image/gif;base64,<%= grunt.file.read("img/favicons/Original/unreadSFW.gif", {encoding: "base64"}) %>'
Favicon.unreadSFWY = 'data:image/png;base64,<%= grunt.file.read("img/favicons/Original/unreadSFWY.png", {encoding: "base64"}) %>'
Favicon.unreadNSFW = 'data:image/gif;base64,<%= grunt.file.read("img/favicons/Original/unreadNSFW.gif", {encoding: "base64"}) %>'
Favicon.unreadNSFWY = 'data:image/png;base64,<%= grunt.file.read("img/favicons/Original/unreadNSFWY.png", {encoding: "base64"}) %>'
if Favicon.SFW
Favicon.unread = Favicon.unreadSFW
Favicon.unreadY = Favicon.unreadSFWY
else
Favicon.unread = Favicon.unreadNSFW
Favicon.unreadY = Favicon.unreadNSFWY
empty: 'data:image/gif;base64,<%= grunt.file.read("img/favicons/empty.gif", {encoding: "base64"}) %>'
dead: 'data:image/gif;base64,<%= grunt.file.read("img/favicons/dead.gif", {encoding: "base64"}) %>'
ThreadStats =
init: ->
return if g.VIEW isnt 'thread' or !Conf['Thread Stats']
@dialog = UI.dialog 'thread-stats', 'bottom: 0; left: 0;', """
0 / 0
"""
@postCountEl = $ '#post-count', @dialog
@fileCountEl = $ '#file-count', @dialog
Thread::callbacks.push
name: 'Thread Stats'
cb: @node
node: ->
postCount = 0
fileCount = 0
for ID, post of @posts
postCount++
fileCount++ if post.file
ThreadStats.update postCount, fileCount
ThreadStats.thread = @
$.on d, 'ThreadUpdate', ThreadStats.onUpdate
$.add d.body, ThreadStats.dialog
onUpdate: (e) ->
return if e.detail[404]
{postCount, fileCount, postLimit, fileLimit} = e.detail
ThreadStats.update postCount, fileCount, postLimit, fileLimit
update: (postCount, fileCount, postLimit, fileLimit) ->
ThreadStats.postCountEl.textContent = postCount
ThreadStats.fileCountEl.textContent = fileCount
(if postLimit and !ThreadStats.thread.isSticky then $.addClass else $.rmClass) ThreadStats.postCountEl, 'warning'
(if fileLimit and !ThreadStats.thread.isSticky then $.addClass else $.rmClass) ThreadStats.fileCountEl, 'warning'
ThreadUpdater =
init: ->
return if g.VIEW isnt 'thread' or !Conf['Thread Updater']
html = ''
for name, conf of Config.updater.checkbox
checked = if Conf[name] then 'checked' else ''
html += ""
checked = if Conf['Auto Update'] then 'checked' else ''
html = """
#{html}
"""
@dialog = UI.dialog 'updater', 'bottom: 0; right: 0;', html
@timer = $ '#update-timer', @dialog
@status = $ '#update-status', @dialog
Thread::callbacks.push
name: 'Thread Updater'
cb: @node
node: ->
ThreadUpdater.thread = @
ThreadUpdater.root = @OP.nodes.root.parentNode
ThreadUpdater.lastPost = +ThreadUpdater.root.lastElementChild.id.match(/\d+/)[0]
ThreadUpdater.outdateCount = 0
ThreadUpdater.lastModified = '0'
for input in $$ 'input', ThreadUpdater.dialog
if input.type is 'checkbox'
$.on input, 'change', $.cb.checked
switch input.name
when 'Scroll BG'
$.on input, 'change', ThreadUpdater.cb.scrollBG
ThreadUpdater.cb.scrollBG()
when 'Auto Update This'
$.on input, 'change', ThreadUpdater.cb.autoUpdate
$.event 'change', null, input
when 'Interval'
$.on input, 'change', ThreadUpdater.cb.interval
ThreadUpdater.cb.interval.call input
when 'Update'
$.on input, 'click', ThreadUpdater.update
$.on window, 'online offline', ThreadUpdater.cb.online
$.on d, 'QRPostSuccessful', ThreadUpdater.cb.post
$.on d, 'visibilitychange', ThreadUpdater.cb.visibility
ThreadUpdater.cb.online()
$.add d.body, ThreadUpdater.dialog
###
http://freesound.org/people/pierrecartoons1979/sounds/90112/
cc-by-nc-3.0
###
beep: 'data:audio/wav;base64,<%= grunt.file.read("audio/beep.wav", {encoding: "base64"}) %>'
cb:
online: ->
if ThreadUpdater.online = navigator.onLine
ThreadUpdater.outdateCount = 0
ThreadUpdater.set 'timer', ThreadUpdater.getInterval()
ThreadUpdater.update() if Conf['Auto Update This']
ThreadUpdater.set 'status', null, null
else
ThreadUpdater.set 'timer', null
ThreadUpdater.set 'status', 'Offline', 'warning'
ThreadUpdater.cb.autoUpdate()
post: (e) ->
return unless Conf['Auto Update This'] and e.detail.threadID is ThreadUpdater.thread.ID
ThreadUpdater.outdateCount = 0
setTimeout ThreadUpdater.update, 1000 if ThreadUpdater.seconds > 2
visibility: ->
return if d.hidden
# Reset the counter when we focus this tab.
ThreadUpdater.outdateCount = 0
if ThreadUpdater.seconds > ThreadUpdater.interval
ThreadUpdater.set 'timer', ThreadUpdater.getInterval()
scrollBG: ->
ThreadUpdater.scrollBG = if Conf['Scroll BG']
-> true
else
-> not d.hidden
autoUpdate: ->
if Conf['Auto Update This'] and ThreadUpdater.online
ThreadUpdater.timeoutID = setTimeout ThreadUpdater.timeout, 1000
else
clearTimeout ThreadUpdater.timeoutID
interval: ->
val = Math.max 5, parseInt @value, 10
ThreadUpdater.interval = @value = val
$.cb.value.call @
load: ->
{req} = ThreadUpdater
switch req.status
when 200
g.DEAD = false
ThreadUpdater.parse JSON.parse(req.response).posts
ThreadUpdater.lastModified = req.getResponseHeader 'Last-Modified'
ThreadUpdater.set 'timer', ThreadUpdater.getInterval()
when 404
g.DEAD = true
ThreadUpdater.set 'timer', null
ThreadUpdater.set 'status', '404', 'warning'
clearTimeout ThreadUpdater.timeoutID
ThreadUpdater.thread.kill()
$.event 'ThreadUpdate',
404: true
thread: ThreadUpdater.thread
else
ThreadUpdater.outdateCount++
ThreadUpdater.set 'timer', ThreadUpdater.getInterval()
###
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 and avoid unnecessary computation.
###
# XXX 304 -> 0 in Opera
[text, klass] = if req.status in [0, 304]
[null, null]
else
["#{req.statusText} (#{req.status})", 'warning']
ThreadUpdater.set 'status', text, klass
delete ThreadUpdater.req
getInterval: ->
i = ThreadUpdater.interval
j = Math.min ThreadUpdater.outdateCount, 10
unless d.hidden
# Lower the max refresh rate limit on visible tabs.
j = Math.min j, 7
ThreadUpdater.seconds = Math.max i, [0, 5, 10, 15, 20, 30, 60, 90, 120, 240, 300][j]
set: (name, text, klass) ->
el = ThreadUpdater[name]
if node = el.firstChild
# Prevent the creation of a new DOM Node
# by setting the text node's data.
node.data = text
else
el.textContent = text
el.className = klass if klass isnt undefined
timeout: ->
ThreadUpdater.timeoutID = setTimeout ThreadUpdater.timeout, 1000
unless n = --ThreadUpdater.seconds
ThreadUpdater.update()
else if n <= -60
ThreadUpdater.set 'status', 'Retrying', null
ThreadUpdater.update()
else if n > 0
ThreadUpdater.set 'timer', n
update: ->
return unless ThreadUpdater.online
ThreadUpdater.seconds = 0
ThreadUpdater.set 'timer', '...'
if ThreadUpdater.req
# abort() triggers onloadend, we don't want that.
ThreadUpdater.req.onloadend = null
ThreadUpdater.req.abort()
url = "//api.4chan.org/#{ThreadUpdater.thread.board}/res/#{ThreadUpdater.thread}.json"
ThreadUpdater.req = $.ajax url, onloadend: ThreadUpdater.cb.load,
headers: 'If-Modified-Since': ThreadUpdater.lastModified
updateThreadStatus: (title, OP) ->
titleLC = title.toLowerCase()
return if ThreadUpdater.thread["is#{title}"] is !!OP[titleLC]
unless ThreadUpdater.thread["is#{title}"] = !!OP[titleLC]
message = if title is 'Sticky'
'The thread is not a sticky anymore.'
else
'The thread is not closed anymore.'
new Notification 'info', message, 30
$.rm $ ".#{titleLC}Icon", ThreadUpdater.thread.OP.nodes.info
return
message = if title is 'Sticky'
'The thread is now a sticky.'
else
'The thread is now closed.'
new Notification 'info', message, 30
icon = $.el 'img',
src: "//static.4chan.org/image/#{titleLC}.gif"
alt: title
title: title
className: "#{titleLC}Icon"
root = $ '[title="Quote this post"]', ThreadUpdater.thread.OP.nodes.info
if title is 'Closed'
root = $('.stickyIcon', ThreadUpdater.thread.OP.nodes.info) or root
$.after root, [$.tn(' '), icon]
parse: (postObjects) ->
OP = postObjects[0]
Build.spoilerRange[ThreadUpdater.thread.board] = OP.custom_spoiler
ThreadUpdater.updateThreadStatus 'Sticky', OP
ThreadUpdater.updateThreadStatus 'Closed', OP
nodes = [] # post container elements
posts = [] # post objects
index = [] # existing posts
files = [] # existing files
count = 0 # new posts count
# Build the index, create posts.
for postObject in postObjects
num = postObject.no
index.push num
files.push num if postObject.fsize
continue if num <= ThreadUpdater.lastPost
# Insert new posts, not older ones.
count++
node = Build.postFromObject postObject, ThreadUpdater.thread.board
nodes.push node
posts.push new Post node, ThreadUpdater.thread, ThreadUpdater.thread.board
deletedPosts = []
deletedFiles = []
# Check for deleted posts/files.
for ID, post of ThreadUpdater.thread.posts
# XXX tmp fix for 4chan's racing condition
# giving us false-positive dead posts.
# continue if post.isDead
ID = +ID
if post.isDead and ID in index
post.resurrect()
else unless ID in index
post.kill()
deletedPosts.push post
else if post.file and !post.file.isDead and ID not in files
post.kill true
deletedFiles.push post
unless count
ThreadUpdater.set 'status', null, null
ThreadUpdater.outdateCount++
else
ThreadUpdater.set 'status', "+#{count}", 'new'
ThreadUpdater.outdateCount = 0
if Conf['Beep'] and d.hidden and Unread.posts and !Unread.posts.length
unless ThreadUpdater.audio
ThreadUpdater.audio = $.el 'audio', src: ThreadUpdater.beep
ThreadUpdater.audio.play()
ThreadUpdater.lastPost = posts[count - 1].ID
Main.callbackNodes Post, posts
scroll = Conf['Auto Scroll'] and ThreadUpdater.scrollBG() and
ThreadUpdater.root.getBoundingClientRect().bottom - doc.clientHeight < 25
$.add ThreadUpdater.root, nodes
if scroll
if Conf['Bottom Scroll']
(if $.engine is 'webkit' then d.body else doc).scrollTop = d.body.clientHeight
else
nodes[0].scrollIntoView()
$.queueTask ->
# Enable 4chan features.
threadID = ThreadUpdater.thread.ID
{length} = ThreadUpdater.root.children
if Conf['Enable 4chan\'s Extension']
$.unsafeWindow.Parser.parseThread threadID, -count
else
Fourchan.parseThread threadID, length - count, length
$.event 'ThreadUpdate',
404: false
thread: ThreadUpdater.thread
newPosts: posts
deletedPosts: deletedPosts
deletedFiles: deletedFiles
postCount: OP.replies + 1
fileCount: OP.images + (!!ThreadUpdater.thread.OP.file and !ThreadUpdater.thread.OP.file.isDead)
postLimit: !!OP.bumplimit
fileLimit: !!OP.imagelimit
ThreadWatcher =
init: ->
return if g.VIEW is 'catalog' or !Conf['Thread Watcher']
@dialog = UI.dialog 'watcher', 'top: 50px; left: 0px;',
'
Thread Watcher
'
$.on d, 'QRPostSuccessful', @cb.post
$.on d, '4chanXInitFinished', @ready
$.sync 'WatchedThreads', @refresh
Thread::callbacks.push
name: 'Thread Watcher'
cb: @node
node: ->
favicon = $.el 'img',
className: 'favicon'
$.on favicon, 'click', ThreadWatcher.cb.toggle
$.before $('input', @OP.nodes.post), favicon
if g.VIEW is 'thread' and @ID is $.get 'AutoWatch', 0
ThreadWatcher.watch @
$.delete 'AutoWatch'
ready: ->
ThreadWatcher.refresh()
$.add d.body, ThreadWatcher.dialog
refresh: (watched) ->
watched or= $.get 'WatchedThreads', {}
nodes = [$('.move', ThreadWatcher.dialog)]
for board of watched
for id, props of watched[board]
x = $.el 'a',
textContent: '×'
href: 'javascript:;'
$.on x, 'click', ThreadWatcher.cb.x
link = $.el 'a', props
link.title = link.textContent
div = $.el 'div'
$.add div, [x, $.tn(' '), link]
nodes.push div
ThreadWatcher.dialog.innerHTML = ''
$.add ThreadWatcher.dialog, nodes
watched = watched[g.BOARD] or {}
for ID, thread of g.BOARD.threads
favicon = $ '.favicon', thread.OP.nodes.post
favicon.src = if ID of watched
Favicon.default
else
Favicon.empty
return
cb:
toggle: ->
ThreadWatcher.toggle Get.postFromNode(@).thread
x: ->
thread = @nextElementSibling.pathname.split '/'
ThreadWatcher.unwatch thread[1], thread[3]
post: (e) ->
{board, postID, threadID} = e.detail
if postID is threadID
if Conf['Auto Watch']
$.set 'AutoWatch', threadID
else if Conf['Auto Watch Reply']
ThreadWatcher.watch board.threads[threadID]
toggle: (thread) ->
if $('.favicon', thread.OP.nodes.post).src is Favicon.empty
ThreadWatcher.watch thread
else
ThreadWatcher.unwatch thread.board, thread.ID
unwatch: (board, threadID) ->
watched = $.get 'WatchedThreads', {}
delete watched[board][threadID]
delete watched[board] unless Object.keys(watched[board]).length
ThreadWatcher.refresh watched
$.set 'WatchedThreads', watched
watch: (thread) ->
watched = $.get 'WatchedThreads', {}
watched[thread.board] or= {}
watched[thread.board][thread] =
href: "/#{thread.board}/res/#{thread}"
textContent: Get.threadExcerpt thread
ThreadWatcher.refresh watched
$.set 'WatchedThreads', watched