Merge Zixaphir X, close #221

This commit is contained in:
Jordan 2014-01-08 20:55:13 -07:00
commit 8419b7c88b
65 changed files with 10668 additions and 8966 deletions

View File

@ -1,26 +1,34 @@
module.exports = (grunt) ->
importHTML = (filename) ->
"\"\"\"#{grunt.file.read("src/General/html/#{filename}.html").replace(/^\s+|\s+$</gm, '').replace(/\n/g, '')}\"\"\""
# Project configuration.
grunt.initConfig
pkg: grunt.file.readJSON 'package.json'
concat:
options: process: Object.create(null, data:
get: -> grunt.config 'pkg'
get: ->
pkg = grunt.config 'pkg'
pkg.importHTML = importHTML
pkg
enumerable: true
)
coffee:
src: [
'src/General/Cheats.coffee'
'src/General/Config.coffee'
'src/General/Globals.coffee'
'src/General/lib/*.coffee'
'src/General/Header.coffee'
'src/General/Index.coffee'
'src/General/Build.coffee'
'src/General/Get.coffee'
'src/General/UI.coffee'
'src/General/Notice.coffee'
'src/Filtering/**/*'
'src/Quotelinks/**/*'
'src/Linkification/**/*'
'src/Posting/QR.coffee'
'src/Posting/**/*'
'src/Images/**/*'
'src/Linkification/**/*'
@ -90,6 +98,8 @@ module.exports = (grunt) ->
stdout: true
stderr: true
failOnError: true
checkout:
command: 'git checkout <%= pkg.meta.mainBranch %>'
commit:
command: """
git commit -am "Release <%= pkg.meta.name %> v<%= pkg.version %>."

View File

@ -1,5 +1,5 @@
/*
* 4chan X - Version 1.2.45 - 2014-01-07
* 4chan X - Version 1.2.45 - 2014-01-08
*
* Licensed under the MIT license.
* https://github.com/seaweedchan/4chan-x/blob/master/LICENSE

View File

@ -2,7 +2,7 @@
// @name 4chan X
// @version 1.2.45
// @minGMVer 1.13
// @minFFVer 22
// @minFFVer 26
// @namespace 4chan-X
// @description Cross-browser userscript for maximum lurking on 4chan.
// @license MIT; https://github.com/seaweedchan/4chan-x/blob/master/LICENSE

File diff suppressed because one or more lines are too long

View File

@ -15,7 +15,7 @@
"run_at": "document_start"
}],
"homepage_url": "http://seaweedchan.github.io/4chan-x/",
"minimum_chrome_version": "27",
"minimum_chrome_version": "31",
"permissions": [
"storage"
]

File diff suppressed because one or more lines are too long

View File

@ -13,11 +13,15 @@
"*://sys.4chan.org/*",
"*://a.4cdn.org/*",
"*://i.4cdn.org/*"
],
"files": {
"metajs": "4chan-X.meta.js",
"userjs": "4chan-X.user.js"
},
"min": {
"chrome": "31",
"firefox": "26",
"greasemonkey": "1.13"
}
},
"devDependencies": {

View File

@ -1,106 +1,119 @@
Redirect =
data:
thread: {}
post: {}
file: {}
init: ->
o =
thread: {}
post: {}
file: {}
{archives} = Redirect
for boardID, data of Conf['selectedArchives']
for type, id of data
if archive = Redirect.archives[id]
boards = archive[type] or archive['boards']
continue unless boards.contains boardID
Redirect.data[type][boardID] = archive
for name, archive of Redirect.archives
for type, id of data when (archive = archives[id]) and boardID in (archive[type] or archive['boards'])
o[type][boardID] = archive.data
for name, archive of archives
for boardID in archive.boards
unless boardID of Redirect.data.thread
Redirect.data.thread[boardID] = archive
unless boardID of Redirect.data.post or archive.software isnt 'foolfuuka'
Redirect.data.post[boardID] = archive
unless boardID of Redirect.data.file or !archive.files.contains boardID
Redirect.data.file[boardID] = archive
return
{data} = archive
unless boardID of o.thread
o.thread[boardID] = data
unless boardID of o.post or archive.software isnt 'foolfuuka'
o.post[boardID] = data
unless boardID of o.file or boardID not in archive.files
o.file[boardID] = data
Redirect.data = o
archives:
"Foolz":
domain: "archive.foolz.us"
http: false
https: true
software: "foolfuuka"
boards: ["a", "co", "gd", "jp", "m", "sp", "tg", "tv", "v", "vg", "vp", "vr", "wsg"]
files: ["a", "gd", "jp", "m", "tg", "vg", "vp", "vr", "wsg"]
data:
domain: "archive.foolz.us"
http: false
https: true
software: "foolfuuka"
"NSFW Foolz":
domain: "nsfw.foolz.us"
http: false
https: true
software: "foolfuuka"
boards: ["u"]
files: ["u"]
data:
domain: "nsfw.foolz.us"
http: false
https: true
software: "foolfuuka"
"The Dark Cave":
domain: "archive.thedarkcave.org"
http: true
https: true
software: "foolfuuka"
boards: ["c", "int", "out", "po"]
files: ["c", "po"]
data:
domain: "archive.thedarkcave.org"
http: true
https: true
software: "foolfuuka"
"4plebs":
domain: "archive.4plebs.org"
http: true
https: true
software: "foolfuuka"
boards: ["hr", "pol", "s4s", "tg", "tv", "x"]
files: ["hr", "pol", "s4s", "tg", "tv", "x"]
data:
domain: "archive.4plebs.org"
http: true
https: true
software: "foolfuuka"
"Nyafuu":
domain: "archive.nyafuu.org"
http: true
https: true
software: "foolfuuka"
boards: ["c", "w", "wg"]
files: ["c", "w", "wg"]
data:
domain: "archive.nyafuu.org"
http: true
https: true
software: "foolfuuka"
"Install Gentoo":
domain: "archive.installgentoo.net"
http: false
https: true
software: "fuuka"
boards: ["diy", "g", "sci"]
files: []
data:
domain: "archive.installgentoo.net"
http: false
https: true
software: "fuuka"
"Rebecca Black Tech":
domain: "rbt.asia"
http: true
https: true
software: "fuuka"
boards: ["cgl", "g", "mu", "w"]
files: ["cgl", "g", "mu", "w"]
data:
domain: "rbt.asia"
http: true
https: true
software: "fuuka"
"Heinessen":
domain: "archive.heinessen.com"
http: true
software: "fuuka"
boards: ["an", "fit", "k", "mlp", "r9k", "toy"]
files: ["an", "fit", "k", "r9k", "toy"]
data:
domain: "archive.heinessen.com"
http: true
software: "fuuka"
"warosu":
domain: "fuuka.warosu.org"
http: true
https: true
software: "fuuka"
boards: ["3", "cgl", "ck", "fa", "ic", "jp", "lit", "tg", "vr"]
files: ["3", "cgl", "ck", "fa", "ic", "jp", "lit", "tg", "vr"]
data:
domain: "fuuka.warosu.org"
http: true
https: true
software: "fuuka"
"Foolz Beta":
domain: "beta.foolz.us"
http: true
https: true
withCredentials: true
software: "foolfuuka"
boards: ["a", "co", "gd", "jp", "m", "s4s", "sp", "tg", "tv", "u", "v", "vg", "vp", "vr", "wsg"],
files: ["a", "gd", "jp", "m", "s4s", "tg", "u", "vg", "vp", "vr", "wsg"]
boards: ["a", "co", "d", "gd", "h", "jp", "m", "mlp", "sp", "tg", "tv", "u", "v", "vg", "vp", "vr", "wsg"],
files: ["a", "d", "gd", "h", "jp", "m", "tg", "u", "vg", "vp", "vr", "wsg"]
data:
domain: "beta.foolz.us"
http: true
https: true
withCredentials: true
software: "foolfuuka"
to: (dest, data) ->
archive = (if dest is 'search' then Redirect.data.thread else Redirect.data[dest])[data.boardID]

View File

@ -21,10 +21,10 @@ Filter =
# and it's not specifically applicable to the current board.
# Defaults to global.
boards = filter.match(/boards:([^;]+)/)?[1].toLowerCase() or 'global'
if boards isnt 'global' and not (boards.split ',').contains g.BOARD.ID
if boards isnt 'global' and g.BOARD.ID not in boards.split ','
continue
if ['uniqueID', 'MD5'].contains key
if key in ['uniqueID', 'MD5']
# MD5 filter will use strings instead of regular expressions.
regexp = regexp[1]
else
@ -113,13 +113,8 @@ Filter =
# Highlight
$.addClass @nodes.root, result.class
if !@isReply and result.top and g.VIEW is 'index'
# Put the highlighted OPs' thread on top of the board page...
thisThread = @nodes.root.parentNode
# ...before the first non highlighted thread.
if firstThread = $ 'div[class="postContainer opContainer"]'
unless firstThread is @nodes.root
$.before firstThread.parentNode, [thisThread, thisThread.nextElementSibling]
if !@isReply and result.top
@thread.isOnTop = true
name: (post) ->
if 'name' of post.info
@ -223,7 +218,7 @@ Filter =
{type} = @dataset
# Convert value -> regexp, unless type is MD5
value = Filter[type] Filter.menu.post
re = if ['uniqueID', 'MD5'].contains type then value else value.replace ///
re = if type in ['uniqueID', 'MD5'] then value else value.replace ///
/
| \\
| \^
@ -248,7 +243,7 @@ Filter =
else
"\\#{c}"
re = if ['uniqueID', 'MD5'].contains type
re = if type in ['uniqueID', 'MD5']
"/#{re}/"
else
"/^#{re}$/"

View File

@ -51,7 +51,15 @@ PostHiding =
return false
PostHiding.menu.post = post
true
subEntries: [{el: apply}, {el: thisPost}, {el: replies}, {el: makeStub}]
subEntries: [
el: apply
,
el: thisPost
,
el: replies
,
el: makeStub
]
# Show
div = $.el 'div',
@ -85,7 +93,13 @@ PostHiding =
thisPost.firstChild.checked = post.isHidden
replies.firstChild.checked = if data?.hideRecursively? then data.hideRecursively else Conf['Recursive Hiding']
true
subEntries: [{el: apply}, {el: thisPost}, {el: replies}]
subEntries: [
el: apply
,
el: thisPost
,
el: replies
]
$.event 'AddMenuEntry',
type: 'post'
@ -136,10 +150,13 @@ PostHiding =
return
makeButton: (post, type) ->
span = $.el 'span',
className: "brackets-wrap"
textContent: "\u00A0#{if type is 'hide' then '-' else '+'}\u00A0"
a = $.el 'a',
className: "#{type}-reply-button"
innerHTML: "<span class=brackets-wrap>&nbsp;#{if type is 'hide' then '-' else '+'}&nbsp;</span>"
href: 'javascript:;'
$.add a, span
$.on a, 'click', PostHiding.toggle
a
@ -186,10 +203,9 @@ PostHiding =
$.add a, $.tn " #{postInfo}"
post.nodes.stub = $.el 'div',
className: 'stub'
$.add post.nodes.stub, if Conf['Menu']
[a, $.tn(' '), button = Menu.makeButton post]
else
a
$.add post.nodes.stub, a
if Conf['Menu']
$.add post.nodes.stub, Menu.makeButton()
$.prepend post.nodes.root, post.nodes.stub
show: (post, showRecursively=Conf['Recursive Hiding']) ->

View File

@ -33,6 +33,6 @@ Recursive =
apply: (recursive, post, args...) ->
{fullID} = post
for ID, post of g.posts
if post.quotes.contains fullID
if fullID in post.quotes
recursive post, args...
return

View File

@ -4,6 +4,7 @@ ThreadHiding =
@db = new DataBoard 'hiddenThreads'
@syncCatalog()
$.on d, 'IndexBuild', @onIndexBuild
Thread.callbacks.push
name: 'Thread Hiding'
cb: @node
@ -14,6 +15,17 @@ ThreadHiding =
return unless Conf['Thread Hiding Buttons']
$.prepend @OP.nodes.root, ThreadHiding.makeButton @, 'hide'
onIndexBuild: ({detail: nodes}) ->
for root, i in nodes by 2
thread = Get.threadFromRoot root
continue unless thread.isHidden
unless thread.stub
nodes[i + 1].hidden = true
else unless root.contains thread.stub
# When we come back to a page, the stub is already there.
ThreadHiding.makeStub thread, root
return
syncCatalog: ->
# Sync hidden threads from the catalog into the index.
hiddenThreads = ThreadHiding.db.get
@ -139,6 +151,23 @@ ThreadHiding =
a.dataset.fullID = thread.fullID
$.on a, 'click', ThreadHiding.toggle
a
makeStub: (thread, root) ->
numReplies = $$('.thread > .replyContainer', root).length
numReplies += +summary.textContent.match /\d+/ if summary = $ '.summary', root
opInfo = if Conf['Anonymize']
'Anonymous'
else
$('.nameBlock', thread.OP.nodes.info).textContent
a = ThreadHiding.makeButton thread, 'show'
$.add a, $.tn " #{opInfo} (#{if numReplies is 1 then '1 reply' else "#{numReplies} replies"})"
thread.stub = $.el 'div',
className: 'stub'
if Conf['Menu']
$.add thread.stub, [a, Menu.makeButton()]
else
$.add thread.stub, a
$.prepend root, thread.stub
saveHiddenState: (thread, makeStub) ->
hiddenThreadsOnCatalog = JSON.parse(localStorage.getItem "4chan-hide-t-#{g.BOARD}") or {}
@ -165,37 +194,13 @@ ThreadHiding =
ThreadHiding.saveHiddenState thread
hide: (thread, makeStub=Conf['Stubs']) ->
{OP} = thread
threadRoot = OP.nodes.root.parentNode
return if thread.isHidden
threadRoot = thread.OP.nodes.root.parentNode
thread.isHidden = true
unless makeStub
threadRoot.hidden = threadRoot.nextElementSibling.hidden = true # <hr>
return
return threadRoot.hidden = threadRoot.nextElementSibling.hidden = true unless makeStub # <hr>
numReplies = (
if span = $ '.summary', threadRoot
+span.textContent.match /\d+/
else
0
) +
$$('.opContainer ~ .replyContainer', threadRoot).length
numReplies = if numReplies is 1 then '1 reply' else "#{numReplies or 'No'} replies"
opInfo =
if Conf['Anonymize']
'Anonymous'
else
$('.nameBlock', OP.nodes.info).textContent
a = ThreadHiding.makeButton thread, 'show'
$.add a, $.tn " #{opInfo} (#{numReplies})"
thread.stub = $.el 'div',
className: 'stub'
$.add thread.stub, if Conf['Menu']
[a, $.tn(' '), Menu.makeButton()]
else
a
$.prepend threadRoot, thread.stub
ThreadHiding.makeStub thread, threadRoot
show: (thread) ->
if thread.stub

View File

@ -1,4 +1,6 @@
Build =
staticPath: '//s.4cdn.org/image/'
gifIcon: if window.devicePixelRatio >= 2 then '@2x.gif' else '.gif'
spoilerRange: {}
shortFilename: (filename, isReply) ->
# FILENAME SHORTENING SCIENCE:
@ -9,6 +11,9 @@ Build =
"#{filename[...threshold - 5]}(...).#{filename[-3..]}"
else
filename
thumbRotate: do ->
n = 0
-> n = (n + 1) % 3
postFromObject: (data, boardID) ->
o =
# id
@ -43,7 +48,7 @@ Build =
width: data.w
MD5: data.md5
size: data.fsize
turl: "//t.4cdn.org/#{boardID}/thumb/#{data.tim}s.jpg"
turl: "//#{Build.thumbRotate()}.t.4cdn.org/#{boardID}/thumb/#{data.tim}s.jpg"
theight: data.tn_h
twidth: data.tn_w
isSpoiler: !!data.spoiler
@ -62,8 +67,12 @@ Build =
file
} = o
isOP = postID is threadID
{staticPath, gifIcon} = Build
staticPath = '//s.4cdn.org/image/'
tripcode = if tripcode
" <span class=postertrip>#{tripcode}</span>"
else
''
if email
emailStart = '<a href="mailto:' + email + '" class="useremail">'
@ -72,7 +81,29 @@ Build =
emailStart = ''
emailEnd = ''
subject = " <span class=subject>#{subject or ''}</span> "
switch capcode
when 'admin', 'admin_highlight'
capcodeClass = " capcodeAdmin"
capcodeStart = " <strong class='capcode hand id_admin'" +
"title='Highlight posts by the Administrator'>## Admin</strong>"
capcodeIcon = " <img src='#{staticPath}adminicon#{gifIcon}' " +
"title='This user is the 4chan Administrator.' class=identityIcon>"
when 'mod'
capcodeClass = " capcodeMod"
capcodeStart = " <strong class='capcode hand id_mod' " +
"title='Highlight posts by Moderators'>## Mod</strong>"
capcodeIcon = " <img src='#{staticPath}modicon#{gifIcon}' " +
"title='This user is a 4chan Moderator.' class=identityIcon>"
when 'developer'
capcodeClass = " capcodeDeveloper"
capcodeStart = " <strong class='capcode hand id_developer' " +
"title='Highlight posts by Developers'>## Developer</strong>"
capcodeIcon = " <img src='#{staticPath}developericon#{gifIcon}' " +
"title='This user is a 4chan Developer.' class=identityIcon>"
else
capcodeClass = ''
capcodeStart = ''
capcodeIcon = ''
userID =
if !capcode and uniqueID
@ -81,33 +112,6 @@ Build =
else
''
switch capcode
when 'admin', 'admin_highlight'
capcodeClass = " capcodeAdmin"
capcodeStart = " <strong class='capcode hand id_admin'" +
"title='Highlight posts by the Administrator'>## Admin</strong>"
capcode = " <img src='#{staticPath}adminicon.gif' " +
"alt='This user is the 4chan Administrator.' " +
"title='This user is the 4chan Administrator.' class=identityIcon>"
when 'mod'
capcodeClass = " capcodeMod"
capcodeStart = " <strong class='capcode hand id_mod' " +
"title='Highlight posts by Moderators'>## Mod</strong>"
capcode = " <img src='#{staticPath}modicon.gif' " +
"alt='This user is a 4chan Moderator.' " +
"title='This user is a 4chan Moderator.' class=identityIcon>"
when 'developer'
capcodeClass = " capcodeDeveloper"
capcodeStart = " <strong class='capcode hand id_developer' " +
"title='Highlight posts by Developers'>## Developer</strong>"
capcode = " <img src='#{staticPath}developericon.gif' " +
"alt='This user is a 4chan Developer.' " +
"title='This user is a 4chan Developer.' class=identityIcon>"
else
capcodeClass = ''
capcodeStart = ''
capcode = ''
flag = unless flagCode
''
else if boardID is 'pol'
@ -117,21 +121,15 @@ Build =
if file?.isDeleted
fileHTML = if isOP
"<div class=file id=f#{postID}><div class=fileInfo></div><span class=fileThumb>" +
"<img src='#{staticPath}filedeleted.gif' alt='File deleted.' class=fileDeletedRes>" +
"<div class=file id=f#{postID}><span class=fileThumb>" +
"<img src='#{staticPath}filedeleted#{gifIcon}' alt='File deleted.' class=fileDeleted>" +
"</span></div>"
else
"<div class=file id=f#{postID}><span class=fileThumb>" +
"<img src='#{staticPath}filedeleted-res.gif' alt='File deleted.' class=fileDeletedRes>" +
"<img src='#{staticPath}filedeleted-res#{gifIcon}' alt='File deleted.' class=fileDeletedRes>" +
"</span></div>"
else if file
ext = file.name[-3..]
if !file.twidth and !file.theight and ext is 'gif' # wtf ?
file.twidth = file.width
file.theight = file.height
fileSize = $.bytesToString file.size
fileSize = $.bytesToString file.size
fileThumb = file.turl
if file.isSpoiler
fileSize = "Spoiler Image, #{fileSize}"
@ -150,20 +148,17 @@ Build =
"<img src='#{fileThumb}' alt='#{fileSize}' data-md5=#{file.MD5} style='height: #{file.theight}px; width: #{file.twidth}px;'>" +
"</a>"
# Ha ha, filenames!
# html -> text, translate WebKit's %22s into "s
a = $.el 'a', innerHTML: file.name
filename = a.textContent.replace /%22/g, '"'
# shorten filename, get html
a.textContent = Build.shortFilename filename
shortFilename = a.innerHTML
# get html
a.textContent = filename
filename = a.innerHTML.replace /'/g, '&apos;'
fileDims = if ext is 'pdf' then 'PDF' else "#{file.width}x#{file.height}"
fileDims = if file.name[-3..] is 'pdf' then 'PDF' else "#{file.width}x#{file.height}"
fileInfo = "<div class=fileText id=fT#{postID}#{if file.isSpoiler then " title='#{filename}'" else ''}>File: <a href='#{file.url}' target=_blank>#{file.timestamp}</a>" +
"-(#{fileSize}, #{fileDims}#{
if file.isSpoiler
@ -176,20 +171,22 @@ Build =
else
fileHTML = ''
tripcode = if tripcode
" <span class=postertrip>#{tripcode}</span>"
else
''
sticky = if isSticky
" <img src=#{staticPath}sticky.gif alt=Sticky title=Sticky class=stickyIcon>"
" <img src=#{staticPath}sticky#{gifIcon} alt=Sticky title=Sticky class=stickyIcon>"
else
''
closed = if isClosed
" <img src=#{staticPath}closed.gif alt=Closed title=Closed class=closedIcon>"
" <img src=#{staticPath}closed#{gifIcon} alt=Closed title=Closed class=closedIcon>"
else
''
if isOP and g.VIEW is 'index'
pageNum = Math.floor Index.liveThreadIDs.indexOf(postID) / Index.threadsNumPerPage
pageIcon = " <span class=page-num title='This thread is on page #{pageNum} in the original index.'>[#{pageNum}]</span>"
replyLink = " &nbsp; <span>[<a href='/#{boardID}/res/#{threadID}' class=replylink>Reply</a>]</span>"
else
pageIcon = replyLink = ''
container = $.el 'div',
id: "pc#{postID}"
className: "postContainer #{if isOP then 'op' else 'reply'}Container"
@ -201,3 +198,34 @@ Build =
quote.href = "/#{boardID}/res/#{href}" # Fix pathnames
container
summary: (boardID, threadID, posts, files) ->
text = []
text.push "#{posts} post#{if posts > 1 then 's' else ''}"
text.push "and #{files} image repl#{if files > 1 then 'ies' else 'y'}" if files
text.push 'omitted.'
$.el 'a',
className: 'summary'
textContent: text.join ' '
href: "/#{boardID}/res/#{threadID}"
thread: (board, data) ->
Build.spoilerRange[board] = data.custom_spoiler
if (OP = board.posts[data.no]) and root = OP.nodes.root.parentNode
$.rmAll root
else
root = $.el 'div',
className: 'thread'
id: "t#{data.no}"
nodes = [if OP then OP.nodes.root else Build.postFromObject data, board.ID]
if data.omitted_posts or !Conf['Show Replies'] and data.replies
[posts, files] = if Conf['Show Replies']
[data.omitted_posts, data.omitted_images]
else
# XXX data.images is not accurate.
[data.replies, data.omitted_images + data.last_replies.filter((data) -> !!data.ext).length]
nodes.push Build.summary board.ID, data.no, posts, files
$.add root, nodes
root

10
src/General/Cheats.coffee Normal file
View File

@ -0,0 +1,10 @@
# I am bad at JavaScript and if you reuse this, so are you.
Array::indexOf = (val) ->
i = @length
while i--
return i if @[i] is val
return i
# Update CoffeeScript's reference to [].indexOf
# Reserved keywords are ignored in embedded javascript.
`__indexOf = [].indexOf`

View File

@ -41,10 +41,6 @@ Config =
true
'Reformat the file information.'
]
'Comment Expansion': [
true
'Add buttons to expand long comments.'
]
'Thread Expansion': [
true
'Add buttons to expand threads.'
@ -89,10 +85,6 @@ Config =
false
'Indicate spoilers if Remove Spoilers is enabled, or make the text appear hovered if Remove Spoiler is disabled.'
]
'Infinite Scrolling': [
false
'Add new posts to the board index upon reaching the bottom of the board.'
]
'Linkification':
'Linkify': [
@ -497,21 +489,34 @@ http://iqdb.org/?url=%TURL
#//archive.foolz.us/%board/search/image/%MD5/;text:View same on foolz /%board/
#//archive.installgentoo.net/%board/image/%MD5;text:View same on installgentoo /%board/
"""
FappeT:
fappe: false
werk: false
'sageEmoji': '4chan SS'
'emojiPos': 'before'
'Custom CSS': false
Index:
'Index Mode': 'paged'
'Index Sort': 'bump'
'Show Replies': true
'Anchor Hidden Threads': true
'Refreshed Navigation': false
Header:
'Fixed Header': true
'Header auto-hide': false
'Bottom Header': false
'Centered links': false
'Header catalog links': false
'Bottom Board List': true
'Shortcut Icons': false
'Custom Board Navigation': true
'Fixed Header': true
'Header auto-hide': false
'Header auto-hide on scroll': false
'Bottom Header': false
'Centered links': false
'Header catalog links': false
'Bottom Board List': true
'Shortcut Icons': false
'Custom Board Navigation': true
boardnav: """
[ toggle-all ]
@ -649,6 +654,10 @@ vp-replace
'Shift+c'
'Open the catalog of the current board'
]
'Search form': [
'Ctrl+Alt+s'
'Focus the search field on the board index.'
]
# Thread Navigation
'Next thread': [
'Shift+Down'

View File

@ -45,7 +45,7 @@ Get =
# if it did quote this post,
# get all their backlinks.
for ID, quoterPost of g.posts
if quoterPost.quotes.contains post.fullID
if post.fullID in quoterPost.quotes
for quoterPost in [quoterPost].concat quoterPost.clones
quotelinks.push.apply quotelinks, quoterPost.nodes.quotelinks
# Second:
@ -98,7 +98,7 @@ Get =
return
{status} = req
unless [200, 304].contains status
unless status in [200, 304]
# The thread can die by the time we check a quote.
if url = Redirect.to 'post', {boardID, postID}
$.cache url,
@ -170,8 +170,8 @@ Get =
threadID = +data.thread_num
o =
# id
postID: "#{postID}"
threadID: "#{threadID}"
postID: postID
threadID: threadID
boardID: boardID
# info
name: data.name_processed
@ -207,8 +207,7 @@ Get =
new Board boardID
thread = g.threads["#{boardID}.#{threadID}"] or
new Thread threadID, board
post = new Post Build.post(o, true), thread, board,
isArchived: true
post = new Post Build.post(o, true), thread, board, {isArchived: true}
Main.callbackNodes Post, [post]
Get.insert post, root, context
parseMarkup: (text) ->

View File

@ -10,8 +10,10 @@ Header =
innerHTML: '<input type=checkbox name="Fixed Header"> Fixed Header'
headerToggler = $.el 'label',
innerHTML: '<input type=checkbox name="Header auto-hide"> Auto-hide header'
scrollHeaderToggler = $.el 'label',
innerHTML: '<input type=checkbox name="Header auto-hide on scroll"> Auto-hide header on scroll'
barPositionToggler = $.el 'label',
innerHTML: '<input type=checkbox name="Bottom header"> Bottom header'
innerHTML: '<input type=checkbox name="Bottom Header"> Bottom header'
linkJustifyToggler = $.el 'label',
innerHTML: "<input type=checkbox #{if Conf['Centered links'] then 'checked' else ''}> Centered links"
customNavToggler = $.el 'label',
@ -24,34 +26,39 @@ Header =
textContent: 'Edit custom board navigation'
href: 'javascript:;'
@barFixedToggler = barFixedToggler.firstElementChild
@barPositionToggler = barPositionToggler.firstElementChild
@linkJustifyToggler = linkJustifyToggler.firstElementChild
@headerToggler = headerToggler.firstElementChild
@footerToggler = footerToggler.firstElementChild
@shortcutToggler = shortcutToggler.firstElementChild
@customNavToggler = customNavToggler.firstElementChild
@barFixedToggler = barFixedToggler.firstElementChild
@scrollHeaderToggler = scrollHeaderToggler.firstElementChild
@barPositionToggler = barPositionToggler.firstElementChild
@linkJustifyToggler = linkJustifyToggler.firstElementChild
@headerToggler = headerToggler.firstElementChild
@footerToggler = footerToggler.firstElementChild
@shortcutToggler = shortcutToggler.firstElementChild
@customNavToggler = customNavToggler.firstElementChild
$.on menuButton, 'click', @menuToggle
$.on @barFixedToggler, 'change', @toggleBarFixed
$.on @barPositionToggler, 'change', @toggleBarPosition
$.on @linkJustifyToggler, 'change', @toggleLinkJustify
$.on @headerToggler, 'change', @toggleBarVisibility
$.on @footerToggler, 'change', @toggleFooterVisibility
$.on @shortcutToggler, 'change', @toggleShortcutIcons
$.on @customNavToggler, 'change', @toggleCustomNav
$.on editCustomNav, 'click', @editCustomNav
$.on menuButton, 'click', @menuToggle
$.on @headerToggler, 'change', @toggleBarVisibility
$.on @barFixedToggler, 'change', @toggleBarFixed
$.on @barPositionToggler, 'change', @toggleBarPosition
$.on @scrollHeaderToggler, 'change', @toggleHideBarOnScroll
$.on @linkJustifyToggler, 'change', @toggleLinkJustify
$.on @headerToggler, 'change', @toggleBarVisibility
$.on @footerToggler, 'change', @toggleFooterVisibility
$.on @shortcutToggler, 'change', @toggleShortcutIcons
$.on @customNavToggler, 'change', @toggleCustomNav
$.on editCustomNav, 'click', @editCustomNav
@setBarFixed Conf['Fixed Header']
@setBarVisibility Conf['Header auto-hide']
@setLinkJustify Conf['Centered links']
@setShortcutIcons Conf['Shortcut Icons']
@setBarFixed Conf['Fixed Header']
@setHideBarOnScroll Conf['Header auto-hide on scroll']
@setBarVisibility Conf['Header auto-hide']
@setLinkJustify Conf['Centered links']
@setShortcutIcons Conf['Shortcut Icons']
$.sync 'Fixed Header', Header.setBarFixed
$.sync 'Bottom Header', Header.setBarPosition
$.sync 'Shortcut Icons', Header.setShortcutIcons
$.sync 'Header auto-hide', Header.setBarVisibility
$.sync 'Centered links', Header.setLinkJustify
$.sync 'Fixed Header', @setBarFixed
$.sync 'Header auto-hide on scroll', @setHideBarOnScroll
$.sync 'Bottom Header', @setBarPosition
$.sync 'Shortcut Icons', @setShortcutIcons
$.sync 'Header auto-hide', @setBarVisibility
$.sync 'Centered links', @setLinkJustify
@addShortcut menuButton
@ -61,14 +68,23 @@ Header =
textContent: 'Header'
order: 107
subEntries: [
{el: barFixedToggler}
{el: headerToggler}
{el: barPositionToggler}
{el: linkJustifyToggler}
{el: footerToggler}
{el: shortcutToggler}
{el: customNavToggler}
{el: editCustomNav}
el: barFixedToggler
,
el: headerToggler
,
el: scrollHeaderToggler
,
el: barPositionToggler
,
el: linkJustifyToggler
,
el: footerToggler
,
el: shortcutToggler
,
el: customNavToggler
,
el: editCustomNav
]
$.on window, 'load hashchange', Header.hashScroll
@ -85,9 +101,10 @@ Header =
@
$.ready =>
@footer = $.id 'boardNavDesktopFoot'
if a = $ "a[href*='/#{g.BOARD}/']", $.id 'boardNavDesktopFoot'
@footer = footer = $.id 'boardNavDesktopFoot'
if a = $ "a[href*='/#{g.BOARD}/']", footer
a.className = 'current'
$.on a, 'click', Index.cb.link
cs = $.el 'a',
id: 'settingsWindowLink'
@ -104,7 +121,7 @@ Header =
bar: $.el 'div',
id: 'header-bar'
notify: $.el 'div',
noticesRoot: $.el 'div',
id: 'notifications'
shortcuts: $.el 'span',
@ -118,18 +135,19 @@ Header =
setBoardList: ->
fourchannav = $.id 'boardNavDesktop'
if a = $ "a[href*='/#{g.BOARD}/']", fourchannav
a.className = 'current'
boardList = $.el 'span',
id: 'board-list'
innerHTML: "<span id=custom-board-list></span><span id=full-board-list hidden><span class='hide-board-list-container brackets-wrap'><a href=javascript:; class='hide-board-list-button'>&nbsp;-&nbsp;</a></span> #{fourchannav.innerHTML}</span>"
if a = $ "a[href*='/#{g.BOARD}/']", boardList
a.className = 'current'
$.on a, 'click', Index.cb.link
fullBoardList = $ '#full-board-list', boardList
btn = $ '.hide-board-list-button', fullBoardList
$.on btn, 'click', Header.toggleBoardList
$.rm $ '#navtopright', fullBoardList
$.add boardList, fullBoardList
$.add Header.bar, [boardList, Header.shortcuts, Header.notify, Header.toggle]
$.add Header.bar, [boardList, Header.shortcuts, Header.noticesRoot, Header.toggle]
Header.setCustomNav Conf['Custom Board Navigation']
Header.generateBoardList Conf['boardnav'].replace /(\r\n|\n|\r)/g, ' '
@ -166,7 +184,11 @@ Header =
if a.textContent is board
a = a.cloneNode true
a.textContent = if /-title/.test(t) or /-replace/.test(t) and $.hasClass a, 'current'
current = $.hasClass a, 'current'
if current
$.on a, 'click', Index.cb.link
a.textContent = if /-title/.test(t) or /-replace/.test(t) and current
a.title
else if /-full/.test t
"/#{board}/ - #{a.title}"
@ -198,17 +220,6 @@ Header =
custom.hidden = !showBoardList
full.hidden = showBoardList
setBarPosition: (bottom) ->
Header.barPositionToggler.checked = bottom
if bottom
$.rmClass doc, 'top'
$.addClass doc, 'bottom'
$.after Header.bar, Header.notify
else
$.rmClass doc, 'bottom'
$.addClass doc, 'top'
$.add Header.bar, Header.notify
setLinkJustify: (centered) ->
Header.linkJustifyToggler.checked = centered
if centered
@ -216,14 +227,6 @@ Header =
else
$.rmClass doc, 'centered-links'
toggleBarPosition: ->
$.event 'CloseMenu'
Header.setBarPosition @checked
Conf['Bottom Header'] = @checked
$.set 'Bottom Header', @checked
toggleLinkJustify: ->
$.event 'CloseMenu'
centered = if @nodeName is 'INPUT'
@ -285,6 +288,54 @@ Header =
'remain visible.'}"
new Notice 'info', message, 2
setHideBarOnScroll: (hide) ->
Header.scrollHeaderToggler.checked = hide
if hide
$.on window, 'scroll', Header.hideBarOnScroll
return
$.off window, 'scroll', Header.hideBarOnScroll
$.rmClass Header.bar, 'scroll'
$.rmClass Header.bar, 'autohide' unless Conf['Header auto-hide']
toggleHideBarOnScroll: (e) ->
hide = @checked
$.set 'Header auto-hide on scroll', hide
Header.setHideBarOnScroll hide
hideBarOnScroll: ->
offsetY = window.pageYOffset
if offsetY > (Header.previousOffset or 0)
$.addClass Header.bar, 'autohide'
$.addClass Header.bar, 'scroll'
else
$.rmClass Header.bar, 'autohide'
$.rmClass Header.bar, 'scroll'
Header.previousOffset = offsetY
setBarPosition: (bottom) ->
Header.barPositionToggler.checked = bottom
$.event 'CloseMenu'
args = if bottom then [
'bottom-header'
'top-header'
'bottom'
'after'
] else [
'top-header'
'bottom-header'
'top'
'add'
]
$.addClass doc, args[0]
$.rmClass doc, args[1]
Header.bar.parentNode.className = args[2]
$[args[3]] Header.bar, Header.notify
toggleBarPosition: ->
$.cb.checked.call @
Header.setBarPosition @checked
setFooterVisibility: (hide) ->
Header.footerToggler.checked = hide
Header.footer.hidden = hide
@ -323,16 +374,33 @@ Header =
$('input[name=boardnav]', settings).focus()
hashScroll: ->
return unless (hash = @location.hash[1..]) and post = $.id hash
hash = @location.hash[1..]
return unless /^p\d+$/.test(hash) and post = $.id hash
return if (Get.postFromRoot post).isHidden
Header.scrollToPost post
scrollToPost: (post) ->
{top} = post.getBoundingClientRect()
Header.scrollTo post
scrollTo: (root, down, needed) ->
if down
x = Header.getBottomOf root
window.scrollBy 0, -x unless needed and x >= 0
else
x = Header.getTopOf root
window.scrollBy 0, x unless needed and x >= 0
scrollToIfNeeded: (root, down) ->
Header.scrollTo root, down, true
getTopOf: (root) ->
{top} = root.getBoundingClientRect()
if Conf['Fixed Header'] and not Conf['Bottom Header']
headRect = Header.bar.getBoundingClientRect()
top -= headRect.top + headRect.height
window.scrollBy 0, top
headRect = Header.toggle.getBoundingClientRect()
top -= headRect.top + headRect.height
top
getBottomOf: (root) ->
{clientHeight} = doc
bottom = clientHeight - root.getBoundingClientRect().bottom
if Conf['Bottom Header']
headRect = Header.toggle.getBoundingClientRect()
bottom -= clientHeight - headRect.bottom + headRect.height
bottom
addShortcut: (el) ->
shortcut = $.el 'span',
@ -340,13 +408,14 @@ Header =
$.add shortcut, el
$.prepend Header.shortcuts, shortcut
menuToggle: (e) ->
Header.menu.toggle e, @, g
createNotification: (e) ->
{type, content, lifetime, cb} = e.detail
notif = new Notice type, content, lifetime
cb notif if cb
notice = new Notice type, content, lifetime
cb notice if cb
areNotificationsEnabled: false
enableDesktopNotifications: ->

426
src/General/Index.coffee Normal file
View File

@ -0,0 +1,426 @@
Index =
init: ->
return if g.VIEW isnt 'index' or g.BOARD.ID is 'f'
@button = $.el 'a',
className: 'index-refresh-shortcut fa fa-refresh'
title: 'Refresh Index'
href: 'javascript:;'
textContent: 'Refresh Index'
$.on @button, 'click', @update
Header.addShortcut @button, 1
modeEntry =
el: $.el 'span', textContent: 'Index mode'
subEntries: [
{ el: $.el 'label', innerHTML: '<input type=radio name="Index Mode" value="paged"> Paged' }
{ el: $.el 'label', innerHTML: '<input type=radio name="Index Mode" value="all pages"> All threads' }
]
for label in modeEntry.subEntries
input = label.el.firstChild
input.checked = Conf['Index Mode'] is input.value
$.on input, 'change', $.cb.value
$.on input, 'change', @cb.mode
sortEntry =
el: $.el 'span', textContent: 'Sort by'
subEntries: [
{ el: $.el 'label', innerHTML: '<input type=radio name="Index Sort" value="bump"> Bump order' }
{ el: $.el 'label', innerHTML: '<input type=radio name="Index Sort" value="lastreply"> Last reply' }
{ el: $.el 'label', innerHTML: '<input type=radio name="Index Sort" value="birth"> Creation date' }
{ el: $.el 'label', innerHTML: '<input type=radio name="Index Sort" value="replycount"> Reply count' }
{ el: $.el 'label', innerHTML: '<input type=radio name="Index Sort" value="filecount"> File count' }
]
for label in sortEntry.subEntries
input = label.el.firstChild
input.checked = Conf['Index Sort'] is input.value
$.on input, 'change', $.cb.value
$.on input, 'change', @cb.sort
repliesEntry =
el: $.el 'label',
innerHTML: '<input type=checkbox name="Show Replies"> Show replies'
anchorEntry =
el: $.el 'label',
innerHTML: '<input type=checkbox name="Anchor Hidden Threads"> Anchor hidden threads'
title: 'Move hidden threads at the end of the index.'
refNavEntry =
el: $.el 'label',
innerHTML: '<input type=checkbox name="Refreshed Navigation"> Refreshed navigation'
title: 'Refresh index when navigating through pages.'
for label in [repliesEntry, anchorEntry, refNavEntry]
input = label.el.firstChild
{name} = input
input.checked = Conf[name]
$.on input, 'change', $.cb.checked
switch name
when 'Show Replies'
$.on input, 'change', @cb.replies
when 'Anchor Hidden Threads'
$.on input, 'change', @cb.sort
$.event 'AddMenuEntry',
type: 'header'
el: $.el 'span',
textContent: 'Index Navigation'
order: 90
subEntries: [modeEntry, sortEntry, repliesEntry, anchorEntry, refNavEntry]
$.addClass doc, 'index-loading'
@update()
@root = $.el 'div', className: 'board'
@pagelist = $.el 'div',
className: 'pagelist'
hidden: true
innerHTML: <%= importHTML('Features/Index-pagelist') %>
@navLinks = $.el 'div',
className: 'navLinks'
innerHTML: <%= importHTML('Features/Index-navlinks') %>
@searchInput = $ '#index-search', @navLinks
@currentPage = @getCurrentPage()
$.on window, 'popstate', @cb.popstate
$.on @pagelist, 'click', @cb.pageNav
$.on @searchInput, 'input', @onSearchInput
$.on $('#index-search-clear', @navLinks), 'click', @clearSearch
$.asap (-> $('.board', doc) or d.readyState isnt 'loading'), ->
board = $ '.board'
$.replace board, Index.root
# Hacks:
# - When removing an element from the document during page load,
# its ancestors will still be correctly created inside of it.
# - Creating loadable elements inside of an origin-less document
# will not download them.
# - Combine the two and you get a download canceller!
# Does not work on Firefox unfortunately. bugzil.la/939713
d.implementation.createDocument(null, null, null).appendChild board
for navLink in $$ '.navLinks'
$.rm navLink
$.after $.x('child::form/preceding-sibling::hr[1]'), Index.navLinks
$.rmClass doc, 'index-loading'
$.asap (-> $('.pagelist') or d.readyState isnt 'loading'), ->
$.replace $('.pagelist'), Index.pagelist
cb:
mode: ->
Index.togglePagelist()
Index.buildIndex()
sort: ->
Index.sort()
Index.buildIndex()
replies: ->
Index.buildThreads()
Index.sort()
Index.buildIndex()
popstate: (e) ->
pageNum = Index.getCurrentPage()
Index.pageLoad pageNum if Index.currentPage isnt pageNum
pageNav: (e) ->
return if e.shiftKey or e.altKey or e.ctrlKey or e.metaKey or e.button isnt 0
switch e.target.nodeName
when 'BUTTON'
a = e.target.parentNode
when 'A'
a = e.target
else
return
return if a.textContent is 'Catalog'
e.preventDefault()
Index.userPageNav +a.pathname.split('/')[2]
link: (e) ->
return if g.VIEW isnt 'index' or /catalog/.test @href
e.preventDefault()
Index.update()
scrollToIndex: ->
Header.scrollToIfNeeded Index.root
getCurrentPage: ->
+window.location.pathname.split('/')[2]
userPageNav: (pageNum) ->
if Conf['Refreshed Navigation'] and Conf['Index Mode'] is 'paged'
Index.update pageNum
else
Index.pageNav pageNum
pageNav: (pageNum) ->
return if Index.currentPage is pageNum
history.pushState null, '', if pageNum is 0 then './' else pageNum
Index.pageLoad pageNum
pageLoad: (pageNum) ->
Index.currentPage = pageNum
return if Conf['Index Mode'] isnt 'paged'
Index.buildIndex()
Index.setPage()
Index.scrollToIndex()
getPagesNum: ->
if Index.isSearching
Math.ceil (Index.sortedNodes.length / 2) / Index.threadsNumPerPage
else
Index.pagesNum
getMaxPageNum: ->
Math.max 0, Index.getPagesNum() - 1
togglePagelist: ->
Index.pagelist.hidden = Conf['Index Mode'] isnt 'paged'
buildPagelist: ->
pagesRoot = $ '.pages', Index.pagelist
maxPageNum = Index.getMaxPageNum()
if pagesRoot.childElementCount isnt maxPageNum + 1
nodes = []
for i in [0..maxPageNum] by 1
a = $.el 'a',
textContent: i
href: if i then i else './'
nodes.push $.tn('['), a, $.tn '] '
$.rmAll pagesRoot
$.add pagesRoot, nodes
Index.togglePagelist()
setPage: ->
pageNum = Index.getCurrentPage()
maxPageNum = Index.getMaxPageNum()
pagesRoot = $ '.pages', Index.pagelist
# Previous/Next buttons
prev = pagesRoot.previousSibling.firstChild
next = pagesRoot.nextSibling.firstChild
href = Math.max pageNum - 1, 0
prev.href = if href is 0 then './' else href
prev.firstChild.disabled = href is pageNum
href = Math.min pageNum + 1, maxPageNum
next.href = if href is 0 then './' else href
next.firstChild.disabled = href is pageNum
# <strong> current page
if strong = $ 'strong', pagesRoot
return if +strong.textContent is pageNum
$.replace strong, strong.firstChild
else
strong = $.el 'strong'
a = pagesRoot.children[pageNum]
$.before a, strong
$.add strong, a
update: (pageNum) ->
return unless navigator.onLine
Index.req?.abort()
Index.notice?.close()
if d.readyState isnt 'loading'
Index.notice = new Notice 'info', 'Refreshing index...'
else
# Delay the notice on initial page load
# and only display it for slow connections.
now = Date.now()
$.ready ->
setTimeout (->
return unless Index.req and !Index.notice
Index.notice = new Notice 'info', 'Refreshing index...'
), 5 * $.SECOND - (Date.now() - now)
pageNum = null if typeof pageNum isnt 'number' # event
onload = (e) -> Index.load e, pageNum
Index.req = $.ajax "//a.4cdn.org/#{g.BOARD}/catalog.json",
onabort: onload
onloadend: onload
,
whenModified: true
$.addClass Index.button, 'fa-spin'
load: (e, pageNum) ->
$.rmClass Index.button, 'fa-spin'
{req, notice} = Index
delete Index.req
delete Index.notice
if e.type is 'abort'
req.onloadend = null
notice.close()
return
try
if req.status is 200
Index.parse JSON.parse(req.response), pageNum
else if req.status is 304 and pageNum?
Index.pageNav pageNum
catch err
c.error 'Index failure:', err.stack
# network error or non-JSON content for example.
if notice
notice.setType 'error'
notice.el.lastElementChild.textContent = 'Index refresh failed.'
setTimeout notice.close, 2 * $.SECOND
else
new Notice 'error', 'Index refresh failed.', 2
return
if notice
notice.setType 'success'
notice.el.lastElementChild.textContent = 'Index refreshed!'
setTimeout notice.close, $.SECOND
timeEl = $ '#index-last-refresh', Index.navLinks
timeEl.dataset.utc = Date.parse req.getResponseHeader 'Last-Modified'
RelativeDates.update timeEl
Index.scrollToIndex()
parse: (pages, pageNum) ->
Index.parseThreadList pages
Index.buildThreads()
Index.sort()
Index.buildPagelist()
if pageNum?
Index.pageNav pageNum
return
Index.buildIndex()
Index.setPage()
parseThreadList: (pages) ->
Index.pagesNum = pages.length
Index.threadsNumPerPage = pages[0].threads.length
Index.liveThreadData = pages.reduce ((arr, next) -> arr.concat next.threads), []
Index.liveThreadIDs = Index.liveThreadData.map (data) -> data.no
for threadID, thread of g.BOARD.threads when thread.ID not in Index.liveThreadIDs
thread.collect()
return
buildThreads: ->
Index.nodes = []
threads = []
posts = []
for threadData, i in Index.liveThreadData
threadRoot = Build.thread g.BOARD, threadData
Index.nodes.push threadRoot, $.el 'hr'
if thread = g.BOARD.threads[threadData.no]
thread.setPage Math.floor i / Index.threadsNumPerPage
thread.setStatus 'Sticky', !!threadData.sticky
thread.setStatus 'Closed', !!threadData.closed
else
thread = new Thread threadData.no, g.BOARD
threads.push thread
continue if thread.ID of thread.posts
try
posts.push new Post $('.opContainer', threadRoot), thread, g.BOARD
catch err
# Skip posts that we failed to parse.
errors = [] unless errors
errors.push
message: "Parsing of Post No.#{thread} failed. Post will be skipped."
error: err
Main.handleErrors errors if errors
# Add the threads and <hr>s in a container to make sure all features work.
$.nodes Index.nodes
Main.callbackNodes Thread, threads
Main.callbackNodes Post, posts
$.event 'IndexRefresh'
buildReplies: (threadRoots) ->
posts = []
for threadRoot in threadRoots by 2
thread = Get.threadFromRoot threadRoot
i = Index.liveThreadIDs.indexOf thread.ID
continue unless lastReplies = Index.liveThreadData[i].last_replies
nodes = []
for data in lastReplies
if post = thread.posts[data.no]
nodes.push post.nodes.root
continue
nodes.push node = Build.postFromObject data, thread.board.ID
try
posts.push new Post node, thread, thread.board
catch err
# Skip posts that we failed to parse.
errors = [] unless errors
errors.push
message: "Parsing of Post No.#{data.no} failed. Post will be skipped."
error: err
$.add threadRoot, nodes
Main.handleErrors errors if errors
Main.callbackNodes Post, posts
sort: ->
switch Conf['Index Sort']
when 'bump'
sortedThreadIDs = Index.liveThreadIDs
when 'lastreply'
sortedThreadIDs = [Index.liveThreadData...].sort((a, b) ->
a = a.last_replies[a.last_replies.length - 1] if 'last_replies' of a
b = b.last_replies[b.last_replies.length - 1] if 'last_replies' of b
b.no - a.no
).map (data) -> data.no
when 'birth'
sortedThreadIDs = [Index.liveThreadIDs...].sort (a, b) -> b - a
when 'replycount'
sortedThreadIDs = [Index.liveThreadData...].sort((a, b) -> b.replies - a.replies).map (data) -> data.no
when 'filecount'
sortedThreadIDs = [Index.liveThreadData...].sort((a, b) -> b.images - a.images).map (data) -> data.no
Index.sortedNodes = []
for threadID in sortedThreadIDs
i = Index.liveThreadIDs.indexOf(threadID) * 2
Index.sortedNodes.push Index.nodes[i], Index.nodes[i + 1]
if Index.isSearching
Index.sortedNodes = Index.querySearch(Index.searchInput.value) or Index.sortedNodes
# Sticky threads
Index.sortOnTop (thread) -> thread.isSticky
# Highlighted threads
Index.sortOnTop((thread) -> thread.isOnTop) if Conf['Filter']
# Non-hidden threads
Index.sortOnTop((thread) -> !thread.isHidden) if Conf['Anchor Hidden Threads']
sortOnTop: (match) ->
offset = 0
for threadRoot, i in Index.sortedNodes by 2 when match Get.threadFromRoot threadRoot
Index.sortedNodes.splice offset++ * 2, 0, Index.sortedNodes.splice(i, 2)...
return
buildIndex: ->
if Conf['Index Mode'] is 'paged'
pageNum = Index.getCurrentPage()
nodesPerPage = Index.threadsNumPerPage * 2
nodes = Index.sortedNodes[nodesPerPage * pageNum ... nodesPerPage * (pageNum + 1)]
else
nodes = Index.sortedNodes
$.rmAll Index.root
Index.buildReplies nodes if Conf['Show Replies']
$.event 'IndexBuild', nodes
$.add Index.root, nodes
isSearching: false
clearSearch: ->
Index.searchInput.value = null
Index.onSearchInput()
Index.searchInput.focus()
onSearchInput: ->
if Index.isSearching = !!Index.searchInput.value.trim()
unless Index.searchInput.dataset.searching
Index.searchInput.dataset.searching = 1
Index.pageBeforeSearch = Index.getCurrentPage()
pageNum = 0
else
pageNum = Index.getCurrentPage()
else
pageNum = Index.pageBeforeSearch
delete Index.pageBeforeSearch
<% if (type === 'userscript') { %>
# XXX https://github.com/greasemonkey/greasemonkey/issues/1571
Index.searchInput.removeAttribute 'data-searching'
<% } else { %>
delete Index.searchInput.dataset.searching
<% } %>
Index.sort()
# Go to the last available page if we were past the limit.
pageNum = Math.min pageNum, Index.getMaxPageNum() if Conf['Index Mode'] is 'paged'
Index.buildPagelist()
if Index.currentPage is pageNum
Index.buildIndex()
Index.setPage()
else
Index.pageNav pageNum
querySearch: (query) ->
return unless keywords = query.toLowerCase().match /\S+/g
Index.search keywords
search: (keywords) ->
found = []
for threadRoot, i in Index.sortedNodes by 2
if Index.searchMatch Get.threadFromRoot(threadRoot), keywords
found.push Index.sortedNodes[i], Index.sortedNodes[i + 1]
found
searchMatch: (thread, keywords) ->
{info, file} = thread.OP
text = []
for key in ['comment', 'subject', 'name', 'tripcode', 'email']
text.push info[key] if key of info
text.push file.name if file
text = text.join(' ').toLowerCase()
for keyword in keywords
return false if -1 is text.indexOf keyword
return true

View File

@ -1,5 +1,18 @@
Main =
init: ->
pathname = location.pathname.split '/'
g.BOARD = new Board pathname[1]
return if g.BOARD.ID in ['z', 'fk']
g.VIEW =
switch pathname[2]
when 'res'
'thread'
when 'catalog'
'catalog'
else
'index'
if g.VIEW is 'thread'
g.THREADID = +pathname[3]
# flatten Config into Conf
# and get saved or default values
@ -24,21 +37,6 @@ Main =
$.on d, '4chanMainInit', Main.initStyle
initFeatures: ->
pathname = location.pathname.split '/'
g.BOARD = new Board pathname[1]
return if g.BOARD.ID in ['z', 'fk']
g.VIEW =
switch pathname[2]
when 'res'
'thread'
when 'catalog'
'catalog'
else
'index'
if g.VIEW is 'thread'
g.THREADID = +pathname[3]
switch location.hostname
when 'a.4cdn.org'
return
@ -47,7 +45,7 @@ Main =
return
when 'i.4cdn.org'
$.ready ->
if Conf['404 Redirect'] and ['4chan - Temporarily Offline', '4chan - 404 Not Found'].contains d.title
if Conf['404 Redirect'] and d.title in ['4chan - Temporarily Offline', '4chan - 404 Not Found']
Redirect.init()
pathname = location.pathname.split '/'
URL = Redirect.to 'file',
@ -70,13 +68,13 @@ Main =
return
# c.time 'All initializations'
init
'Polyfill': Polyfill
'Redirect': Redirect
'Header': Header
'Catalog Links': CatalogLinks
'Settings': Settings
'Index Generator': Index
'Announcement Hiding': PSAHiding
'Fourchan thingies': Fourchan
'Emoji': Emoji
@ -118,7 +116,6 @@ Main =
'Reveal Spoiler Thumbnails': RevealSpoilers
'Image Loading': ImageLoader
'Image Hover': ImageHover
'Comment Expansion': ExpandComment
'Thread Expansion': ExpandThread
'Thread Excerpt': ThreadExcerpt
'Favicon': Favicon
@ -132,8 +129,6 @@ Main =
'Keybinds': Keybinds
'Show Dice Roll': Dice
'Banner': Banner
'Infinite Scrolling': InfiniScroll
# c.timeEnd 'All initializations'
$.on d, 'AddCallback', Main.addCallback
@ -175,7 +170,7 @@ Main =
attributeFilter: ['href']
initReady: ->
if ['4chan - Temporarily Offline', '4chan - 404 Not Found'].contains d.title
if d.title in ['4chan - Temporarily Offline', '4chan - 404 Not Found']
if Conf['404 Redirect'] and g.VIEW is 'thread'
href = Redirect.to 'thread',
boardID: g.BOARD.ID
@ -187,26 +182,21 @@ Main =
# Something might have gone wrong!
Main.initStyle()
if board = $ '.board'
threads = []
posts = []
for threadRoot in $$ '.board > .thread', board
thread = new Thread +threadRoot.id[1..], g.BOARD
threads.push thread
for postRoot in $$ '.thread > .postContainer', threadRoot
try
posts.push new Post postRoot, thread, g.BOARD
catch err
# Skip posts that we failed to parse.
unless errors
errors = []
errors.push
message: "Parsing of Post No.#{postRoot.id.match(/\d+/)} failed. Post will be skipped."
error: err
if g.VIEW is 'thread' and threadRoot = $ '.thread'
thread = new Thread +threadRoot.id[1..], g.BOARD
posts = []
for postRoot in $$ '.thread > .postContainer', threadRoot
try
posts.push post = new Post postRoot, thread, g.BOARD, {isOriginalMarkup: true}
catch err
# Skip posts that we failed to parse.
errors = [] unless errors
errors.push
message: "Parsing of Post No.#{postRoot.id.match /\d+/} failed. Post will be skipped."
error: err
Main.handleErrors errors if errors
Main.callbackNodes Thread, threads
Main.callbackNodes Thread, [thread]
Main.callbackNodesDB Post, posts, ->
$.event '4chanXInitFinished'
@ -222,67 +212,44 @@ Main =
return
<% if (type === 'userscript') { %>
GMver = GM_info.version.split '.'
for v, i in "<%= meta.min.greasemonkey %>".split '.'
break if v < GMver[i]
continue if v is GMver[i]
new Notice 'warning', "Your version of Greasemonkey is outdated (v#{GM_info.version} instead of v<%= meta.min.greasemonkey %> minimum) and <%= meta.name %> may not operate correctly.", 30
break
<% } %>
try
localStorage.getItem '4chan-settings'
catch err
new Notice 'warning', 'Cookies need to be enabled on 4chan for <%= meta.name %> to properly function.', 30
Main.disableReports = true
$.event '4chanXInitFinished'
new Notice 'warning', 'Cookies need to be enabled on 4chan for <%= meta.name %> to operate properly.', 30
callbackNodes: (klass, nodes) ->
# get the nodes' length only once
len = nodes.length
for callback in klass.callbacks
# c.profile callback.name
i = 0
while i < len
node = nodes[i++]
try
callback.cb.call node
catch err
errors = [] unless errors
errors.push
message: "\"#{callback.name}\" crashed on #{klass.name} No.#{node} (/#{node.board}/)."
error: err
# c.profileEnd callback.name
Main.handleErrors errors if errors
i = 0
cb = klass.callbacks
while node = nodes[i++]
cb.execute node
return
callbackNodesDB: (klass, nodes, cb) ->
queue = []
errors = null
len = 0
i = 0
func = (node) ->
for callback in klass.callbacks
try
callback.cb.call node
catch err
errors = [] unless errors
errors.push
message: "\"#{callback.name}\" crashed on #{klass.name} No.#{node} (/#{node.board}/)."
error: err
# finish
unless queue.length
Main.handleErrors errors if errors
cb() if cb
{callbacks} = klass
softTask = ->
node = queue.shift()
func node
return unless queue.length
unless queue.length % 7
softTask = ->
node = nodes[i++]
callbacks.execute node
return cb() if len is i and cb
unless i % 7
setTimeout softTask, 0
else
softTask()
# get the nodes' length only once
len = nodes.length
i = 0
while i < len
node = nodes[i++]
queue.push node
len = nodes.length
softTask()
addCallback: (e) ->
@ -324,18 +291,13 @@ Main =
new Notice 'error', [div, logs], 30
parseError: (data) ->
Main.logError data
c.error data.message, data.error.stack
message = $.el 'div',
textContent: data.message
error = $.el 'div',
textContent: data.error
[message, error]
errors: []
logError: (data) ->
c.error data.message, data.error.stack
Main.errors.push data
isThisPageLegit: ->
# 404 error page or similar.
unless 'thisPageIsLegit' of Main

View File

@ -2,7 +2,7 @@ Settings =
init: ->
# 4chan X settings link
link = $.el 'a',
className: 'settings-link fourchanx-icon icon-wrench'
className: 'settings-link fa fa-wrench'
textContent: 'Settings'
href: 'javascript:;'
$.on link, 'click', Settings.open
@ -41,9 +41,7 @@ Settings =
return if Settings.dialog
$.event 'CloseMenu'
html = """
<%= grunt.file.read('src/General/html/Settings/Settings.html').replace(/>\s+</g, '><').trim() %>
"""
html = <%= importHTML('Settings/Settings') %>
Settings.overlay = overlay = $.el 'div',
id: 'overlay'
@ -124,7 +122,7 @@ Settings =
return
div = $.el 'div',
innerHTML: "<button></button><span class=description>: Clear manually-hidden threads and posts on all boards. Refresh the page to apply."
innerHTML: "<button></button><span class=description>: Clear manually-hidden threads and posts on all boards. Reload the page to apply."
button = $ 'button', div
hiddenNum = 0
$.get 'hiddenThreads', boards: {}, (item) ->
@ -187,7 +185,7 @@ Settings =
try
data = JSON.parse e.target.result
Settings.loadSettings data
if confirm 'Import successful. Refresh now?'
if confirm 'Import successful. Reload now?'
window.location.reload()
catch err
output.textContent = 'Import failed due to an error.'
@ -274,12 +272,11 @@ Settings =
data
filter: (section) ->
section.innerHTML = """
<%= grunt.file.read('src/General/html/Settings/Filter-select.html').replace(/>\s+</g, '><').trim() %>
"""
section.innerHTML = <%= importHTML('Settings/Filter-select') %>
select = $ 'select', section
$.on select, 'change', Settings.selectFilter
Settings.selectFilter.call select
selectFilter: ->
div = @nextElementSibling
if (name = @value) isnt 'guide'
@ -293,30 +290,24 @@ Settings =
$.on ta, 'change', $.cb.value
$.add div, ta
return
div.innerHTML = """
<%= grunt.file.read('src/General/html/Settings/Filter-guide.html').replace(/>\s+</g, '><').trim() %>
"""
div.innerHTML = <%= importHTML('Settings/Filter-guide') %>
sauce: (section) ->
section.innerHTML = """
<%= grunt.file.read('src/General/html/Settings/Sauce.html').replace(/>\s+</g, '><').trim() %>
"""
section.innerHTML = <%= importHTML('Settings/Sauce') %>
ta = $ 'textarea', section
$.get 'sauces', Conf['sauces'], (item) ->
ta.value = item['sauces']
$.on ta, 'change', $.cb.value
advanced: (section) ->
section.innerHTML = """
<%= grunt.file.read('src/General/html/Settings/Advanced.html').replace(/>\s+</g, '><').trim() %>
"""
items = {}
section.innerHTML = <%= importHTML('Settings/Advanced') %>
items = {}
inputs = {}
for name in ['boardnav', 'time', 'backlink', 'fileInfo', 'favicon', 'sageEmoji', 'emojiPos', 'usercss']
input = $ "[name=#{name}]", section
items[name] = Conf[name]
inputs[name] = input
event = if ['favicon', 'usercss', 'sageEmoji', 'emojiPos'].contains name
event = if name in ['favicon', 'usercss', 'sageEmoji', 'emojiPos']
'change'
else
'input'
@ -330,7 +321,7 @@ Settings =
$.get items, (items) ->
for key, val of items
continue if ['emojiPos'].contains key
continue if key is 'emojiPos'
input = inputs[key]
input.value = val
continue if key is 'usercss'
@ -351,7 +342,7 @@ Settings =
file: []
data.thread.push name
data.post.push name if archive.software is 'foolfuuka'
data.file.push name if archive.files.contains boardID
data.file.push name if boardID in archive.files
rows = []
boardOptions = []
@ -460,10 +451,10 @@ Settings =
$.cb.checked.call @
usercss: ->
CustomCSS.update()
keybinds: (section) ->
section.innerHTML = """
<%= grunt.file.read('src/General/html/Settings/Keybinds.html').replace(/>\s+</g, '><').trim() %>
"""
section.innerHTML = <%= importHTML('Settings/Keybinds') %>
tbody = $ 'tbody', section
items = {}
inputs = {}

View File

@ -173,7 +173,7 @@ UI = do ->
['0px', 'auto']
else
['auto', '0px']
[left, right] = if eRect.right + sRect.width < cWidth
[left, right] = if eRect.right + sRect.width < cWidth - 150
['100%', 'auto']
else
['auto', '100%']

File diff suppressed because one or more lines are too long

View File

@ -34,6 +34,9 @@
background-color: #F2F2F2;
color: #888;
}
.field::-webkit-search-decoration {
display: none;
}
.move {
cursor: move;
overflow: hidden;
@ -73,6 +76,9 @@ a {
div.center:not(.ad-cnt) {
display: none !important;
}
.page-num {
margin-right: -8px;
}
/* fixed, z-index */
#overlay,
@ -118,10 +124,10 @@ div.center:not(.ad-cnt) {
z-index: 10;
}
/* Header */
.fixed.top body {
.fixed.top-header body {
padding-top: 2em;
}
.fixed.bottom body {
.fixed.bottom-header body {
padding-bottom: 2em;
}
.fixed #header-bar {
@ -129,10 +135,10 @@ div.center:not(.ad-cnt) {
left: 0;
padding: 3px 4px 4px;
}
.fixed.top #header-bar {
.fixed.top-header #header-bar {
top: 0;
}
.fixed.bottom #header-bar {
.fixed.bottom-header #header-bar {
bottom: 0;
}
#header-bar {
@ -150,14 +156,14 @@ div.center:not(.ad-cnt) {
position: relative;
left: 150px;
}
.fixed.top #header-bar {
.fixed.top-header #header-bar {
border-bottom-width: 1px;
}
.fixed.bottom #header-bar {
.fixed.bottom-header #header-bar {
box-shadow: 0 -1px 2px rgba(0, 0, 0, .15);
border-top-width: 1px;
}
.fixed.bottom #header-bar .menu-button i {
.fixed.bottom-header #header-bar .menu-button i {
border-top: none;
border-bottom: 6px solid;
}
@ -168,12 +174,12 @@ div.center:not(.ad-cnt) {
box-shadow: none;
transition: all .8s .6s cubic-bezier(.55, .055, .675, .19);
}
.fixed.top #header-bar.autohide:not(:hover) {
.fixed.top-header #header-bar.autohide:not(:hover) {
margin-bottom: -1em;
-webkit-transform: translateY(-100%);
transform: translateY(-100%);
}
.fixed.bottom #header-bar.autohide:not(:hover) {
.fixed.bottom-header #header-bar.autohide:not(:hover) {
-webkit-transform: translateY(100%);
transform: translateY(100%);
}
@ -192,10 +198,10 @@ div.center:not(.ad-cnt) {
.fixed #header-bar #scroll-marker {
display: block;
}
.fixed.top #header-bar #scroll-marker {
.fixed.top-header #header-bar #scroll-marker {
top: 100%;
}
.fixed.bottom #header-bar #scroll-marker {
.fixed.bottom-header #header-bar #scroll-marker {
bottom: 100%;
}
#header-bar a:not(.entry):not(.close) {
@ -252,7 +258,7 @@ div.center:not(.ad-cnt) {
left: 0;
transition: all .8s .6s cubic-bezier(.55, .055, .675, .19);
}
.fixed.top #header-bar #notifications {
.fixed.top-header #header-bar #notifications {
position: absolute;
top: 100%;
}
@ -284,11 +290,14 @@ div.center:not(.ad-cnt) {
color: white;
}
.notification > .close {
padding: 6px;
top: 0;
padding: 7px;
top: 0px;
right: 5px;
position: absolute;
}
.notification > .fa-times::before {
font-size: 11px !important;
}
.message {
-moz-box-sizing: border-box;
box-sizing: border-box;
@ -335,7 +344,7 @@ div.center:not(.ad-cnt) {
}
#fourchanx-settings > nav a.close {
text-decoration: none;
padding: 2px;
padding: 0 2px;
}
.section-container {
overflow: auto;
@ -432,6 +441,37 @@ div.center:not(.ad-cnt) {
overflow: hidden;
}
/* Index */
:root.index-loading .navLinks,
:root.index-loading .board,
:root.index-loading .pagelist {
display: none;
}
#index-search {
padding-right: 1.5em;
width: 100px;
transition: color .25s, border-color .25s, width .25s;
}
#index-search:focus,
#index-search[data-searching] {
width: 200px;
}
#index-search-clear {
color: gray;
margin-left: -1.25em;
}
<% if (type === 'crx') { %>
/* ``::-webkit-*'' selectors break selector lists on Firefox. */
#index-search::-webkit-search-cancel-button,
<% } %>
#index-search:not([data-searching]) + #index-search-clear {
display: none;
}
.summary {
text-decoration: none;
}
/* Announcement Hiding */
:root.hide-announcement #globalMessage {
display: none;
@ -638,10 +678,15 @@ a.hide-announcement {
max-width: 75%;
padding-bottom: 16px;
}
/* Fappe Tyme */
.fappeTyme .thread > .noFile,
.fappeTyme .threadContainer > .noFile {
display: none;
}
/* Werk Tyme */
.werkTyme .post .file {
display: none;
}
/* Index/Reply Navigation */
#navlinks {
@ -897,7 +942,7 @@ input#qr-filename:not(.edit) {
opacity: .5;
overflow: hidden;
position: relative;
text-shadow: 0 1px 1px #000;
text-shadow: 0 0 2px #000;
-moz-transition: opacity .25s ease-in-out;
vertical-align: top;
background-size: cover;
@ -929,8 +974,7 @@ input#qr-filename:not(.edit) {
.remove {
background: none;
color: #e00;
font-weight: 700;
padding: 3px;
padding: 1px;
}
a:only-of-type > .remove {
display: none;
@ -975,7 +1019,7 @@ a:only-of-type > .remove {
}
/* Menu */
.menu-button {
.menu-button:not(.fa-bars) {
display: inline-block;
position: relative;
cursor: pointer;
@ -1029,7 +1073,7 @@ a:only-of-type > .remove {
left: 100%;
top: -1px;
}
.focused .submenu {
.focused > .submenu {
display: block;
}
.imp-exp-result {
@ -1274,4 +1318,12 @@ a:only-of-type > .remove {
:root.gal-hide-thumbnails:not(.gal-fit-height) .gal-name,
:root.gal-hide-thumbnails:not(.gal-fit-height) .gal-count {
right: 44px !important;
}
@media screen and (resolution: 1dppx) {
.fa-bars {
font-size: 14px;
}
#shortcuts .fa-bars {
vertical-align: -1px;
}
}

View File

@ -12,7 +12,7 @@
font-size: 9pt;
color: #89A;
}
:root.yotsuba-b #header-bar a, :root.yotsuba-b #notifications a {
:root.yotsuba #board-list a, :root.yotsuba #shortcuts a {
color: #34345C;
}

View File

@ -12,7 +12,7 @@
font-size: 9pt;
color: #B86;
}
:root.yotsuba #header-bar a, :root.yotsuba #notifications a {
:root.yotsuba #header-bar a {
color: #800000;
}

View File

@ -1,47 +1,23 @@
"""#{if isOP then '' else "<div class=sideArrows id=sa#{postID}>&gt;&gt;</div>"}
<div id=p#{postID} class='post #{if isOP then 'op' else 'reply'}#{
if capcode is 'admin_highlight' then
if capcodeIcon is 'admin_highlight' then
' highlightPost'
else
''
}'>
<div class='postInfoM mobile' id=pim#{postID}>
<span class='nameBlock#{capcodeClass}'>
<span class=name>
#{name or ''}
</span>
#{tripcode + capcodeStart + capcode + userID + flag + sticky + closed}
<br>#{subject}
</span>
<span class='dateTime postNum' data-utc=#{dateUTC}>
#{date}
<a href=#{"/#{boardID}/res/#{threadID}#p#{postID}"}>
No.
</a>
<a href='#{
if g.VIEW is 'thread' and g.THREADID is +threadID then
"javascript:quote(#{postID})"
else
"/#{boardID}/res/#{threadID}#q#{postID}"
}'>
#{postID}
</a>
</span>
</div>
#{if isOP then fileHTML else ''}
<div class='postInfo desktop' id=pi#{postID}>
<div class='postInfo' id=pi#{postID}>
<input type=checkbox name=#{postID} value=delete>
#{subject}
#{' '}<span class=subject>#{subject or ''}</span>#{' '}
<span class='nameBlock#{capcodeClass}'>
#{emailStart}
<span class=name>#{name or ''}</span>
#{tripcode + capcodeStart + emailEnd + capcode + userID + flag + sticky + closed}
#{tripcode + capcodeStart + emailEnd + capcodeIcon + userID + flag}
</span>#{" "}
<span class=dateTime data-utc=#{dateUTC}>#{date}</span>#{" "}
<span class='postNum desktop'>
<span class=dateTime data-utc=#{dateUTC}>#{date}</span>#{' '}
<span class='postNum'>
<a href=#{"/#{boardID}/res/#{threadID}#p#{postID}"} title='Highlight this post'>No.</a>
<a href='#{
if g.VIEW is 'thread' and g.THREADID is +threadID then
@ -49,11 +25,12 @@
else
"/#{boardID}/res/#{threadID}#q#{postID}"
}' title='Quote this post'>#{postID}</a>
#{pageIcon + sticky + closed + replyLink}
</span>
</div>
#{if isOP then '' else fileHTML}
<blockquote class=postMessage id=m#{postID}>#{comment or ''}</blockquote>#{" "}
<blockquote class=postMessage id=m#{postID}>#{comment or ''}</blockquote>#{' '}
</div>"""

View File

@ -0,0 +1,4 @@
[<a href="./catalog">Catalog</a>]&nbsp;
[<time id="index-last-refresh" title="Last index refresh">...</time>]&nbsp;
<input type="search" id="index-search" class="field" placeholder="Search">
<a id="index-search-clear" class="fa fa-times-circle" href="javascript:;"></a>

View File

@ -0,0 +1,14 @@
<div class="prev">
<a>
<button disabled>Previous</button>
</a>
</div>
<div class="pages"></div>
<div class="next">
<a>
<button disabled>Next</button>
</a>
</div>
<div class="pages cataloglink">
<a href="./catalog">Catalog</a>
</div>

View File

@ -23,7 +23,7 @@
For example: <code>highlight;</code> or <code>highlight:wallpaper;</code>.
</li>
<li>
Highlighted OPs will have their threads put on top of board pages by default.<br>
Highlighted OPs will have their threads put on top of the board index by default.<br>
For example: <code>top:yes;</code> or <code>top:no;</code>.
</li>
</ul>

View File

@ -1,31 +1,17 @@
String::capitalize = ->
@charAt(0).toUpperCase() + @slice(1);
String::contains = (string) ->
@indexOf(string) > -1
Array::contains = (object) ->
@indexOf(object) > -1
Array::indexOf = (object) ->
i = @length
while i--
return i if @[i] is object
return i
# loosely follows the jquery api:
# http://api.jquery.com/
# not chainable
$ = (selector, root=d.body) ->
root.querySelector selector
$.extend = (object, properties) ->
for key, val of properties
continue unless properties.hasOwnProperty key
object[key] = val
$.extend = (obj, prop) ->
obj[key] = val for key, val of prop when prop.hasOwnProperty key
return
$.DAY = 24 * ($.HOUR = 60 * ($.MINUTE = 60 * ($.SECOND = 1000)))
$.DAY = 24 *
$.HOUR = 60 *
$.MINUTE = 60 *
$.SECOND = 1000
$.id = (id) ->
d.getElementById id
@ -66,7 +52,7 @@ $.ajax = do ->
type or= form and 'post' or 'get'
r.open type, url, !sync
if whenModified
r.setRequestHeader 'If-Modified-Since', lastModified[url] or '0'
r.setRequestHeader 'If-Modified-Since', lastModified[url] if url of lastModified
$.on r, 'load', -> lastModified[url] = r.getResponseHeader 'Last-Modified'
$.extend r, options
$.extend r.upload, upCallbacks
@ -137,7 +123,7 @@ $.toggleClass = (el, className) ->
el.classList.toggle className
$.hasClass = (el, className) ->
el.classList.contains className
className in el.classList
$.rm = do ->
if 'remove' of Element::
@ -147,7 +133,7 @@ $.rm = do ->
$.rmAll = (root) ->
# jsperf.com/emptify-element
while node = root.firstChild
for node in [root.childNodes...]
# HTMLSelectElement.remove !== Element.remove
root.removeChild node
return
@ -287,7 +273,7 @@ $.sync = do ->
chrome.storage.onChanged.addListener (changes) ->
for key of changes
if cb = $.syncing[key]
cb changes[key].newValue
cb changes[key].newValue, key
return
(key, cb) -> $.syncing[key] = cb
@ -330,9 +316,8 @@ $.get = (key, val, cb) ->
count = 0
done = (item) ->
{lastError} = chrome.runtime
if lastError
c.error lastError, lastError.message or 'No message.'
if chrome.runtime.lastError
c.error chrome.runtime.lastError.message
$.extend items, item
cb items unless --count
@ -368,11 +353,12 @@ $.set = do ->
set()
<% } else { %>
# http://wiki.greasespot.net/Main_Page
$.sync = do ->
$.on window, 'storage', (e) ->
if cb = $.syncing[e.key]
cb JSON.parse e.newValue
$.on window, 'storage', ({key, newValue}) ->
if cb = $.syncing[key]
cb JSON.parse(newValue), key
(key, cb) -> $.syncing[g.NAMESPACE + key] = cb
$.delete = (keys) ->

View File

@ -0,0 +1,20 @@
class Callbacks
push: ({name, cb}) -> @[name] = cb
clean: ->
@rm name for name of @ when @hasOwnProperty name
return
rm: (name) -> delete @[name]
execute: (node) ->
for name of @ when @hasOwnProperty name
try
@[name].call node
catch err
errors = [] unless errors
errors.push
message: ['"', name, '" crashed on node No.', node, ' (', node.board, ').'].join('')
error: err
Main.handleErrors errors if errors

View File

@ -1,6 +1,8 @@
<%= grunt.file.read('src/General/lib/callbacks.class') %>
<%= grunt.file.read('src/General/lib/board.class') %>
<%= grunt.file.read('src/General/lib/thread.class') %>
<%= grunt.file.read('src/General/lib/post.class') %>
<%= grunt.file.read('src/General/lib/clone.class') %>
<%= grunt.file.read('src/General/lib/databoard.class') %>
<%= grunt.file.read('src/General/lib/notice.class') %>
<%= grunt.file.read('src/General/lib/notice.class') %>
<%= grunt.file.read('src/General/lib/randomaccesslist.class') %>

View File

@ -74,7 +74,9 @@ class DataBoard
ajaxClean: (boardID) ->
$.cache "//a.4cdn.org/#{boardID}/threads.json", (e) =>
return if e.target.status isnt 200
if e.target.status isnt 200
@delete boardID if e.target.status is 404
return
board = @data.boards[boardID]
threads = {}
for page in JSON.parse e.target.response

View File

@ -1,7 +1,7 @@
class Notice
constructor: (type, content, @timeout) ->
@el = $.el 'div',
innerHTML: '<a href=javascript:; class=close title=Close>×</a><div class=message></div>'
innerHTML: '<a href=javascript:; class="close fa fa-times" title=Close></a><div class=message></div>'
@el.style.opacity = 0
@setType type
$.on @el.firstElementChild, 'click', @close
@ -19,7 +19,7 @@ class Notice
$.on d, 'visibilitychange', @add
return
$.off d, 'visibilitychange', @add
$.add $.id('notifications'), @el
$.add Header.noticesRoot, @el
@el.clientHeight # force reflow
@el.style.opacity = 1
setTimeout @close, @timeout * $.SECOND if @timeout

View File

@ -6,7 +6,7 @@ Polyfill =
@visibility()
<% } %>
notificationPermission: ->
return if !window.Notification or 'permission' of Notification
return if !window.Notification or 'permission' of Notification or !window.webkitNotifications
Object.defineProperty Notification, 'permission',
get: ->
switch webkitNotifications.checkPermission()
@ -27,7 +27,7 @@ Polyfill =
cb new Blob [ui8a], type: 'image/png'
visibility: ->
# page visibility API
return unless 'webkitHidden' of document
return if 'visibilityState' of d
Object.defineProperties HTMLDocument.prototype,
visibilityState:
get: -> @webkitVisibilityState

View File

@ -1,11 +1,12 @@
class Post
@callbacks = []
@callbacks = new Callbacks()
toString: -> @ID
constructor: (root, @thread, @board, that={}) ->
@ID = +root.id[2..]
@fullID = "#{@board}.#{@ID}"
@cleanup root if that.isOriginalMarkup
post = $ '.post', root
info = $ '.postInfo', post
@nodes =
@ -55,7 +56,7 @@ class Post
@parseComment()
@parseQuotes()
@parseFile(that)
@parseFile that
@clones = []
g.posts[@fullID] = thread.posts[@] = board.posts[@] = @
@ -111,7 +112,7 @@ class Post
# ES6 Set when?
fullID = "#{match[1]}.#{match[2]}"
@quotes.push fullID unless @quotes.contains fullID
@quotes.push fullID unless fullID in @quotes
parseFile: (that) ->
return unless (fileEl = $ '.file', @nodes.post) and thumb = $ 'img[data-md5]', fileEl
@ -149,6 +150,13 @@ class Post
if @file.isImage = /(jpg|png|gif)$/i.test @file.name
@file.dimensions = fileText.textContent.match(/\d+x\d+/)[0]
cleanup: (root) ->
for node in $$ '.mobile', root
$.rm node
for node in $$ '.desktop', root
$.rmClass node, 'desktop'
return
kill: (file, now) ->
now or= new Date()
if file
@ -203,6 +211,12 @@ class Post
$.rmClass quotelink, 'deadlink'
return
collect: ->
@kill()
delete g.posts[@fullID]
delete @thread.posts[@]
delete @board.posts[@]
addClone: (context) ->
new Clone @, context

View File

@ -0,0 +1,58 @@
class RandomAccessList
constructor: ->
@length = 0
push: (item) ->
{ID} = item
return if @[ID]
{last} = @
item.prev = last
@[ID] = item
@last = if last
last.next = item
else
@first = item
@length++
after: (root, item) ->
return if item.prev is root
@rmi item
{next} = root
root.next = item
item.prev = root
item.next = next
next.prev = item
prepend: (item) ->
{first} = @
return if item is first or not @[item.ID]
@rmi item
item.next = first
first.prev = item
@first = item
delete item.prev
shift: ->
@rm @first.ID
rm: (ID) ->
item = @[ID]
return unless item
delete @[ID]
@length--
@rmi item
delete item.next
delete item.prev
rmi: (item) ->
{prev, next} = item
if prev
prev.next = next
else
@first = next
if next
next.prev = prev
else
@last = prev

View File

@ -1,13 +1,50 @@
class Thread
@callbacks = []
@callbacks = new Callbacks()
toString: -> @ID
constructor: (@ID, @board) ->
@fullID = "#{@board}.#{@ID}"
@posts = {}
@fullID = "#{@board}.#{@ID}"
@posts = {}
@isSticky = false
@isClosed = false
@postLimit = false
@fileLimit = false
g.threads[@fullID] = board.threads[@] = @
setPage: (pageNum) ->
icon = $ '.page-num', @OP.nodes.post
for key in ['title', 'textContent']
icon[key] = icon[key].replace /\d+/, pageNum
return
setStatus: (type, status) ->
name = "is#{type}"
return if @[name] is status
@[name] = status
return unless @OP
typeLC = type.toLowerCase()
unless status
$.rm $ ".#{typeLC}Icon", @OP.nodes.info
return
icon = $.el 'img',
src: "//s.4cdn.org/image/#{typeLC}#{if window.devicePixelRatio >= 2 then '@2x' else ''}.gif"
alt: type
title: type
className: "#{typeLC}Icon"
root = if type is 'Closed' and @isSticky
$ '.stickyIcon', @OP.nodes.info
else if g.VIEW is 'index'
$ '.page-num', @OP.nodes.info
else
$ '[title="Quote this post"]', @OP.nodes.info
$.after root, [$.tn(' '), icon]
kill: ->
@isDead = true
@timeOfDeath = Date.now()
collect: ->
for postID, post in @posts
post.collect()
delete g.threads[@fullID]
delete @board.threads[@]

View File

@ -15,7 +15,7 @@
"run_at": "document_start"
}],
"homepage_url": "<%= meta.page %>",
"minimum_chrome_version": "27",
"minimum_chrome_version": "<%= meta.min.chrome %>",
"permissions": [
"storage"
]

View File

@ -1,8 +1,8 @@
// ==UserScript==
// @name <%= meta.name %>
// @version <%= version %>
// @minGMVer 1.13
// @minFFVer 22
// @minGMVer <%= meta.min.greasemonkey %>
// @minFFVer <%= meta.min.firefox %>
// @namespace <%= name %>
// @description <%= description %>
// @license MIT; <%= meta.repo %>blob/<%= meta.mainBranch %>/LICENSE

View File

@ -2,33 +2,21 @@ FappeTyme =
init: ->
return if !(Conf['Fappe Tyme'] or Conf['Werk Tyme']) or g.VIEW is 'catalog' or g.BOARD is 'f'
if Conf['Fappe Tyme']
for type in ["Fappe", "Werk"] when Conf["#{type} Tyme"]
lc = type.toLowerCase()
el = $.el 'label',
innerHTML: "<input type=checkbox name=fappe-tyme> Fappe Tyme"
title: 'Fappe Tyme'
innerHTML: "<input type=checkbox name=#{lc}> #{type} Tyme"
title: "#{type} Tyme"
FappeTyme.fappe = input = el.firstElementChild
$.on input, 'change', FappeTyme.cb.fappe
FappeTyme[lc] = input = el.firstElementChild
$.on input, 'change', FappeTyme.cb.toggle.bind input
$.event 'AddMenuEntry',
type: 'header'
el: el
order: 97
if Conf['Werk Tyme']
el = $.el 'label',
innerHTML: "<input type=checkbox name=werk-tyme> Werk Tyme"
title: 'Werk Tyme'
FappeTyme.werk = input = el.firstElementChild
$.on input, 'change', FappeTyme.cb.werk
$.event 'AddMenuEntry',
type: 'header'
el: el
order: 98
FappeTyme.cb.set lc if Conf[lc]
Post.callbacks.push
name: 'Fappe Tyme'
@ -39,9 +27,11 @@ FappeTyme =
$.addClass @nodes.root, "noFile"
cb:
fappe: ->
$.toggleClass doc, 'fappeTyme'
FappeTyme.fappe.checked = $.hasClass doc, 'fappeTyme'
werk: ->
$.toggleClass doc, 'werkTyme'
FappeTyme.werk.checked = $.hasClass doc, 'werkTyme'
set: (type) ->
FappeTyme[type].checked = Conf[type]
$["#{if Conf[type] then 'add' else 'rm'}Class"] doc, "#{type}Tyme"
toggle: ->
Conf[@name] = !Conf[@name]
FappeTyme.cb.set @name
$.cb.checked.call FappeTyme[@name]

View File

@ -6,7 +6,7 @@ Gallery =
href: 'javascript:;'
id: 'appchan-gal'
title: 'Gallery'
className: 'fourchanx-icon icon-picture'
className: 'fa fa-picture'
textContent: 'Gallery'
$.on el, 'click', @cb.toggle
@ -247,7 +247,7 @@ Gallery =
label = $.el 'label',
innerHTML: "<input type=checkbox name='#{name}'> #{name}"
input = label.firstElementChild
if ['Fit Width', 'Fit Height', 'Hide Thumbnails'].contains name
if name in ['Fit Width', 'Fit Height', 'Hide Thumbnails']
$.on input, 'change', Gallery.cb.setFitness
input.checked = Conf[name]
$.event 'change', null, input

View File

@ -3,12 +3,12 @@ ImageExpand =
return if g.VIEW is 'catalog' or !Conf['Image Expansion']
@EAI = $.el 'a',
className: 'expand-all-shortcut fourchanx-icon icon-resize-full'
className: 'expand-all-shortcut fa fa-expand'
textContent: 'EAI'
title: 'Expand All Images'
href: 'javascript:;'
$.on @EAI, 'click', ImageExpand.cb.toggleAll
Header.addShortcut @EAI, 2
Header.addShortcut @EAI, 3
Post.callbacks.push
name: 'Image Expansion'
@ -33,11 +33,11 @@ ImageExpand =
toggleAll: ->
$.event 'CloseMenu'
if ImageExpand.on = $.hasClass ImageExpand.EAI, 'expand-all-shortcut'
ImageExpand.EAI.className = 'contract-all-shortcut fourchanx-icon icon-resize-small'
ImageExpand.EAI.className = 'contract-all-shortcut fa fa-compress'
ImageExpand.EAI.title = 'Contract All Images'
func = ImageExpand.expand
else
ImageExpand.EAI.className = 'expand-all-shortcut fourchanx-icon icon-resize-full'
ImageExpand.EAI.className = 'expand-all-shortcut fa fa-expand'
ImageExpand.EAI.title = 'Expand All Images'
func = ImageExpand.contract
for ID, post of g.posts
@ -46,7 +46,7 @@ ImageExpand =
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)
Conf['Expand from here'] and Header.getTopOf(file.thumb) < 0)
continue
$.queueTask func, post
return
@ -62,7 +62,7 @@ ImageExpand =
# Scroll back to the thumbnail when contracting the image
# to avoid being left miles away from the relevant post.
{root} = post.nodes
rect = (if Conf['Advance on contract'] then do ->
{top, left} = (if Conf['Advance on contract'] then do ->
next = root
while next = $.x "following::div[contains(@class,'postContainer')][1]", next
continue if $('.stub', next) or next.offsetHeight is 0
@ -72,13 +72,13 @@ ImageExpand =
root
).getBoundingClientRect()
if rect.top < 0
y = rect.top
if top < 0
y = top
if Conf['Fixed Header'] and not Conf['Bottom Header']
headRect = Header.bar.getBoundingClientRect()
y -= headRect.top + headRect.height
if rect.left < 0
if left < 0
x = -window.scrollX
window.scrollBy x, y if x or y
ImageExpand.contract post
@ -116,13 +116,12 @@ ImageExpand =
$.addClass post.nodes.root, 'expanded-image'
$.rmClass post.file.thumb, 'expanding'
return
prev = post.nodes.root.getBoundingClientRect()
{bottom} = post.nodes.root.getBoundingClientRect()
$.queueTask ->
$.addClass post.nodes.root, 'expanded-image'
$.rmClass post.file.thumb, 'expanding'
return unless prev.top + prev.height <= 0
curr = post.nodes.root.getBoundingClientRect()
window.scrollBy 0, curr.height - prev.height + curr.top - prev.top
return unless bottom <= 0
window.scrollBy 0, post.nodes.root.getBoundingClientRect().bottom - bottom
error: ->
post = Get.postFromNode @

View File

@ -288,8 +288,13 @@ Linkify =
LiveLeak:
regExp: /.*(?:liveleak.com\/view.+i=)([0-9a-z_]+)/
el: (a) ->
$.el 'object',
innerHTML: "<embed src='http://www.liveleak.com/e/#{a.dataset.uid}?autostart=true' wmode='opaque' width='640' height='390' pluginspage='http://get.adobe.com/flashplayer/' type='application/x-shockwave-flash'></embed>"
el = $.el 'iframe',
width: "640",
height: "360",
src: "http://www.liveleak.com/ll_embed?i=#{a.dataset.uid}",
frameborder: "0"
el.setAttribute "allowfullscreen", "true"
el
MediaCrush:
regExp: /.*(?:mediacru.sh\/)([0-9a-z_]+)/i
@ -298,7 +303,7 @@ Linkify =
el = $.el 'div'
$.cache "https://mediacru.sh/#{a.dataset.uid}.json", ->
{status} = @
return div.innerHTML = "ERROR #{status}" unless [200, 304].contains status
return div.innerHTML = "ERROR #{status}" unless status in [200, 304]
{files} = JSON.parse @response
for type in ['video/mp4', 'video/ogv', 'image/svg+xml', 'image/png', 'image/gif', 'image/jpeg', 'image/svg', 'audio/mpeg']
for file in files
@ -404,8 +409,10 @@ Linkify =
YouTube:
regExp: /.*(?:youtu.be\/|youtube.*v=|youtube.*\/embed\/|youtube.*\/v\/|youtube.*videos\/)([^#\&\?]*)\??(t\=.*)?/
el: (a) ->
$.el 'iframe',
el = $.el 'iframe',
src: "//www.youtube.com/embed/#{a.dataset.uid}#{if a.dataset.option then '#' + a.dataset.option else ''}?wmode=opaque"
el.setAttribute "allowfullscreen", "true"
el
title:
api: (uid) -> "https://gdata.youtube.com/feeds/api/videos/#{uid}?alt=json&fields=title/text(),yt:noembed,app:control/yt:state/@reasonCode"
text: (data) -> data.entry.title.$t

View File

@ -10,18 +10,23 @@ Menu =
node: ->
if @isClone
$.on $('.menu-button', @nodes.info), 'click', Menu.toggle
else
$.add @nodes.info, [$.tn('\u00A0'), Menu.makeButton()]
return
$.add @nodes.info, Menu.makeButton()
makeButton: do ->
a = $.el 'a',
className: 'menu-button brackets-wrap'
innerHTML: '<i></i>'
href: 'javascript:;'
frag = null
->
button = a.cloneNode true
$.on button, 'click', Menu.toggle
button
unless frag?
frag = $.nodes [
$.tn(' ')
$.el 'a',
className: 'menu-button'
innerHTML: '[<i></i>]'
href: 'javascript:;'
]
clone = frag.cloneNode true
$.on clone.lastElementChild, 'click', Menu.toggle
clone
toggle: (e) ->
post = Get.postFromNode @

View File

@ -1,11 +1,10 @@
CatalogLinks =
init: ->
return unless Conf['Catalog Links']
el = $.el 'label',
CatalogLinks.el = el = $.el 'label',
id: 'toggleCatalog'
href: 'javascript:;'
innerHTML: "<input type=checkbox #{if Conf['Header catalog links'] then 'checked' else ''}> Catalog Links"
title: "Turn catalog links #{if Conf['Header catalog links'] then 'off' else 'on'}."
input = $ 'input', el
$.on input, 'change', @toggle
@ -22,32 +21,33 @@ CatalogLinks =
toggle: ->
$.event 'CloseMenu'
$.set 'Header catalog links', useCatalog = @checked
CatalogLinks.set useCatalog
$.set 'Header catalog links', @checked
CatalogLinks.set @checked
set: (useCatalog) ->
path = if useCatalog then 'catalog' else ''
for a in $$ """
#board-list a:not(.catalog),
#boardNavDesktopFoot a
"""
board = a.pathname.split('/')[1]
continue if ['f', 'status', '4chan'].contains(board) or !board
if Conf['External Catalog']
a.href = if useCatalog
CatalogLinks.external board
else
"/#{board}/"
else
a.pathname = "/#{board}/#{path}"
@title = "Turn catalog links #{if useCatalog then 'off' else 'on'}."
generateURL = if useCatalog and Conf['External Catalog']
CatalogLinks.external
else
(board) -> a.href = "/#{board}/#{path}"
for a in $$ """#board-list a:not(.catalog), #boardNavDesktopFoot a"""
continue if a.hostname not in ['boards.4chan.org', 'catalog.neet.tv', '4index.gropes.us'] or
!(board = a.pathname.split('/')[1]) or
board in ['f', 'status', '4chan']
# Href is easier than pathname because then we don't have
# conditions where External Catalog has been disabled between switches.
a.href = generateURL board
CatalogLinks.el.title = "Turn catalog links #{if useCatalog then 'off' else 'on'}."
external: (board) ->
return (
if ['a', 'c', 'g', 'co', 'k', 'm', 'o', 'p', 'v', 'vg', 'w', 'cm', '3', 'adv', 'an', 'cgl', 'ck', 'diy', 'fa', 'fit', 'int', 'jp', 'mlp', 'lit', 'mu', 'n', 'po', 'sci', 'toy', 'trv', 'tv', 'vp', 'x', 'q'].contains board
switch board
when 'a', 'c', 'g', 'co', 'k', 'm', 'o', 'p', 'v', 'vg', 'w', 'cm', '3', 'adv', 'an', 'cgl', 'ck', 'diy', 'fa', 'fit', 'int', 'jp', 'mlp', 'lit', 'mu', 'n', 'po', 'sci', 'toy', 'trv', 'tv', 'vp', 'x', 'q'
"http://catalog.neet.tv/#{board}"
else if ['d', 'e', 'gif', 'h', 'hr', 'hc', 'r9k', 's', 'pol', 'soc', 'u', 'i', 'ic', 'hm', 'r', 'w', 'wg', 'wsg', 't', 'y'].contains board
when 'd', 'e', 'gif', 'h', 'hr', 'hc', 'r9k', 's', 'pol', 'soc', 'u', 'i', 'ic', 'hm', 'r', 'w', 'wg', 'wsg', 't', 'y'
"http://4index.gropes.us/#{board}"
else
"/#{board}/catalog"
)
"/#{board}/catalog"

View File

@ -32,7 +32,7 @@ ExpandComment =
post.nodes.comment = post.nodes.shortComment
parse: (req, a, post) ->
{status} = req
unless [200, 304].contains status
unless status in [200, 304]
a.textContent = "Error #{req.statusText} (#{status})"
return

View File

@ -1,111 +1,97 @@
ExpandThread =
init: ->
return if g.VIEW isnt 'index' or !Conf['Thread Expansion']
@statuses = {}
$.on d, 'IndexRefresh', @onIndexRefresh
Thread.callbacks.push
name: 'Thread Expansion'
cb: @node
node: ->
return unless span = $.x 'following-sibling::span[contains(@class,"summary")][1]', @OP.nodes.root
[posts, files] = span.textContent.match /\d+/g
a = $.el 'a',
textContent: ExpandThread.text '+', posts, files
className: 'summary'
href: 'javascript:;'
setButton: (thread) ->
return unless a = $.x 'following-sibling::a[contains(@class,"summary")][1]', thread.OP.nodes.root
a.textContent = ExpandThread.text '+', a.textContent.match(/\d+/g)...
$.on a, 'click', ExpandThread.cbToggle
$.replace span, a
onIndexRefresh: ->
for threadID, status of ExpandThread.statuses
status.req?.abort()
delete ExpandThread.statuses[threadID]
for threadID, thread of g.BOARD.threads
ExpandThread.setButton thread
return
text: (status, posts, files) ->
"#{status} #{posts} post#{if posts > 1 then 's' else ''}" +
(if +files then " and #{files} image repl#{if files > 1 then 'ies' else 'y'}" else "") +
" #{if status is '-' then 'shown' else 'omitted'}."
cbToggle: ->
cbToggle: (e) ->
return if e.shiftKey or e.altKey or e.ctrlKey or e.metaKey or e.button isnt 0
e.preventDefault()
ExpandThread.toggle Get.threadFromNode @
toggle: (thread) ->
threadRoot = thread.OP.nodes.root.parentNode
a = $ '.summary', threadRoot
switch thread.isExpanded
when false, undefined
for post in $$ '.thread > .postContainer', threadRoot
ExpandComment.expand Get.postFromRoot post
unless a
thread.isExpanded = true
return
thread.isExpanded = 'loading'
[posts, files] = a.textContent.match /\d+/g
a.textContent = ExpandThread.text '...', posts, files
$.cache "//a.4cdn.org/#{thread.board}/res/#{thread}.json", ->
ExpandThread.parse @, thread, a
when 'loading'
thread.isExpanded = false
return unless a
[posts, files] = a.textContent.match /\d+/g
a.textContent = ExpandThread.text '+', posts, files
when true
thread.isExpanded = false
#goddamit moot
num = if thread.isSticky
1
else switch g.BOARD.ID
# XXX boards config
when 'b', 'vg' then 3
when 't' then 1
else 5
posts = $$ ".thread > .replyContainer", threadRoot
for post in [thread.OP.nodes.root].concat posts[-num..]
ExpandComment.contract Get.postFromRoot post
return unless a
postsCount = 0
filesCount = 0
for reply in posts[...-num]
if Conf['Quote Inlining']
# rm clones
inlined.click() while inlined = $ '.inlined', reply
postsCount++
filesCount++ if 'file' of Get.postFromRoot reply
$.rm reply
a.textContent = ExpandThread.text '+', postsCount, filesCount
return
parse: (req, thread, a) ->
return if a.textContent[0] is '+'
unless [200, 304].contains req.status
a.textContent = "Error #{req.statusText} (#{req.status})"
$.off a, 'click', ExpandThread.cbToggle
return unless a = $ '.summary', threadRoot
if thread.ID of ExpandThread.statuses
ExpandThread.contract thread, a, threadRoot
else
ExpandThread.expand thread, a, threadRoot
expand: (thread, a, threadRoot) ->
ExpandThread.statuses[thread] = status = {}
a.textContent = ExpandThread.text '...', a.textContent.match(/\d+/g)...
status.req = $.cache "//a.4cdn.org/#{thread.board}/res/#{thread}.json", ->
delete status.req
ExpandThread.parse @, thread, a
contract: (thread, a, threadRoot) ->
status = ExpandThread.statuses[thread]
delete ExpandThread.statuses[thread]
if status.req
status.req.abort()
a.textContent = ExpandThread.text '+', a.textContent.match(/\d+/g)... if a
return
thread.isExpanded = true
replies = $$ '.thread > .replyContainer', threadRoot
if Conf['Show Replies']
num = if thread.isSticky
1
else switch g.BOARD.ID
# XXX boards config
when 'b', 'vg' then 3
when 't' then 1
else 5
replies = replies[...-num]
postsCount = 0
filesCount = 0
for reply in replies
# rm clones
inlined.click() while inlined = $ '.inlined', reply if Conf['Quote Inlining']
postsCount++
filesCount++ if 'file' of Get.postFromRoot reply
$.rm reply
a.textContent = ExpandThread.text '+', postsCount, filesCount
parse: (req, thread, a) ->
if req.status not in [200, 304]
a.textContent = "Error #{req.statusText} (#{req.status})"
return
{posts} = JSON.parse req.response
if spoilerRange = posts.shift().custom_spoiler
Build.spoilerRange[thread.board] = spoilerRange
data = JSON.parse(req.response).posts
Build.spoilerRange[thread.board] = data.shift().custom_spoiler
postsObj = []
posts = []
postsRoot = []
filesCount = 0
for reply in posts
if post = thread.posts[reply.no]
for postData in data
if post = thread.posts[postData.no]
filesCount++ if 'file' of post
postsRoot.push post.nodes.root
continue
root = Build.postFromObject reply, thread.board.ID
root = Build.postFromObject postData, thread.board.ID
post = new Post root, thread, thread.board
link = $ 'a[title="Highlight this post"]', root
link.href = "res/#{thread}#p#{post}"
link.nextSibling.href = "res/#{thread}#q#{post}"
filesCount++ if 'file' of post
postsObj.push post
posts.push post
postsRoot.push root
Main.callbackNodes Post, postsObj
Main.callbackNodes Post, posts
$.after a, postsRoot
postsCount = postsRoot.length
postsCount = postsRoot.length
a.textContent = ExpandThread.text '-', postsCount, filesCount
Fourchan.parseThread thread.ID, 1, postsCount

View File

@ -8,7 +8,7 @@ FileInfo =
cb: @node
node: ->
return if !@file or @isClone
@file.text.innerHTML = FileInfo.funk FileInfo, @
@file.text.innerHTML = "<span class=file-info>#{FileInfo.funk FileInfo, @}</span>"
createFunc: (format) ->
code = format.replace /%(.)/g, (s, c) ->
if c of FileInfo.formatters
@ -21,11 +21,10 @@ FileInfo =
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 = if unit is 'MB'
Math.round(size * 100) / 100
else
size.toFixed()
"#{size} #{unit}"
escape: (name) ->
name.replace /<|>/g, (c) ->

View File

@ -6,8 +6,9 @@ Fourchan =
if board is 'g'
$.globalEval """
window.addEventListener('prettyprint', function(e) {
var pre = e.detail;
pre.innerHTML = prettyPrintOne(pre.innerHTML);
window.dispatchEvent(new CustomEvent('prettyprint:cb', {
detail: prettyPrintOne(e.detail)
}));
}, false);
"""
Post.callbacks.push
@ -32,9 +33,11 @@ Fourchan =
cb: @math
code: ->
return if @isClone
apply = (e) -> pre.innerHTML = e.detail
$.on window, 'prettyprint:cb', apply
for pre in $$ '.prettyprint:not(.prettyprinted)', @nodes.comment
$.event 'prettyprint', pre, window
$.addClass pre, 'prettyprinted'
$.event 'prettyprint', pre.innerHTML, window
$.off window, 'prettyprint:cb', apply
return
math: ->
return if @isClone or !$ '.math', @nodes.comment

View File

@ -2,20 +2,25 @@ Keybinds =
init: ->
return if g.VIEW is 'catalog' or !Conf['Keybinds']
for hotkey of Conf.hotkeys
$.sync hotkey, Keybinds.sync
init = ->
$.off d, '4chanXInitFinished', init
$.on d, 'keydown', Keybinds.keydown
$.on d, 'keydown', Keybinds.keydown
for node in $$ '[accesskey]'
node.removeAttribute 'accesskey'
return
$.on d, '4chanXInitFinished', init
sync: (key, hotkey) ->
Conf[hotkey] = key
keydown: (e) ->
return unless key = Keybinds.keyCode e
{target} = e
if ['INPUT', 'TEXTAREA'].contains target.nodeName
return unless /(Esc|Alt|Ctrl|Meta)/.test key
if target.nodeName in ['INPUT', 'TEXTAREA']
return unless /(Esc|Alt|Ctrl|Meta|Shift\+\w{2,})/.test key
threadRoot = Nav.getThread()
if op = $ '.op', threadRoot
thread = Get.postFromNode(op).thread
@ -59,11 +64,15 @@ Keybinds =
Keybinds.sage() if QR.nodes
when Conf['Submit QR']
QR.submit() if QR.nodes and !QR.status()
# Thread related
# Index/Thread related
when Conf['Update']
switch g.VIEW
when 'thread'
ThreadUpdater.update()
when 'index'
Index.update()
when Conf['Watch']
ThreadWatcher.toggle thread
when Conf['Update']
ThreadUpdater.update()
# Images
when Conf['Expand image']
Keybinds.img threadRoot
@ -72,22 +81,25 @@ Keybinds =
when Conf['Open Gallery']
Gallery.cb.toggle()
when Conf['fappeTyme']
FappeTyme.cb.fappe()
FappeTyme.cb.toggle.call {name: 'fappe'}
when Conf['werkTyme']
FappeTyme.cb.werk()
FappeTyme.cb.toggle.call {name: 'werk'}
# Board Navigation
when Conf['Front page']
window.location = "/#{g.BOARD}/0#delform"
if g.VIEW is 'index'
Index.userPageNav 0
else
window.location = "/#{g.BOARD}/"
when Conf['Open front page']
$.open "/#{g.BOARD}/#delform"
$.open "/#{g.BOARD}/"
when Conf['Next page']
return if g.VIEW is 'thread'
if form = $ '.next form'
window.location = form.action
return unless g.VIEW is 'index' and Conf['Index Mode'] is 'paged'
$('.next button', Index.pagelist).click()
when Conf['Previous page']
return if g.VIEW is 'thread'
if form = $ '.prev form'
window.location = form.action
return unless g.VIEW is 'index' and Conf['Index Mode'] is 'paged'
$('.prev button', Index.pagelist).click()
when Conf['Search form']
Index.searchInput.focus()
when Conf['Open catalog']
if Conf['External Catalog']
window.location = CatalogLinks.external(g.BOARD.ID)
@ -114,7 +126,7 @@ Keybinds =
when Conf['Deselect reply']
Keybinds.hl 0, threadRoot
when Conf['Hide']
ThreadHiding.toggle thread if g.VIEW is 'index'
ThreadHiding.toggle thread if ThreadHiding.db
when Conf['Previous Post Quoting You']
QuoteYou.cb.seek 'preceding'
when Conf['Next Post Quoting You']
@ -200,43 +212,31 @@ Keybinds =
location.href = url
hl: (delta, thread) ->
postEl = $ '.reply.highlight', thread
unless delta
if postEl = $ '.reply.highlight', thread
$.rmClass postEl, 'highlight'
$.rmClass postEl, 'highlight' if postEl
return
if Conf['Fixed Header'] and Conf['Bottom header']
topMargin = 0
else
headRect = Header.bar.getBoundingClientRect()
topMargin = headRect.top + headRect.height
if postEl = $ '.reply.highlight', thread
$.rmClass postEl, 'highlight'
rect = postEl.getBoundingClientRect()
if rect.bottom >= topMargin and rect.top <= doc.clientHeight # We're at least partially visible
if postEl
{height} = postEl.getBoundingClientRect()
if Header.getTopOf(postEl) >= -height and Header.getBottomOf(postEl) >= -height # We're at least partially visible
root = postEl.parentNode
axe = if delta is +1
axis = if delta is +1
'following'
else
'preceding'
next = $.x "#{axe}-sibling::div[contains(@class,'replyContainer')][1]/child::div[contains(@class,'reply')]", root
unless next
@focus postEl
return
return unless g.VIEW is 'thread' or $.x('ancestor::div[parent::div[@class="board"]]', next) is thread
rect = next.getBoundingClientRect()
if rect.top < 0 or rect.bottom > doc.clientHeight
if delta is -1
window.scrollBy 0, rect.top - topMargin
else
next.scrollIntoView false
return unless next = $.x "#{axis}-sibling::div[contains(@class,'replyContainer') and not(@hidden) and not(child::div[@class='stub'])][1]/child::div[contains(@class,'reply')]", root
Header.scrollToIfNeeded next, delta is +1
@focus next
$.rmClass postEl, 'highlight'
return
$.rmClass postEl, 'highlight'
replies = $$ '.reply', thread
replies.reverse() if delta is -1
for reply in replies
rect = reply.getBoundingClientRect()
if delta is +1 and rect.top >= topMargin or delta is -1 and rect.bottom <= doc.clientHeight
if delta is +1 and Header.getTopOf(reply) > 0 or delta is -1 and Header.getBottomOf(reply) > 0
@focus reply
return

View File

@ -38,29 +38,24 @@ Nav =
else
Nav.scroll +1
getThread: (full) ->
if Conf['Bottom header'] or !Conf['Fixed Header']
topMargin = 0
else
headRect = Header.bar.getBoundingClientRect()
topMargin = headRect.top + headRect.height
threads = $$('.thread').filter (thread) ->
thread = Get.threadFromRoot thread
!(thread.isHidden and !thread.stub)
for thread, i in threads
rect = thread.getBoundingClientRect()
if rect.bottom > topMargin # not scrolled past
return if full then [threads, thread, i, rect, topMargin] else thread
getThread: ->
for threadRoot in $$ '.thread'
thread = Get.threadFromRoot threadRoot
continue if thread.isHidden and !thread.stub
if Header.getTopOf(threadRoot) >= -threadRoot.getBoundingClientRect().height # not scrolled past
return threadRoot
return $ '.board'
scroll: (delta) ->
[threads, thread, i, rect, topMargin] = Nav.getThread true
top = rect.top - topMargin
# unless we're not at the beginning of the current thread
# (and thus wanting to move to beginning)
# or we're above the first thread and don't want to skip it
if (delta is -1 and top > -5) or (delta is +1 and top < 5)
top = threads[i + delta]?.getBoundingClientRect().top - topMargin
window.scrollBy 0, top
thread = Nav.getThread()
axis = if delta is +1
'following'
else
'preceding'
if next = $.x "#{axis}-sibling::div[contains(@class,'thread') and not(@hidden)][1]", thread
# Unless we're not at the beginning of the current thread,
# and thus wanting to move to beginning,
# or we're above the first thread and don't want to skip it.
top = Header.getTopOf thread
thread = next if delta is +1 and top < 5 or delta is -1 and top > -5
Header.scrollTo thread

View File

@ -1,13 +1,17 @@
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()
switch g.VIEW
when 'index'
@flush()
$.on d, 'visibilitychange', @flush
return unless Conf['Relative Post Dates']
when 'thread'
return unless Conf['Relative Post Dates']
@flush()
$.on d, 'visibilitychange ThreadUpdate', @flush if g.VIEW is 'thread'
else
return
Post.callbacks.push
name: 'Relative Post Dates'
@ -21,7 +25,7 @@ RelativeDates =
dateEl = @nodes.date
dateEl.title = dateEl.textContent
RelativeDates.setUpdate @
RelativeDates.update @
# diff is milliseconds from now.
relative: (diff, now, date) ->
@ -71,37 +75,41 @@ RelativeDates =
return if d.hidden
now = new Date()
update now for update in RelativeDates.stale
RelativeDates.update data, now for data 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
# `update()`, when called from `flush()`, updates the elements,
# and re-calls `setOwnTimeout()` to re-add `data` to the stale list later.
update: (data, now) ->
isPost = data instanceof Post
date = if isPost
data.info.date
else
new Date +data.dataset.utc
now or= new Date()
diff = now - date
relative = RelativeDates.relative diff, now, date
if isPost
for singlePost in [data].concat data.clones
singlePost.nodes.date.firstChild.textContent = relative
setOwnTimeout diff
markStale = -> RelativeDates.stale.push update
# Kick off initial timeout.
update new Date()
else
data.firstChild.textContent = relative
RelativeDates.setOwnTimeout diff, data
setOwnTimeout: (diff, data) ->
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 RelativeDates.markStale, delay, data
markStale: (data) ->
return if data in RelativeDates.stale # We can call RelativeDates.update() multiple times.
return if data instanceof Post and !g.posts[data.fullID] # collected post.
RelativeDates.stale.push data

View File

@ -45,5 +45,5 @@ Favicon =
Favicon.unread = Favicon.unreadNSFW
Favicon.unreadY = Favicon.unreadNSFWY
dead: 'data:image/gif;base64,<%= grunt.file.read("src/General/img/favicons/dead.gif", {encoding: "base64"}) %>'
logo: 'data:image/png;base64,<%= grunt.file.read("src/General/img/icon128.png", {encoding: "base64"}) %>'
dead: 'data:image/gif;base64,<%= grunt.file.read("src/General/img/favicons/dead.gif", {encoding: "base64"}) %>'
logo: 'data:image/png;base64,<%= grunt.file.read("src/General/img/icon128.png", {encoding: "base64"}) %>'

View File

@ -208,43 +208,32 @@ ThreadUpdater =
ThreadUpdater.set 'timer', '...'
else
ThreadUpdater.set 'timer', 'Update'
ThreadUpdater.req.abort() if ThreadUpdater.req
ThreadUpdater.req?.abort()
url = "//a.4cdn.org/#{ThreadUpdater.thread.board}/res/#{ThreadUpdater.thread}.json"
ThreadUpdater.req = $.ajax url, onloadend: ThreadUpdater.cb.load,
whenModified: true
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.'
updateThreadStatus: (type, status) ->
return unless hasChanged = ThreadUpdater.thread["is#{type}"] isnt status
ThreadUpdater.thread.setStatus type, status
change = if type is 'Sticky'
if status
'now a sticky'
else
'The thread is not closed anymore.'
new Notice 'info', message, 30
$.rm $ ".#{titleLC}Icon", ThreadUpdater.thread.OP.nodes.info
return
message = if title is 'Sticky'
'The thread is now a sticky.'
'not a sticky anymore'
else
'The thread is now closed.'
new Notice '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]
if status
'now closed'
else
'not closed anymore'
new Notice 'info', "The thread is #{change}.", 30
parse: (postObjects) ->
OP = postObjects[0]
Build.spoilerRange[ThreadUpdater.thread.board] = OP.custom_spoiler
ThreadUpdater.updateThreadStatus 'Sticky', OP
ThreadUpdater.updateThreadStatus 'Closed', OP
ThreadUpdater.updateThreadStatus 'Sticky', !!OP.sticky
ThreadUpdater.updateThreadStatus 'Closed', !!OP.closed
ThreadUpdater.thread.postLimit = !!OP.bumplimit
ThreadUpdater.thread.fileLimit = !!OP.imagelimit
@ -265,18 +254,20 @@ ThreadUpdater =
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 index.contains ID
post.resurrect()
else unless index.contains ID
unless ID in index
post.kill()
deletedPosts.push post
else if post.file and !post.file.isDead and not files.contains ID
else if post.isDead
post.resurrect()
else if post.file and not (post.file.isDead or ID in files)
post.kill true
deletedFiles.push post
@ -304,7 +295,7 @@ ThreadUpdater =
continue unless posts.hasOwnProperty key
root = post.nodes.root
if post.cb
unless post.cb.call post
unless post.cb()
$.add ThreadUpdater.root, root
else
$.add ThreadUpdater.root, root
@ -313,7 +304,7 @@ ThreadUpdater =
if Conf['Bottom Scroll']
window.scrollTo 0, d.body.clientHeight
else
Header.scrollToPost root if root
Header.scrollTo root if root
$.queueTask ->
# Enable 4chan features.

View File

@ -6,12 +6,10 @@ ThreadWatcher =
id: 'watcher-link'
textContent: 'Watcher'
href: 'javascript:;'
className: 'disabled fourchanx-icon icon-eye-open'
className: 'disabled fa fa-eye-open'
@db = new DataBoard 'watchedThreads', @refresh, true
@dialog = UI.dialog 'thread-watcher', 'top: 50px; left: 0px;', """
<%= grunt.file.read('src/General/html/Monitoring/ThreadWatcher.html').replace(/>\s+</g, '><').trim() %>
"""
@dialog = UI.dialog 'thread-watcher', 'top: 50px; left: 0px;', <%= importHTML('Monitoring/ThreadWatcher') %>
@status = $ '#watcher-status', @dialog
@list = @dialog.lastElementChild
@ -19,7 +17,13 @@ ThreadWatcher =
$.on d, 'ThreadUpdate', @cb.threadUpdate if g.VIEW is 'thread'
$.on sc, 'click', @toggleWatcher
$.on $('.move>.close', ThreadWatcher.dialog), 'click', @toggleWatcher
$.on d, '4chanXInitFinished', @ready
switch g.VIEW
when 'index'
$.on d, 'IndexRefresh', @cb.onIndexRefresh
when 'thread'
$.on d, 'ThreadUpdate', @cb.onThreadRefresh
if Conf['Toggleable Thread Watcher']
Header.addShortcut sc
@ -89,7 +93,17 @@ ThreadWatcher =
$.set 'AutoWatch', threadID
else if Conf['Auto Watch Reply']
ThreadWatcher.add board.threads[threadID]
threadUpdate: (e) ->
onIndexRefresh: ->
{db} = ThreadWatcher
boardID = g.BOARD.ID
for threadID, data of db.data.boards[boardID] when not data.isDead and threadID not of g.BOARD.threads
if Conf['Auto Prune']
ThreadWatcher.db.delete {boardID, threadID}
else
data.isDead = true
ThreadWatcher.db.set {boardID, threadID, val: data}
ThreadWatcher.refresh()
onThreadRefresh: (e) ->
{thread} = e.detail
return unless e.detail[404] and ThreadWatcher.db.get {boardID: thread.board.ID, threadID: thread.ID}
# Update 404 status.
@ -120,7 +134,7 @@ ThreadWatcher =
ThreadWatcher.status.textContent = status
return if @status isnt 404
if Conf['Auto Prune']
ThreadWatcher.rm boardID, threadID
ThreadWatcher.db.delete {boardID, threadID}
else
data.isDead = true
ThreadWatcher.db.set {boardID, threadID, val: data}
@ -139,7 +153,7 @@ ThreadWatcher =
makeLine: (boardID, threadID, data) ->
x = $.el 'a',
textContent: '×'
className: 'fa fa-times'
href: 'javascript:;'
$.on x, 'click', ThreadWatcher.cb.rm
@ -281,6 +295,7 @@ ThreadWatcher =
$.on entry.el, 'click', cb if cb
@refreshers.push refresh.bind entry if refresh
$.event 'AddMenuEntry', entry
return
createSubEntry: (name, desc) ->
entry =
type: 'thread watcher'

View File

@ -5,7 +5,7 @@ Unread =
@db = new DataBoard 'lastReadPosts', @sync
@hr = $.el 'hr',
id: 'unread-line'
@posts = []
@posts = new RandomAccessList
@postsQuotingYou = []
Thread.callbacks.push
@ -27,32 +27,27 @@ Unread =
ready: ->
$.off d, '4chanXInitFinished', Unread.ready
posts = []
for ID, post of Unread.thread.posts
posts.push post if post.isReply
posts.push post for ID, post of Unread.thread.posts when post.isReply
Unread.addPosts posts
Unread.scroll()
QuoteThreading.force() if Conf['Quote Threading']
Unread.scroll() if Conf['Scroll to Last Read Post']
scroll: ->
return unless Conf['Scroll to Last Read Post']
# Let the header's onload callback handle it.
return if (hash = location.hash.match /\d+/) and hash[0] of Unread.thread.posts
if post = Unread.posts[0]
if post = Unread.posts.first
# Scroll to a non-hidden, non-OP post that's before the first unread post.
while root = $.x 'preceding-sibling::div[contains(@class,"replyContainer")][1]', post.nodes.root
break unless (post = Get.postFromRoot root).isHidden
return unless root
onload = -> root.scrollIntoView false if checkPosition root
down = true
else
# Scroll to the last read post.
posts = Object.keys Unread.thread.posts
{root} = Unread.thread.posts[posts[posts.length - 1]].nodes
onload = -> Header.scrollToPost root if checkPosition root
checkPosition = (target) ->
# Scroll to the target unless we scrolled past it.
target.getBoundingClientRect().bottom > doc.clientHeight
# Prevent the browser to scroll back to
# the previous scroll location on page load.
$.on window, 'load', onload
# Scroll to the target unless we scrolled past it.
Header.scrollTo root, down if Header.getBottomOf(root) < 0
sync: ->
lastReadPost = Unread.db.get
@ -61,7 +56,13 @@ Unread =
defaultValue: 0
return unless Unread.lastReadPost < lastReadPost
Unread.lastReadPost = lastReadPost
Unread.readArray Unread.posts
post = Unread.posts.first
while post
break if ({ID} = post) > Unread.lastReadPost
post = post.next
Unread.posts.rm ID
Unread.readArray Unread.postsQuotingYou
Unread.setLine() if Conf['Unread Line']
Unread.update()
@ -69,19 +70,16 @@ Unread =
addPosts: (posts) ->
for post in posts
{ID} = post
if ID <= Unread.lastReadPost or post.isHidden
continue
if QR.db
data =
boardID: post.board.ID
threadID: post.thread.ID
postID: post.ID
continue if QR.db.get data
Unread.posts.push post
continue if ID <= Unread.lastReadPost or post.isHidden or QR.db.get {
boardID: post.board.ID
threadID: post.thread.ID
postID: ID
}
Unread.posts.push post unless post.prev or post.next
Unread.addPostQuotingYou post
if Conf['Unread Line']
# Force line on visible threads if there were no unread posts previously.
Unread.setLine posts.contains Unread.posts[0]
Unread.setLine Unread.posts.first in posts
Unread.read()
Unread.update()
@ -102,7 +100,7 @@ Unread =
body: post.info.comment
icon: Favicon.logo
notif.onclick = ->
Header.scrollToPost post.nodes.root
Header.scrollToIfNeeded post.nodes.root, true
window.focus()
notif.onshow = ->
setTimeout ->
@ -116,11 +114,12 @@ Unread =
Unread.addPosts e.detail.newPosts
readSinglePost: (post) ->
return if (i = Unread.posts.indexOf post) is -1
Unread.posts.splice i, 1
if i is 0
Unread.lastReadPost = post.ID
{ID} = post
return unless Unread.posts[ID]
if post is Unread.posts.first
Unread.lastReadPost = ID
Unread.saveLastReadPost()
Unread.posts.rm ID
if (i = Unread.postsQuotingYou.indexOf post) isnt -1
Unread.postsQuotingYou.splice i, 1
Unread.update()
@ -130,28 +129,18 @@ Unread =
break if post.ID > Unread.lastReadPost
arr.splice 0, i
read: $.debounce 50, (e) ->
read: $.debounce 100, (e) ->
return if d.hidden or !Unread.posts.length
height = doc.clientHeight
{posts} = Unread
i = 0
while post = posts[i]
if post.nodes.root.getBoundingClientRect().bottom < height # post is not completely read
{ID} = post
if Conf['Mark Quotes of You']
if post.info.yours
QuoteYou.lastRead = post.nodes.root
if Conf['Quote Threading']
posts.splice i, 1
continue
else
unless Conf['Quote Threading']
break
i++
if i and !Conf['Quote Threading']
posts.splice 0, i
{posts} = Unread
while post = posts.first
break unless Header.getBottomOf(post.nodes.root) > -1 # post is not completely read
{ID} = post
posts.rm ID
if Conf['Mark Quotes of You'] and post.info.yours
QuoteYou.lastRead = post.nodes.root
return unless ID
@ -163,13 +152,13 @@ Unread =
saveLastReadPost: $.debounce 2 * $.SECOND, ->
return if Unread.thread.isDead
Unread.db.set
boardID: Unread.thread.board.ID
boardID: Unread.thread.board.ID
threadID: Unread.thread.ID
val: Unread.lastReadPost
setLine: (force) ->
return unless d.hidden or force is true
return $.rm Unread.hr unless post = Unread.posts[0]
return $.rm Unread.hr unless post = Unread.posts.first
if $.x 'preceding-sibling::div[contains(@class,"replyContainer")]', post.nodes.root # not the first reply
$.before post.nodes.root, Unread.hr
@ -177,7 +166,7 @@ Unread =
count = Unread.posts.length
if Conf['Unread Count']
d.title = "#{if Conf['Quoted Title'] and Unread.postsQuotingYou.length then '(!) ' else ''}#{if count or !Conf['Hide Unread Count at (0)'] then "(#{count}) " else ''}#{if g.DEAD then "/#{g.BOARD}/ - 404" else "#{Unread.title}"}"
d.title = "#{if Conf['Quoted Title'] and Unread.postsQuotingYou.length then '(!) ' else ''}#{if count or !Conf['Hide Unread Count at (0)'] then "(#{count}) " else ''}#{if g.DEAD then "/#{g.BOARD}/ - 404" else "#{Unread.title}"}"
<% if (type === 'crx') { %>
# XXX Chrome bug where it doesn't always update the tab title.
# crbug.com/124381

View File

@ -0,0 +1,124 @@
QR.captcha =
init: ->
return if d.cookie.indexOf('pass_enabled=1') >= 0
return unless @isEnabled = !!$.id 'captchaFormPart'
$.asap (-> $.id 'recaptcha_challenge_field_holder'), @ready.bind @
ready: ->
setLifetime = (e) => @lifetime = e.detail
$.on window, 'captcha:timeout', setLifetime
$.globalEval 'window.dispatchEvent(new CustomEvent("captcha:timeout", {detail: RecaptchaState.timeout}))'
$.off window, 'captcha:timeout', setLifetime
imgContainer = $.el 'div',
className: 'captcha-img'
title: 'Reload reCAPTCHA'
innerHTML: '<img>'
input = $.el 'input',
className: 'captcha-input field'
title: 'Verification'
autocomplete: 'off'
spellcheck: false
tabIndex: 55
@nodes =
challenge: $.id 'recaptcha_challenge_field_holder'
img: imgContainer.firstChild
input: input
new MutationObserver(@load.bind @).observe @nodes.challenge,
childList: true
$.on imgContainer, 'click', @reload.bind @
$.on input, 'keydown', @keydown.bind @
$.on input, 'focus', -> $.addClass QR.nodes.el, 'focus'
$.on input, 'blur', -> $.rmClass QR.nodes.el, 'focus'
$.get 'captchas', [], ({captchas}) =>
@sync captchas
$.sync 'captchas', @sync
# start with an uncached captcha
@reload()
<% if (type === 'userscript') { %>
# XXX Firefox lacks focusin/focusout support.
$.on input, 'blur', QR.focusout
$.on input, 'focus', QR.focusin
<% } %>
$.addClass QR.nodes.el, 'has-captcha'
$.after QR.nodes.com.parentNode, [imgContainer, input]
sync: (captchas) ->
QR.captcha.captchas = captchas
QR.captcha.count()
getOne: ->
@clear()
if captcha = @captchas.shift()
{challenge, response} = captcha
@count()
$.set 'captchas', @captchas
else
challenge = @nodes.img.alt
if response = @nodes.input.value then @reload()
if response
response = response.trim()
# one-word-captcha:
# If there's only one word, duplicate it.
response = "#{response} #{response}" unless /\s/.test response
{challenge, response}
save: ->
return unless response = @nodes.input.value.trim()
@captchas.push
challenge: @nodes.img.alt
response: response
timeout: @timeout
@count()
@reload()
$.set 'captchas', @captchas
clear: ->
now = Date.now()
for captcha, i in @captchas
break if captcha.timeout > now
return unless i
@captchas = @captchas[i..]
@count()
$.set 'captchas', @captchas
load: ->
return unless @nodes.challenge.firstChild
# -1 minute to give upload some time.
@timeout = Date.now() + @lifetime * $.SECOND - $.MINUTE
challenge = @nodes.challenge.firstChild.value
@nodes.img.alt = challenge
@nodes.img.src = "//www.google.com/recaptcha/api/image?c=#{challenge}"
@nodes.input.value = null
@clear()
count: ->
count = @captchas.length
@nodes.input.placeholder = switch count
when 0
'Verification (Shift + Enter to cache)'
when 1
'Verification (1 cached captcha)'
else
"Verification (#{count} cached captchas)"
@nodes.input.alt = count # For XTRM RICE.
reload: (focus) ->
# the 't' argument prevents the input from being focused
$.globalEval 'Recaptcha.reload("t")'
# Focus if we meant to.
@nodes.input.focus() if focus
keydown: (e) ->
if e.keyCode is 8 and not @nodes.input.value
@reload()
else if e.keyCode is 13 and e.shiftKey
@save()
else
return
e.preventDefault()

628
src/Posting/QuickReply.coffee → src/Posting/QR.coffee Executable file → Normal file
View File

@ -3,10 +3,11 @@ QR =
return if !Conf['Quick Reply']
@db = new DataBoard 'yourPosts'
@posts = []
if Conf['QR Shortcut']
sc = $.el 'a',
className: "qr-shortcut fourchanx-icon icon-comment #{unless Conf['Persistent QR'] then 'disabled' else ''}"
className: "qr-shortcut fa fa-comment-o #{unless Conf['Persistent QR'] then 'disabled' else ''}"
textContent: 'QR'
title: 'Quick Reply'
href: 'javascript:;'
@ -65,11 +66,15 @@ QR =
$.on d, 'dragover', QR.dragOver
$.on d, 'drop', QR.dropFile
$.on d, 'dragstart dragend', QR.drag
$.on d, 'ThreadUpdate', ->
if g.DEAD
QR.abort()
else
QR.status()
switch g.VIEW
when 'index'
$.on d, 'IndexRefresh', QR.generatePostableThreadsList
when 'thread'
$.on d, 'ThreadUpdate', ->
if g.DEAD
QR.abort()
else
QR.status()
node: ->
$.on $('a[title="Quote this post"]', @nodes.info), 'click', QR.quote
@ -199,192 +204,6 @@ QR =
value
status.disabled = disabled or false
persona:
pwd: ''
always: {}
init: ->
QR.persona.getPassword()
$.get 'QR.personas', Conf['QR.personas'], ({'QR.personas': personas}) ->
types =
name: []
email: []
sub: []
for item in personas.split '\n'
QR.persona.parseItem item.trim(), types
for type, arr of types
QR.persona.loadPersonas type, arr
return
parseItem: (item, types) ->
return if item[0] is '#'
return unless match = item.match /(name|email|subject|password):"(.*)"/i
[match, type, val] = match
# Don't mix up item settings with val.
item = item.replace match, ''
boards = item.match(/boards:([^;]+)/i)?[1].toLowerCase() or 'global'
if boards isnt 'global' and not ((boards.split ',').contains g.BOARD.ID)
return
if type is 'password'
QR.persona.pwd = val
return
type = 'sub' if type is 'subject'
if /always/i.test item
QR.persona.always[type] = val
unless types[type].contains val
types[type].push val
loadPersonas: (type, arr) ->
list = $ "#list-#{type}", QR.nodes.el
for val in arr when val
$.add list, $.el 'option',
textContent: val
return
getPassword: ->
unless QR.persona.pwd
QR.persona.pwd = if m = d.cookie.match /4chan_pass=([^;]+)/
decodeURIComponent m[1]
else if input = $.id 'postPassword'
input.value
else
# If we're in a closed thread, #postPassword isn't available.
# And since #delPassword.value is only filled on window.onload
# we'd rather use #postPassword when we can.
$.id('delPassword').value
return QR.persona.pwd
get: (cb) ->
$.get 'QR.persona', {}, ({'QR.persona': persona}) ->
cb persona
set: (post) ->
$.get 'QR.persona', {}, ({'QR.persona': persona}) ->
persona =
name: post.name
email: if /^sage$/.test post.email then persona.email else post.email
sub: if Conf['Remember Subject'] then post.sub else undefined
flag: post.flag
$.set 'QR.persona', persona
cooldown:
init: ->
return unless Conf['Cooldown']
setTimers = (e) => QR.cooldown.types = e.detail
$.on window, 'cooldown:timers', setTimers
$.globalEval 'window.dispatchEvent(new CustomEvent("cooldown:timers", {detail: cooldowns}))'
QR.cooldown.types or= {} # XXX tmp workaround until all pages and the catalogs get the cooldowns var.
$.off window, 'cooldown:timers', setTimers
for type of QR.cooldown.types
QR.cooldown.types[type] = +QR.cooldown.types[type]
QR.cooldown.upSpd = 0
QR.cooldown.upSpdAccuracy = .5
key = "cooldown.#{g.BOARD}"
$.get key, {}, (item) ->
QR.cooldown.cooldowns = item[key]
QR.cooldown.start()
$.sync key, QR.cooldown.sync
start: ->
return unless Conf['Cooldown']
return if QR.cooldown.isCounting
QR.cooldown.isCounting = true
QR.cooldown.count()
sync: (cooldowns) ->
# Add each cooldowns, don't overwrite everything in case we
# still need to prune one in the current tab to auto-post.
for id of cooldowns
QR.cooldown.cooldowns[id] = cooldowns[id]
QR.cooldown.start()
set: (data) ->
return unless Conf['Cooldown']
{req, post, isReply, threadID, delay} = data
start = if req then req.uploadEndTime else Date.now()
if delay
cooldown = {delay}
else
if post.file
upSpd = post.file.size / ((start - req.uploadStartTime) / $.SECOND)
QR.cooldown.upSpdAccuracy = ((upSpd > QR.cooldown.upSpd * .9) + QR.cooldown.upSpdAccuracy) / 2
QR.cooldown.upSpd = upSpd
cooldown = {isReply, threadID}
QR.cooldown.cooldowns[start] = cooldown
$.set "cooldown.#{g.BOARD}", QR.cooldown.cooldowns
QR.cooldown.start()
unset: (id) ->
delete QR.cooldown.cooldowns[id]
if Object.keys(QR.cooldown.cooldowns).length
$.set "cooldown.#{g.BOARD}", QR.cooldown.cooldowns
else
$.delete "cooldown.#{g.BOARD}"
count: ->
unless Object.keys(QR.cooldown.cooldowns).length
$.delete "#{g.BOARD}.cooldown"
delete QR.cooldown.isCounting
delete QR.cooldown.seconds
QR.status()
return
clearTimeout QR.cooldown.timeout
QR.cooldown.timeout = setTimeout QR.cooldown.count, $.SECOND
now = Date.now()
post = QR.posts[0]
isReply = post.thread isnt 'new'
hasFile = !!post.file
seconds = null
{types, cooldowns, upSpd, upSpdAccuracy} = QR.cooldown
for start, cooldown of cooldowns
if 'delay' of cooldown
if cooldown.delay
seconds = Math.max seconds, cooldown.delay--
else
seconds = Math.max seconds, 0
QR.cooldown.unset start
continue
if 'timeout' of cooldown
# XXX tmp conversion from previous cooldowns
QR.cooldown.unset start
continue
if isReply is cooldown.isReply
# Only cooldowns relevant to this post can set the seconds variable:
# reply cooldown with a reply, thread cooldown with a thread
elapsed = Math.floor (now - start) / $.SECOND
continue if elapsed < 0 # clock changed since then?
type = unless isReply
'thread'
else if hasFile
'image'
else
'reply'
maxTimer = Math.max types[type] or 0, types[type + '_intra'] or 0
unless start <= now <= start + maxTimer * $.SECOND
QR.cooldown.unset start
type += '_intra' if isReply and +post.thread is cooldown.threadID
seconds = Math.max seconds, types[type] - elapsed
if seconds and Conf['Cooldown Prediction'] and hasFile and upSpd
seconds -= Math.floor post.file.size / upSpd * upSpdAccuracy
seconds = if seconds > 0 then seconds else 0
# Update the status when we change posting type.
# Don't get stuck at some random number.
# Don't interfere with progress status updates.
update = seconds isnt null or !!QR.cooldown.seconds
QR.cooldown.seconds = seconds
QR.status() if update
QR.submit() if seconds is 0 and QR.cooldown.auto and !QR.req
quote: (e) ->
e?.preventDefault()
return unless QR.postingIsEnabled
@ -471,7 +290,7 @@ QR =
if file.size > max
QR.error "#{file.name}: File too large (file: #{$.bytesToString file.size}, max: #{$.bytesToString max})."
return
else unless QR.mimeTypes.contains file.type
else unless file.type in QR.mimeTypes
unless /^text/.test file.type
QR.error "#{file.name}: Unsupported file type."
return
@ -495,408 +314,32 @@ QR =
$.addClass QR.nodes.filename, 'edit'
QR.nodes.filename.focus()
return $.on QR.nodes.filename, 'blur', -> $.rmClass QR.nodes.filename, 'edit'
return if e.target.nodeName is 'INPUT' or (e.keyCode and not [32, 13].contains e.keyCode) or e.ctrlKey
return if e.target.nodeName is 'INPUT' or (e.keyCode and e.keyCode not in [32, 13]) or e.ctrlKey
e.preventDefault()
QR.nodes.fileInput.click()
posts: []
post: class
constructor: (select) ->
el = $.el 'a',
className: 'qr-preview'
draggable: true
href: 'javascript:;'
innerHTML: '<a class=remove>×</a><label hidden><input type=checkbox> Spoiler</label><span></span>'
@nodes =
el: el
rm: el.firstChild
label: $ 'label', el
spoiler: $ 'input', el
span: el.lastChild
<% if (type === 'userscript') { %>
# XXX Firefox lacks focusin/focusout support.
for elm in $$ '*', el
$.on elm, 'blur', QR.focusout
$.on elm, 'focus', QR.focusin
<% } %>
$.on el, 'click', @select
$.on @nodes.rm, 'click', (e) => e.stopPropagation(); @rm()
$.on @nodes.label, 'click', (e) => e.stopPropagation()
$.on @nodes.spoiler, 'change', (e) =>
@spoiler = e.target.checked
QR.nodes.spoiler.checked = @spoiler if @ is QR.selected
$.add QR.nodes.dumpList, el
for event in ['dragStart', 'dragEnter', 'dragLeave', 'dragOver', 'dragEnd', 'drop']
$.on el, event.toLowerCase(), @[event]
@thread = if g.VIEW is 'thread'
g.THREADID
else
'new'
prev = QR.posts[QR.posts.length - 1]
QR.posts.push @
@nodes.spoiler.checked = @spoiler = if prev and Conf['Remember Spoiler']
prev.spoiler
else
false
QR.persona.get (persona) =>
@name = if 'name' of QR.persona.always
QR.persona.always.name
else if prev
prev.name
else
persona.name
@email = if 'email' of QR.persona.always
QR.persona.always.email
else if prev and !/^sage$/.test prev.email
prev.email
else
persona.email
@sub = if 'sub' of QR.persona.always
QR.persona.always.sub
else if Conf['Remember Subject']
if prev then prev.sub else persona.sub
else
''
if QR.nodes.flag
@flag = if prev
prev.flag
else
persona.flag
@load() if QR.selected is @ # load persona
@select() if select
@unlock()
rm: ->
@delete()
index = QR.posts.indexOf @
if QR.posts.length is 1
new QR.post true
$.rmClass QR.nodes.el, 'dump'
else if @ is QR.selected
(QR.posts[index-1] or QR.posts[index+1]).select()
QR.posts.splice index, 1
QR.status()
delete: ->
$.rm @nodes.el
URL.revokeObjectURL @URL
lock: (lock=true) ->
@isLocked = lock
return unless @ is QR.selected
for name in ['thread', 'name', 'email', 'sub', 'com', 'fileButton', 'filename', 'spoiler', 'flag'] when node = QR.nodes[name]
node.disabled = lock
@nodes.rm.style.visibility = if lock then 'hidden' else ''
(if lock then $.off else $.on) QR.nodes.filename.previousElementSibling, 'click', QR.openFileInput
@nodes.spoiler.disabled = lock
@nodes.el.draggable = !lock
unlock: ->
@lock false
select: =>
if QR.selected
QR.selected.nodes.el.id = null
QR.selected.forceSave()
QR.selected = @
@lock @isLocked
@nodes.el.id = 'selected'
# Scroll the list to center the focused post.
rectEl = @nodes.el.getBoundingClientRect()
rectList = @nodes.el.parentNode.getBoundingClientRect()
@nodes.el.parentNode.scrollLeft += rectEl.left + rectEl.width/2 - rectList.left - rectList.width/2
@load()
$.event 'QRPostSelection', @
load: ->
# Load this post's values.
for name in ['thread', 'name', 'email', 'sub', 'com', 'filename', 'flag']
continue unless node = QR.nodes[name]
node.value = @[name] or node.dataset.default or null
@showFileData()
QR.characterCount()
save: (input) ->
if input.type is 'checkbox'
@spoiler = input.checked
return
{name} = input.dataset
@[name] = input.value or input.dataset.default or null
switch name
when 'thread'
QR.status()
when 'com'
@nodes.span.textContent = @com
QR.characterCount()
# Disable auto-posting if you're typing in the first post
# during the last 5 seconds of the cooldown.
if QR.cooldown.auto and @ is QR.posts[0] and 0 < QR.cooldown.seconds <= 5
QR.cooldown.auto = false
when 'filename'
return unless @file
@file.newName = @filename.replace /[/\\]/g, '-'
unless /\.(jpe?g|png|gif|pdf|swf)$/i.test @filename
# 4chan will truncate the filename if it has no extension,
# but it will always replace the extension by the correct one,
# so we suffix it with '.jpg' when needed.
@file.newName += '.jpg'
@updateFilename()
forceSave: ->
return unless @ is QR.selected
# Do this in case people use extensions
# that do not trigger the `input` event.
for name in ['thread', 'name', 'email', 'sub', 'com', 'filename', 'spoiler', 'flag']
continue unless node = QR.nodes[name]
@save node
return
setFile: (@file) ->
@filename = file.name
@filesize = $.bytesToString file.size
@nodes.label.hidden = false if QR.spoiler
URL.revokeObjectURL @URL
@showFileData() if @ is QR.selected
unless /^image/.test file.type
@nodes.el.style.backgroundImage = null
return
@setThumbnail()
setThumbnail: ->
# Create a redimensioned thumbnail.
img = $.el 'img'
img.onload = =>
# Generate thumbnails only if they're really big.
# Resized pictures through canvases look like ass,
# so we generate thumbnails `s` times bigger then expected
# to avoid crappy resized quality.
s = 90*2
s *= 3 if @file.type is 'image/gif' # let them animate
{height, width} = img
if height < s or width < s
@URL = fileURL
@nodes.el.style.backgroundImage = "url(#{@URL})"
return
if height <= width
width = s / height * width
height = s
else
height = s / width * height
width = s
cv = $.el 'canvas'
cv.height = img.height = height
cv.width = img.width = width
cv.getContext('2d').drawImage img, 0, 0, width, height
URL.revokeObjectURL fileURL
cv.toBlob (blob) =>
@URL = URL.createObjectURL blob
@nodes.el.style.backgroundImage = "url(#{@URL})"
fileURL = URL.createObjectURL @file
img.src = fileURL
rmFile: ->
return if @isLocked
delete @file
delete @filename
delete @filesize
@nodes.el.title = null
QR.nodes.fileContainer.title = ''
@nodes.el.style.backgroundImage = null
@nodes.label.hidden = true if QR.spoiler
@showFileData()
URL.revokeObjectURL @URL
updateFilename: ->
long = "#{@filename} (#{@filesize})\nCtrl+click to edit filename. Shift+click to clear."
@nodes.el.title = long
return unless @ is QR.selected
QR.nodes.fileContainer.title = long
showFileData: ->
if @file
@updateFilename()
QR.nodes.filename.value = @filename
QR.nodes.spoiler.checked = @spoiler
$.addClass QR.nodes.fileSubmit, 'has-file'
else
$.rmClass QR.nodes.fileSubmit, 'has-file'
pasteText: (file) ->
reader = new FileReader()
reader.onload = (e) =>
text = e.target.result
if @com
@com += "\n#{text}"
else
@com = text
if QR.selected is @
QR.nodes.com.value = @com
@nodes.span.textContent = @com
reader.readAsText file
dragStart: (e) ->
e.dataTransfer.setDragImage @, e.layerX, e.layerY
$.addClass @, 'drag'
dragEnd: -> $.rmClass @, 'drag'
dragEnter: -> $.addClass @, 'over'
dragLeave: -> $.rmClass @, 'over'
dragOver: (e) ->
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
drop: ->
$.rmClass @, 'over'
return unless @draggable
el = $ '.drag', @parentNode
index = (el) -> [el.parentNode.children...].indexOf el
oldIndex = index el
newIndex = index @
(if oldIndex < newIndex then $.after else $.before) @, el
post = QR.posts.splice(oldIndex, 1)[0]
QR.posts.splice newIndex, 0, post
QR.status()
captcha:
init: ->
return if d.cookie.indexOf('pass_enabled=1') >= 0
return unless @isEnabled = !!$.id 'captchaFormPart'
$.asap (-> $.id 'recaptcha_challenge_field_holder'), @ready.bind @
ready: ->
setLifetime = (e) => @lifetime = e.detail
$.on window, 'captcha:timeout', setLifetime
$.globalEval 'window.dispatchEvent(new CustomEvent("captcha:timeout", {detail: RecaptchaState.timeout}))'
$.off window, 'captcha:timeout', setLifetime
imgContainer = $.el 'div',
className: 'captcha-img'
title: 'Reload'
innerHTML: '<img>'
input = $.el 'input',
className: 'captcha-input field'
title: 'Verification'
autocomplete: 'off'
spellcheck: false
tabIndex: 55
@nodes =
challenge: $.id 'recaptcha_challenge_field_holder'
img: imgContainer.firstChild
input: input
new MutationObserver(@load.bind @).observe @nodes.challenge,
childList: true
$.on imgContainer, 'click', @reload.bind @
$.on input, 'keydown', @keydown.bind @
$.on input, 'focus', -> $.addClass QR.nodes.el, 'focus'
$.on input, 'blur', -> $.rmClass QR.nodes.el, 'focus'
$.get 'captchas', [], ({captchas}) =>
@sync captchas
$.sync 'captchas', @sync
# start with an uncached captcha
@reload()
<% if (type === 'userscript') { %>
# XXX Firefox lacks focusin/focusout support.
$.on input, 'blur', QR.focusout
$.on input, 'focus', QR.focusin
<% } %>
$.addClass QR.nodes.el, 'has-captcha'
$.after QR.nodes.com.parentNode, [imgContainer, input]
sync: (captchas) ->
QR.captcha.captchas = captchas
QR.captcha.count()
getOne: ->
@clear()
if captcha = @captchas.shift()
{challenge, response} = captcha
@count()
$.set 'captchas', @captchas
else
challenge = @nodes.img.alt
if response = @nodes.input.value then @reload()
if response
response = response.trim()
# one-word-captcha:
# If there's only one word, duplicate it.
response = "#{response} #{response}" unless /\s/.test response
{challenge, response}
save: ->
return unless response = @nodes.input.value.trim()
@captchas.push
challenge: @nodes.img.alt
response: response
timeout: @timeout
@count()
@reload()
$.set 'captchas', @captchas
clear: ->
now = Date.now()
for captcha, i in @captchas
break if captcha.timeout > now
return unless i
@captchas = @captchas[i..]
@count()
$.set 'captchas', @captchas
load: ->
return unless @nodes.challenge.firstChild
# -1 minute to give upload some time.
@timeout = Date.now() + @lifetime * $.SECOND - $.MINUTE
challenge = @nodes.challenge.firstChild.value
@nodes.img.alt = challenge
@nodes.img.src = "//www.google.com/recaptcha/api/image?c=#{challenge}"
@nodes.input.value = null
@clear()
count: ->
count = @captchas.length
@nodes.input.placeholder = switch count
when 0
'Verification (Shift + Enter to cache)'
when 1
'Verification (1 cached captcha)'
else
"Verification (#{count} cached captchas)"
@nodes.input.alt = count
reload: (focus) ->
# the 't' argument prevents the input from being focused
$.globalEval 'Recaptcha.reload("t")'
# Focus if we meant to.
@nodes.input.focus() if focus
keydown: (e) ->
if e.keyCode is 8 and not @nodes.input.value
@reload()
else if e.keyCode is 13 and e.shiftKey
@save()
else
return
e.preventDefault()
generatePostableThreadsList: ->
return unless QR.nodes
list = QR.nodes.thread
options = [list.firstChild]
for thread of g.BOARD.threads
options.push $.el 'option',
value: thread
textContent: "Thread No.#{thread}"
val = list.value
$.rmAll list
$.add list, options
list.value = val
return unless list.value
# Fix the value if the option disappeared.
list.value = if g.VIEW is 'thread'
g.THREADID
else
'new'
dialog: ->
QR.nodes = nodes =
el: dialog = UI.dialog 'qr', 'top:0;right:0;', """
<%= grunt.file.read('src/General/html/Features/QuickReply.html').replace(/>\s+</g, '><').trim() %>
"""
el: dialog = UI.dialog 'qr', 'top:0;right:0;', <%= importHTML('Features/QuickReply') %>
nodes[key] = $ value, dialog for key, value of {
move: '.move'
@ -964,12 +407,6 @@ QR =
nodes.flag.dataset.default = '0'
$.add nodes.form, nodes.flag
# Make a list of threads.
for thread of g.BOARD.threads
$.add nodes.thread, $.el 'option',
value: thread
textContent: "Thread No.#{thread}"
$.on nodes.filename.parentNode, 'click keydown', QR.openFileInput
<% if (type === 'userscript') { %>
@ -1011,6 +448,7 @@ QR =
$.set 'QR Size', @style.cssText
<% } %>
QR.generatePostableThreadsList()
QR.persona.init()
new QR.post true
QR.status()

View File

@ -0,0 +1,106 @@
QR.cooldown =
init: ->
return unless Conf['Cooldown']
setTimers = (e) => QR.cooldown.types = e.detail
$.on window, 'cooldown:timers', setTimers
$.globalEval 'window.dispatchEvent(new CustomEvent("cooldown:timers", {detail: cooldowns}))'
$.off window, 'cooldown:timers', setTimers
for type of QR.cooldown.types
QR.cooldown.types[type] = +QR.cooldown.types[type]
QR.cooldown.upSpd = 0
QR.cooldown.upSpdAccuracy = .5
key = "cooldown.#{g.BOARD}"
$.get key, {}, (item) ->
QR.cooldown.cooldowns = item[key]
QR.cooldown.start()
$.sync key, QR.cooldown.sync
start: ->
return unless Conf['Cooldown']
return if QR.cooldown.isCounting
QR.cooldown.isCounting = true
QR.cooldown.count()
sync: (cooldowns) ->
# Add each cooldowns, don't overwrite everything in case we
# still need to prune one in the current tab to auto-post.
for id of cooldowns
QR.cooldown.cooldowns[id] = cooldowns[id]
QR.cooldown.start()
set: (data) ->
return unless Conf['Cooldown']
{req, post, isReply, threadID, delay} = data
start = if req then req.uploadEndTime else Date.now()
if delay
cooldown = {delay}
else
if post.file
upSpd = post.file.size / ((start - req.uploadStartTime) / $.SECOND)
QR.cooldown.upSpdAccuracy = ((upSpd > QR.cooldown.upSpd * .9) + QR.cooldown.upSpdAccuracy) / 2
QR.cooldown.upSpd = upSpd
cooldown = {isReply, threadID}
QR.cooldown.cooldowns[start] = cooldown
$.set "cooldown.#{g.BOARD}", QR.cooldown.cooldowns
QR.cooldown.start()
unset: (id) ->
delete QR.cooldown.cooldowns[id]
if Object.keys(QR.cooldown.cooldowns).length
$.set "cooldown.#{g.BOARD}", QR.cooldown.cooldowns
else
$.delete "cooldown.#{g.BOARD}"
count: ->
unless Object.keys(QR.cooldown.cooldowns).length
$.delete "#{g.BOARD}.cooldown"
delete QR.cooldown.isCounting
delete QR.cooldown.seconds
QR.status()
return
clearTimeout QR.cooldown.timeout
QR.cooldown.timeout = setTimeout QR.cooldown.count, $.SECOND
now = Date.now()
post = QR.posts[0]
isReply = post.thread isnt 'new'
hasFile = !!post.file
seconds = null
{types, cooldowns, upSpd, upSpdAccuracy} = QR.cooldown
for start, cooldown of cooldowns
if 'delay' of cooldown
if cooldown.delay
seconds = Math.max seconds, cooldown.delay--
else
seconds = Math.max seconds, 0
QR.cooldown.unset start
continue
if isReply is cooldown.isReply
# Only cooldowns relevant to this post can set the seconds variable:
# reply cooldown with a reply, thread cooldown with a thread
elapsed = Math.floor (now - start) / $.SECOND
continue if elapsed < 0 # clock changed since then?
type = unless isReply
'thread'
else if hasFile
'image'
else
'reply'
maxTimer = Math.max types[type] or 0, types[type + '_intra'] or 0
unless start <= now <= start + maxTimer * $.SECOND
QR.cooldown.unset start
type += '_intra' if isReply and +post.thread is cooldown.threadID
seconds = Math.max seconds, types[type] - elapsed
if seconds and Conf['Cooldown Prediction'] and hasFile and upSpd
seconds -= Math.floor post.file.size / upSpd * upSpdAccuracy
seconds = if seconds > 0 then seconds else 0
# Update the status when we change posting type.
# Don't get stuck at some random number.
# Don't interfere with progress status updates.
update = seconds isnt null or !!QR.cooldown.seconds
QR.cooldown.seconds = seconds
QR.status() if update
QR.submit() if seconds is 0 and QR.cooldown.auto and !QR.req

View File

@ -0,0 +1,72 @@
QR.persona =
pwd: ''
always: {}
init: ->
QR.persona.getPassword()
$.get 'QR.personas', Conf['QR.personas'], ({'QR.personas': personas}) ->
types =
name: []
email: []
sub: []
for item in personas.split '\n'
QR.persona.parseItem item.trim(), types
for type, arr of types
QR.persona.loadPersonas type, arr
return
parseItem: (item, types) ->
return if item[0] is '#'
return unless match = item.match /(name|email|subject|password):"(.*)"/i
[match, type, val] = match
# Don't mix up item settings with val.
item = item.replace match, ''
boards = item.match(/boards:([^;]+)/i)?[1].toLowerCase() or 'global'
return if boards isnt 'global' and g.BOARD.ID not in boards.split ','
if type is 'password'
QR.persona.pwd = val
return
type = 'sub' if type is 'subject'
if /always/i.test item
QR.persona.always[type] = val
unless val in types[type]
types[type].push val
loadPersonas: (type, arr) ->
list = $ "#list-#{type}", QR.nodes.el
for val in arr when val
$.add list, $.el 'option',
textContent: val
return
getPassword: ->
unless QR.persona.pwd
QR.persona.pwd = if m = d.cookie.match /4chan_pass=([^;]+)/
decodeURIComponent m[1]
else if input = $.id 'postPassword'
input.value
else
# If we're in a closed thread, #postPassword isn't available.
# And since #delPassword.value is only filled on window.onload
# we'd rather use #postPassword when we can.
$.id('delPassword').value
return QR.persona.pwd
get: (cb) ->
$.get 'QR.persona', {}, ({'QR.persona': persona}) ->
cb persona
set: (post) ->
$.get 'QR.persona', {}, ({'QR.persona': persona}) ->
persona =
name: post.name
email: if /^sage$/.test post.email then persona.email else post.email
sub: if Conf['Remember Subject'] then post.sub else undefined
flag: post.flag
$.set 'QR.persona', persona

265
src/Posting/QR.post.coffee Normal file
View File

@ -0,0 +1,265 @@
QR.post = class
constructor: (select) ->
el = $.el 'a',
className: 'qr-preview'
draggable: true
href: 'javascript:;'
innerHTML: '<a class="remove fa fa-times-circle" title=Remove></a><label hidden><input type=checkbox> Spoiler</label><span></span>'
@nodes =
el: el
rm: el.firstChild
label: $ 'label', el
spoiler: $ 'input', el
span: el.lastChild
<% if (type === 'userscript') { %>
# XXX Firefox lacks focusin/focusout support.
for elm in $$ '*', el
$.on elm, 'blur', QR.focusout
$.on elm, 'focus', QR.focusin
<% } %>
$.on el, 'click', @select
$.on @nodes.rm, 'click', (e) => e.stopPropagation(); @rm()
$.on @nodes.label, 'click', (e) => e.stopPropagation()
$.on @nodes.spoiler, 'change', (e) =>
@spoiler = e.target.checked
QR.nodes.spoiler.checked = @spoiler if @ is QR.selected
$.add QR.nodes.dumpList, el
for event in ['dragStart', 'dragEnter', 'dragLeave', 'dragOver', 'dragEnd', 'drop']
$.on el, event.toLowerCase(), @[event]
@thread = if g.VIEW is 'thread'
g.THREADID
else
'new'
prev = QR.posts[QR.posts.length - 1]
QR.posts.push @
@nodes.spoiler.checked = @spoiler = if prev and Conf['Remember Spoiler']
prev.spoiler
else
false
QR.persona.get (persona) =>
@name = if 'name' of QR.persona.always
QR.persona.always.name
else if prev
prev.name
else
persona.name
@email = if 'email' of QR.persona.always
QR.persona.always.email
else if prev and !/^sage$/.test prev.email
prev.email
else
persona.email
@sub = if 'sub' of QR.persona.always
QR.persona.always.sub
else if Conf['Remember Subject']
if prev then prev.sub else persona.sub
else
''
if QR.nodes.flag
@flag = if prev
prev.flag
else
persona.flag
@load() if QR.selected is @ # load persona
@select() if select
@unlock()
rm: ->
@delete()
index = QR.posts.indexOf @
if QR.posts.length is 1
new QR.post true
$.rmClass QR.nodes.el, 'dump'
else if @ is QR.selected
(QR.posts[index-1] or QR.posts[index+1]).select()
QR.posts.splice index, 1
QR.status()
delete: ->
$.rm @nodes.el
URL.revokeObjectURL @URL
lock: (lock=true) ->
@isLocked = lock
return unless @ is QR.selected
for name in ['thread', 'name', 'email', 'sub', 'com', 'fileButton', 'filename', 'spoiler', 'flag'] when node = QR.nodes[name]
node.disabled = lock
@nodes.rm.style.visibility = if lock then 'hidden' else ''
(if lock then $.off else $.on) QR.nodes.filename.previousElementSibling, 'click', QR.openFileInput
@nodes.spoiler.disabled = lock
@nodes.el.draggable = !lock
unlock: ->
@lock false
select: =>
if QR.selected
QR.selected.nodes.el.id = null
QR.selected.forceSave()
QR.selected = @
@lock @isLocked
@nodes.el.id = 'selected'
# Scroll the list to center the focused post.
rectEl = @nodes.el.getBoundingClientRect()
rectList = @nodes.el.parentNode.getBoundingClientRect()
@nodes.el.parentNode.scrollLeft += rectEl.left + rectEl.width/2 - rectList.left - rectList.width/2
@load()
$.event 'QRPostSelection', @
load: ->
# Load this post's values.
for name in ['thread', 'name', 'email', 'sub', 'com', 'filename', 'flag']
continue unless node = QR.nodes[name]
node.value = @[name] or node.dataset.default or null
@showFileData()
QR.characterCount()
save: (input) ->
if input.type is 'checkbox'
@spoiler = input.checked
return
{name} = input.dataset
@[name] = input.value or input.dataset.default or null
switch name
when 'thread'
QR.status()
when 'com'
@nodes.span.textContent = @com
QR.characterCount()
# Disable auto-posting if you're typing in the first post
# during the last 5 seconds of the cooldown.
if QR.cooldown.auto and @ is QR.posts[0] and 0 < QR.cooldown.seconds <= 5
QR.cooldown.auto = false
when 'filename'
return unless @file
@file.newName = @filename.replace /[/\\]/g, '-'
unless /\.(jpe?g|png|gif|pdf|swf)$/i.test @filename
# 4chan will truncate the filename if it has no extension,
# but it will always replace the extension by the correct one,
# so we suffix it with '.jpg' when needed.
@file.newName += '.jpg'
@updateFilename()
forceSave: ->
return unless @ is QR.selected
# Do this in case people use extensions
# that do not trigger the `input` event.
for name in ['thread', 'name', 'email', 'sub', 'com', 'filename', 'spoiler', 'flag']
continue unless node = QR.nodes[name]
@save node
return
setFile: (@file) ->
@filename = file.name
@filesize = $.bytesToString file.size
@nodes.label.hidden = false if QR.spoiler
URL.revokeObjectURL @URL
@showFileData() if @ is QR.selected
unless /^image/.test file.type
@nodes.el.style.backgroundImage = null
return
@setThumbnail()
setThumbnail: ->
# Create a redimensioned thumbnail.
img = $.el 'img'
img.onload = =>
# Generate thumbnails only if they're really big.
# Resized pictures through canvases look like ass,
# so we generate thumbnails `s` times bigger then expected
# to avoid crappy resized quality.
s = 90 * 2 * window.devicePixelRatio
s *= 3 if @file.type is 'image/gif' # let them animate
{height, width} = img
if height < s or width < s
@URL = fileURL
@nodes.el.style.backgroundImage = "url(#{@URL})"
return
if height <= width
width = s / height * width
height = s
else
height = s / width * height
width = s
cv = $.el 'canvas'
cv.height = img.height = height
cv.width = img.width = width
cv.getContext('2d').drawImage img, 0, 0, width, height
URL.revokeObjectURL fileURL
cv.toBlob (blob) =>
@URL = URL.createObjectURL blob
@nodes.el.style.backgroundImage = "url(#{@URL})"
fileURL = URL.createObjectURL @file
img.src = fileURL
rmFile: ->
return if @isLocked
delete @file
delete @filename
delete @filesize
@nodes.el.title = null
QR.nodes.fileContainer.title = ''
@nodes.el.style.backgroundImage = null
@nodes.label.hidden = true if QR.spoiler
@showFileData()
URL.revokeObjectURL @URL
updateFilename: ->
long = "#{@filename} (#{@filesize})\nCtrl+click to edit filename. Shift+click to clear."
@nodes.el.title = long
return unless @ is QR.selected
QR.nodes.fileContainer.title = long
showFileData: ->
if @file
@updateFilename()
QR.nodes.filename.value = @filename
QR.nodes.spoiler.checked = @spoiler
$.addClass QR.nodes.fileSubmit, 'has-file'
else
$.rmClass QR.nodes.fileSubmit, 'has-file'
pasteText: (file) ->
reader = new FileReader()
reader.onload = (e) =>
text = e.target.result
if @com
@com += "\n#{text}"
else
@com = text
if QR.selected is @
QR.nodes.com.value = @com
@nodes.span.textContent = @com
reader.readAsText file
dragStart: (e) ->
e.dataTransfer.setDragImage @, e.layerX, e.layerY
$.addClass @, 'drag'
dragEnd: -> $.rmClass @, 'drag'
dragEnter: -> $.addClass @, 'over'
dragLeave: -> $.rmClass @, 'over'
dragOver: (e) ->
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
drop: ->
$.rmClass @, 'over'
return unless @draggable
el = $ '.drag', @parentNode
index = (el) -> [el.parentNode.children...].indexOf el
oldIndex = index el
newIndex = index @
(if oldIndex < newIndex then $.after else $.before) @, el
post = QR.posts.splice(oldIndex, 1)[0]
QR.posts.splice newIndex, 0, post
QR.status()

View File

@ -19,7 +19,7 @@ QuoteOP =
{quotelinks} = @nodes
# rm (OP) from cross-thread quotes.
if @isClone and quotes.contains @thread.fullID
if @isClone and @thread.fullID in quotes
i = 0
while quotelink = quotelinks[i++]
quotelink.textContent = quotelink.textContent.replace QuoteOP.text, ''
@ -27,7 +27,7 @@ QuoteOP =
{fullID} = (if @isClone then @context else @).thread
# add (OP) to quotes quoting this context's OP.
return unless quotes.contains fullID
return unless fullID in quotes
i = 0
while quotelink = quotelinks[i++]
{boardID, postID} = Get.postDataFromLink quotelink

View File

@ -11,14 +11,14 @@ QuoteThreading =
innerHTML: '<label><input id=threadingControl type=checkbox checked> Threading</label>'
input = $ 'input', @controls
$.on input, 'change', QuoteThreading.toggle
$.on input, 'change', @toggle
$.event 'AddMenuEntry',
type: 'header'
el: @controls
order: 98
$.on d, '4chanXInitFinished', @setup
$.on d, '4chanXInitFinished', @setup unless Conf['Unread Count']
Post.callbacks.push
name: 'Quote Threading'
@ -26,83 +26,87 @@ QuoteThreading =
setup: ->
$.off d, '4chanXInitFinished', QuoteThreading.setup
{posts} = g
QuoteThreading.force()
for ID, post of posts
if post.cb
post.cb.call post
QuoteThreading.hasRun = true
force: ->
post.cb true for ID, post of g.posts when post.cb
return
node: ->
return if @isClone or not QuoteThreading.enabled or @thread.OP is @
{quotes, ID, fullID} = @
{posts} = g
return if !(post = posts[fullID]) or post.isHidden # Filtered
return if @isClone or not QuoteThreading.enabled
Unread.posts.push @ if Conf['Unread Count']
uniq = {}
len = "#{g.BOARD}".length + 1
for quote in quotes
qid = quote
continue unless qid[len..] < ID
if qid of posts
uniq[qid[len..]] = true
return if @thread.OP is @ or !(post = posts[@fullID]) or post.isHidden # Filtered
keys = []
len = g.BOARD.ID.length + 1
keys.push quote for quote in @quotes when (quote[len..] < @ID) and quote of posts
keys = Object.keys uniq
return unless keys.length is 1
@threaded = "#{g.BOARD}.#{keys[0]}"
@threaded = keys[0]
@cb = QuoteThreading.nodeinsert
nodeinsert: ->
qpost = g.posts[@threaded]
nodeinsert: (force) ->
post = g.posts[@threaded]
delete @threaded
delete @cb
return false if @thread.OP is post
return false if @thread.OP is qpost
{posts} = Unread
{root} = post.nodes
if QuoteThreading.hasRun
unless force
height = doc.clientHeight
{bottom, top} = qpost.nodes.root.getBoundingClientRect()
{bottom, top} = root.getBoundingClientRect()
# Post is unread or is fully visible.
return false unless Unread.posts.contains(qpost) or ((bottom < height) and (top > 0))
return false unless (Conf['Unread Count'] and posts[post.ID]) or ((bottom < height) and (top > 0))
qroot = qpost.nodes.root
unless $.hasClass qroot, 'threadOP'
$.addClass qroot, 'threadOP'
if $.hasClass root, 'threadOP'
threadContainer = root.nextElementSibling
post = Get.postFromRoot $.x 'descendant::div[contains(@class,"postContainer")][last()]', threadContainer
$.add threadContainer, @nodes.root
else
threadContainer = $.el 'div',
className: 'threadContainer'
$.after qroot, threadContainer
else
threadContainer = qroot.nextSibling
$.add threadContainer, @nodes.root
$.after root, threadContainer
$.addClass root, 'threadOP'
return true unless Conf['Unread Count']
if posts[post.ID]
posts.after post, @
else
posts.prepend @
$.add threadContainer, @nodes.root
return true
toggle: ->
thread = $ '.thread'
replies = $$ '.thread > .replyContainer, .threadContainer > .replyContainer', thread
QuoteThreading.enabled = @checked
if @checked
QuoteThreading.hasRun = false
for reply in replies
QuoteThreading.node.call node = Get.postFromRoot reply
node.cb() if node.cb
QuoteThreading.hasRun = true
if QuoteThreading.enabled = @checked
QuoteThreading.force()
else
replies.sort (a, b) ->
aID = Number a.id[2..]
bID = Number b.id[2..]
aID - bID
$.add thread, replies
thread = $('.thread')
posts = []
nodes = []
posts.push post for ID, post of g.posts when not (post is post.thread.OP or post.isClone)
posts.sort (a, b) -> a.ID - b.ID
nodes.push post.nodes.root for post in posts
$.add thread, nodes
containers = $$ '.threadContainer', thread
$.rm container for container in containers
$.rmClass post, 'threadOP' for post in $$ '.threadOP'
Unread.update true
return
kb: ->
control = $.id 'threadingControl'
control.click()
control.checked = not control.checked
QuoteThreading.toggle.call control

View File

@ -69,19 +69,16 @@ Quotify =
$.addClass a, 'quotelink'
$.extend a.dataset, {boardID, postID}
unless @quotes.contains quoteID
@quotes.push quoteID
@quotes.push quoteID unless quoteID in @quotes
unless a
deadlink.textContent = "#{quote}\u00A0(Dead)"
return
return deadlink.textContent = "#{quote}\u00A0(Dead)" unless a
$.replace deadlink, a
if $.hasClass a, 'quotelink'
@nodes.quotelinks.push a
fixDeadlink: (deadlink) ->
if !(el = deadlink.previousSibling) or el.nodeName is 'BR'
if not (el = deadlink.previousSibling) or el.nodeName is 'BR'
green = $.el 'span',
className: 'quote'
$.before deadlink, green