Merge Zixaphir X, close #221
This commit is contained in:
commit
8419b7c88b
@ -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 %>."
|
||||
|
||||
2
LICENSE
2
LICENSE
@ -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
|
||||
|
||||
@ -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
@ -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"
|
||||
]
|
||||
|
||||
7383
builds/crx/script.js
7383
builds/crx/script.js
File diff suppressed because one or more lines are too long
@ -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": {
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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}$/"
|
||||
|
||||
@ -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> #{if type is 'hide' then '-' else '+'} </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']) ->
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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, '''
|
||||
|
||||
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 = " <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
10
src/General/Cheats.coffee
Normal 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`
|
||||
@ -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'
|
||||
|
||||
@ -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) ->
|
||||
|
||||
@ -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'> - </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
426
src/General/Index.coffee
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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 = {}
|
||||
|
||||
@ -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%']
|
||||
|
||||
1143
src/General/css/font-awesome.css
vendored
1143
src/General/css/font-awesome.css
vendored
File diff suppressed because one or more lines are too long
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -1,47 +1,23 @@
|
||||
"""#{if isOP then '' else "<div class=sideArrows id=sa#{postID}>>></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>"""
|
||||
|
||||
4
src/General/html/Features/Index-navlinks.html
Normal file
4
src/General/html/Features/Index-navlinks.html
Normal file
@ -0,0 +1,4 @@
|
||||
[<a href="./catalog">Catalog</a>]
|
||||
[<time id="index-last-refresh" title="Last index refresh">...</time>]
|
||||
<input type="search" id="index-search" class="field" placeholder="Search">
|
||||
<a id="index-search-clear" class="fa fa-times-circle" href="javascript:;"></a>
|
||||
14
src/General/html/Features/Index-pagelist.html
Normal file
14
src/General/html/Features/Index-pagelist.html
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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) ->
|
||||
|
||||
20
src/General/lib/callbacks.class
Normal file
20
src/General/lib/callbacks.class
Normal 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
|
||||
@ -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') %>
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
58
src/General/lib/randomaccesslist.class
Normal file
58
src/General/lib/randomaccesslist.class
Normal 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
|
||||
@ -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[@]
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
"run_at": "document_start"
|
||||
}],
|
||||
"homepage_url": "<%= meta.page %>",
|
||||
"minimum_chrome_version": "27",
|
||||
"minimum_chrome_version": "<%= meta.min.chrome %>",
|
||||
"permissions": [
|
||||
"storage"
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
@ -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
|
||||
|
||||
@ -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 @
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 @
|
||||
|
||||
@ -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"
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) ->
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"}) %>'
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
124
src/Posting/QR.captcha.coffee
Normal file
124
src/Posting/QR.captcha.coffee
Normal 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
628
src/Posting/QuickReply.coffee → src/Posting/QR.coffee
Executable file → Normal 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()
|
||||
106
src/Posting/QR.cooldown.coffee
Normal file
106
src/Posting/QR.cooldown.coffee
Normal 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
|
||||
72
src/Posting/QR.persona.coffee
Normal file
72
src/Posting/QR.persona.coffee
Normal 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
265
src/Posting/QR.post.coffee
Normal 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()
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user