This commit is contained in:
Kabir Sala 2014-02-24 23:35:11 +01:00
commit 180844b376
82 changed files with 4965 additions and 2395 deletions

View File

@ -1,8 +1,17 @@
### v1.3.10
*2014-02-20*
## v1.4.0
*2014-02-24*
**Spittie**
- I keep breaking and fixing stuff. But mostly breaking.
**ParrotParrot**:
- Added `Original filename` variable to Sauce panel.
**MayhemYDG**:
- Added a Reset Settings button in the settings.
- More stability update.
- Stability update.
**Zixaphir**:
- Merge changes from Mayhem fork
### v1.3.9
*2014-02-20*

48
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,48 @@
## Reporting bugs and suggestions
Reporting bugs:
1. Make sure both your **browser** and **4chan X** are up to date.<br>
Only **Chrome**, **Firefox** and **Opera** are supported.<br>
**SRWare Iron**, **Firefox ESR**, **Pale Moon**, **Waterfox**, and other derivatives are not supported, use them at your own risk.
2. Look at the list of [known problems and solutions](https://github.com/MayhemYDG/4chan-x/wiki/FAQ#known-problems).
3. Disable your other extensions & scripts to identify conflicts.
4. If your issue persists, open a [new issue](https://github.com/MayhemYDG/4chan-x/issues) with the following information:
1. Precise steps to reproduce the problem, with the expected and actual results.
2. [Console errors](https://github.com/MayhemYDG/4chan-x/wiki/FAQ#console-errors), if any.
3. 4chan X version, browser variant, browser version, and Greasemonkey version if you are using it.
4. Your exported settings. If your settings contains sensible information (e.g. personas), edit the text file manually.
Respect these guidelines:
- Describe the issue clearly, put some effort into it. A one-liner isn't a good enough description.
- If you want to get your suggestion implemented sooner, make it convincing.
- If you want to criticize, make it convincing and constructive.
- Be mature. Act like an idiot and you will be blocked without warning.
## Development & Contribution
### Get started
- Install [node.js](http://nodejs.org/).
- Install [Grunt's CLI](http://gruntjs.com/) with `npm install -g grunt-cli`.
- Clone 4chan X.
- `cd` into it.
- Install/Update 4chan X dependencies with `npm install`.
### Build
- Build with `grunt`.
- Continuously build with `grunt watch`.
### Release
- Update the version with `grunt patch`, `grunt minor` or `grunt major`.
- Release with `grunt release`.
Note: this is only used to release new 4chan X versions, and is **not** needed or wanted in pull requests.
### Contribute
- Edit the sources.
- If the edits affect regular users, edit the changelog.
- Open a pull request.

View File

@ -36,6 +36,7 @@ module.exports = (grunt) ->
'src/Monitoring/**/*'
'src/Archive/**/*'
'src/Miscellaneous/**/*'
'src/General/Navigate.coffee'
'src/General/Settings.coffee'
'src/General/Main.coffee'
]

View File

@ -1,5 +1,5 @@
/*
* 4chan X - Version 1.3.10 - 2014-02-24
* 4chan X - Version 1.4.0 - 2014-02-24
*
* Licensed under the MIT license.
* https://github.com/Spittie/4chan-x/blob/master/LICENSE

View File

@ -1,7 +1,7 @@
// ==UserScript==
// @name 4chan X
// @version 1.3.10
// @minGMVer 1.13
// @version 1.4.0
// @minGMVer 1.14
// @minFFVer 26
// @namespace 4chan-X
// @description Cross-browser userscript for maximum lurking on 4chan.
@ -13,6 +13,7 @@
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM_openInTab
// @grant GM_xmlhttpRequest
// @run-at document-start

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
{
"name": "4chan X",
"version": "1.3.10",
"version": "1.4.0",
"manifest_version": 2,
"description": "Cross-browser userscript for maximum lurking on 4chan.",
"icons": {

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
postMessage({version:'1.3.10'},'*')
postMessage({version:'1.4.0'},'*')

View File

@ -1,6 +1,6 @@
{
"name": "4chan-X",
"version": "1.3.10",
"version": "1.4.0",
"description": "Cross-browser userscript for maximum lurking on 4chan.",
"meta": {
"name": "4chan X",
@ -21,22 +21,22 @@
"min": {
"chrome": "31",
"firefox": "26",
"greasemonkey": "1.13"
"greasemonkey": "1.14"
}
},
"devDependencies": {
"font-awesome": "https://github.com/FortAwesome/Font-Awesome/archive/v4.0.3.tar.gz",
"grunt": "~0.4.1",
"grunt-bump": "~0.0.11",
"grunt-concurrent": "~0.4.0",
"font-awesome": "~4.0.3",
"grunt": "~0.4.2",
"grunt-bump": "~0.0.13",
"grunt-concurrent": "~0.4.3",
"grunt-contrib-clean": "~0.5.0",
"grunt-contrib-coffee": "~0.8.0",
"grunt-contrib-compress": "~0.5.2",
"grunt-contrib-coffee": "~0.8.2",
"grunt-contrib-compress": "~0.6.0",
"grunt-contrib-concat": "~0.3.0",
"grunt-contrib-copy": "~0.5.0",
"grunt-contrib-watch": "~0.5.3",
"grunt-shell": "~0.6.0",
"load-grunt-tasks": "~0.2.0"
"grunt-shell": "~0.6.4",
"load-grunt-tasks": "~0.2.1"
},
"repository": {
"type": "git",

View File

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

View File

@ -58,7 +58,7 @@ ThreadHiding =
$.cache "//a.4cdn.org/#{g.BOARD}/threads.json", ->
return unless @status is 200
threads = {}
for page in JSON.parse @response
for page in @response
for thread in page.threads
if thread.no of hiddenThreadsOnCatalog
threads[thread.no] = hiddenThreadsOnCatalog[thread.no]

View File

@ -208,7 +208,8 @@ Build =
className: 'summary'
textContent: text.join ' '
href: "/#{boardID}/res/#{threadID}"
thread: (board, data) ->
thread: (board, data, full) ->
Build.spoilerRange[board] = data.custom_spoiler
if (OP = board.posts[data.no]) and root = OP.nodes.root.parentNode
@ -218,6 +219,10 @@ Build =
className: 'thread'
id: "t#{data.no}"
$.add root, Build[if full then 'fullThread' else 'excerptThread'] board, data, OP
root
excerptThread: (board, data, OP) ->
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']
@ -226,6 +231,6 @@ Build =
# 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
nodes
$.add root, nodes
root
fullThread: (board, data) -> Build.postFromObject data, board.ID

View File

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

View File

@ -1,6 +1,10 @@
Config =
main:
'Miscellaneous':
'JSON Navigation' : [
true
'Use JSON for loading the Board Index and Threads. Also allows searching and sorting the board index and infinite scolling.'
]
'Catalog Links': [
true
'Add toggle link in header menu to turn Navigation links into links to each board\'s catalog.'

View File

@ -1,13 +1,13 @@
Get =
threadExcerpt: (thread) ->
{OP} = thread
excerpt = OP.info.subject?.trim() or
excerpt = "/#{thread.board}/ - " + (
OP.info.subject?.trim() or
OP.info.comment.replace(/\n+/g, ' // ') or
Conf['Anonymize'] and 'Anonymous' or
$('.nameBlock', OP.nodes.info).textContent.trim()
if excerpt.length > 70
excerpt = "#{excerpt[...67]}..."
"/#{thread.board}/ - #{excerpt}"
$('.nameBlock', OP.nodes.info).textContent.trim())
return "#{excerpt[...70]}..." if excerpt.length > 73
excerpt
threadFromRoot: (root) ->
g.threads["#{g.BOARD}.#{root.id[1..]}"]
threadFromNode: (node) ->
@ -40,24 +40,28 @@ Get =
allQuotelinksLinkingTo: (post) ->
# Get quotelinks & backlinks linking to the given post.
quotelinks = []
{posts} = g
fullID = {post}
handleQuotes = (qPost, type) ->
quotelinks.push qPost.nodes[type]...
quotelinks.push clone.nodes[type]... for clone in qPost.clones
return
# First:
# In every posts,
# if it did quote this post,
# get all their backlinks.
for ID, quoterPost of g.posts
if post.fullID in quoterPost.quotes
for quoterPost in [quoterPost].concat quoterPost.clones
quotelinks.push.apply quotelinks, quoterPost.nodes.quotelinks
posts.forEach (qPost) ->
if fullID in qPost.quotes
handleQuotes qPost, 'quotelinks'
# Second:
# If we have quote backlinks:
# in all posts this post quoted
# and their clones,
# get all of their backlinks.
if Conf['Quote Backlinks']
for quote in post.quotes
continue unless quotedPost = g.posts[quote]
for quotedPost in [quotedPost].concat quotedPost.clones
quotelinks.push.apply quotelinks, [quotedPost.nodes.backlinks...]
handleQuotes qPost, 'backlinks' for quote in post.quotes when qPost = posts[quote]
# Third:
# Filter out irrelevant quotelinks.
quotelinks.filter (quotelink) ->
@ -76,6 +80,7 @@ Get =
$.cache url,
-> Get.archivedPost @, boardID, postID, root, context
,
responseType: 'json'
withCredentials: url.archive.withCredentials
insert: (post, root, context) ->
# Stop here if the container has been removed while loading.
@ -114,7 +119,7 @@ Get =
"Error #{req.statusText} (#{req.status})."
return
posts = JSON.parse(req.response).posts
{posts} = req.response
Build.spoilerRange[boardID] = posts[0].custom_spoiler
for post in posts
break if post.no is postID # we found it!
@ -145,7 +150,7 @@ Get =
Get.insert post, root, context
return
data = JSON.parse req.response
data = req.response
if data.error
$.addClass root, 'warning'
root.textContent = data.error
@ -211,28 +216,16 @@ Get =
Main.callbackNodes Post, [post]
Get.insert post, root, context
parseMarkup: (text) ->
switch text
when '\n'
'<br>'
when '[b]'
'<b>'
when '[/b]'
'</b>'
when '[spoiler]'
'<s>'
when '[/spoiler]'
'</s>'
when '[code]'
'<pre class=prettyprint>'
when '[/code]'
'</pre>'
when '[moot]'
'<div style="padding:5px;margin-left:.5em;border-color:#faa;border:2px dashed rgba(255,0,0,.1);border-radius:2px">'
when '[/moot]'
'</div>'
when '[banned]'
'<strong style="color: red;">'
when '[/banned]'
'</strong>'
else
text.replace ':lit', ''
{
'\n': '<br>'
'[b]': '<b>'
'[/b]': '</b>'
'[spoiler]': '<s>'
'[/spoiler]': '</s>'
'[code]': '<pre class=prettyprint>'
'[/code]': '</pre>'
'[moot]': '<div style="padding:5px;margin-left:.5em;border-color:#faa;border:2px dashed rgba(255,0,0,.1);border-radius:2px">'
'[/moot]': '</div>'
'[banned]': '<strong style="color: red;">'
'[/banned]': '</strong>'
}[text] or text.replace ':lit', ''

View File

@ -5,6 +5,4 @@ doc = d.documentElement
g =
VERSION: '<%= version %>'
NAMESPACE: '<%= meta.name %>.'
boards: {}
threads: {}
posts: {}
boards: {}

View File

@ -102,9 +102,10 @@ Header =
$.ready =>
@footer = footer = $.id 'boardNavDesktopFoot'
if Conf['JSON Navigation']
$.on a, 'click', Navigate.navigate for a in $$ 'a', footer
if a = $ "a[href*='/#{g.BOARD}/']", footer
a.className = 'current'
$.on a, 'click', Index.cb.link
cs = $.el 'a',
id: 'settingsWindowLink'
@ -133,21 +134,27 @@ Header =
toggle: $.el 'div',
id: 'scroll-marker'
initReady: ->
Header.setBoardList()
Header.addNav()
setBoardList: ->
fourchannav = $.id 'boardNavDesktop'
boardList = $.el 'span',
Header.boardList = boardList = $.el 'span',
id: 'board-list'
innerHTML: "<span id=custom-board-list></span><span id=full-board-list hidden><span class='hide-board-list-container brackets-wrap'><a href=javascript:; class='hide-board-list-button'>&nbsp;-&nbsp;</a></span> #{fourchannav.innerHTML}</span>"
if a = $ "a[href*='/#{g.BOARD}/']", boardList
a.className = 'current'
$.on a, 'click', Index.cb.link
for a in $$ 'a', boardList
if Conf['JSON Navigation']
$.on a, 'click', Navigate.navigate
if a.pathname.split('/')[1] is g.BOARD.ID
a.className = 'current'
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.noticesRoot, Header.toggle]
$.add Header.bar, [Header.boardList, Header.shortcuts, Header.noticesRoot, Header.toggle]
Header.setCustomNav Conf['Custom Board Navigation']
Header.generateBoardList Conf['boardnav'].replace /(\r\n|\n|\r)/g, ' '
@ -156,10 +163,10 @@ Header =
$.sync 'boardnav', Header.generateBoardList
generateBoardList: (text) ->
list = $ '#custom-board-list', Header.bar
list = $ '#custom-board-list', Header.boardList
$.rmAll list
return unless text
as = $$ '#full-board-list a[title]', Header.bar
as = $$ '#full-board-list a[title]', Header.boardList
nodes = text.match(/[\w@]+((-(all|title|replace|full|index|catalog|url:"[^"]+[^"]"|text:"[^"]+")|\,"[^"]+[^"]"))*|[^\w@]+/g).map (t) ->
if /^[^\w@]/.test t
return $.tn t
@ -184,11 +191,10 @@ Header =
if a.textContent is board
a = a.cloneNode true
current = $.hasClass a, 'current'
if current
$.on a, 'click', Index.cb.link
if Conf['JSON Navigation']
$.on a, 'click', Navigate.navigate
a.textContent = if /-title/.test(t) or /-replace/.test(t) and current
a.textContent = if /-title/.test(t) or /-replace/.test(t) and $.hasClass a, 'current'
a.title
else if /-full/.test t
"/#{board}/ - #{a.title}"
@ -299,17 +305,15 @@ Header =
toggleHideBarOnScroll: (e) ->
hide = @checked
$.set 'Header auto-hide on scroll', hide
$.cb.checked.call @
Header.setHideBarOnScroll hide
hideBarOnScroll: ->
offsetY = window.pageYOffset
if offsetY > (Header.previousOffset or 0)
$.addClass Header.bar, 'autohide'
$.addClass Header.bar, 'scroll'
$.addClass Header.bar, 'autohide', 'scroll'
else
$.rmClass Header.bar, 'autohide'
$.rmClass Header.bar, 'scroll'
$.rmClass Header.bar, 'autohide', 'scroll'
Header.previousOffset = offsetY
setBarPosition: (bottom) ->
@ -329,7 +333,7 @@ Header =
$.addClass doc, args[0]
$.rmClass doc, args[1]
Header.bar.parentNode.className = args[2]
Header.bar.parentNode.className = args[2]
$[args[3]] Header.bar, Header.noticesRoot
toggleBarPosition: ->
@ -379,21 +383,37 @@ Header =
return if (Get.postFromRoot post).isHidden
Header.scrollTo post
scrollTo: (root, down, needed) ->
if down
x = Header.getBottomOf root
if Conf['Header auto-hide on scroll'] and Conf['Bottom header']
{height} = Header.bar.getBoundingClientRect()
if x <= 0
x += height if !Header.isHidden()
else
x -= height if Header.isHidden()
window.scrollBy 0, -x unless needed and x >= 0
else
x = Header.getTopOf root
if Conf['Header auto-hide on scroll'] and !Conf['Bottom header']
{height} = Header.bar.getBoundingClientRect()
if x >= 0
x += height if !Header.isHidden()
else
x -= height if Header.isHidden()
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.toggle.getBoundingClientRect()
top -= headRect.top + headRect.height
top
getBottomOf: (root) ->
{clientHeight} = doc
bottom = clientHeight - root.getBoundingClientRect().bottom
@ -401,6 +421,12 @@ Header =
headRect = Header.toggle.getBoundingClientRect()
bottom -= clientHeight - headRect.bottom + headRect.height
bottom
isHidden: ->
{top} = Header.bar.getBoundingClientRect()
if Conf['Bottom header']
top is doc.clientHeight
else
top < 0
addShortcut: (el) ->
shortcut = $.el 'span',
@ -408,6 +434,8 @@ Header =
$.add shortcut, el
$.prepend Header.shortcuts, shortcut
rmShortcut: (el) ->
$.rm el.parentElement
menuToggle: (e) ->
Header.menu.toggle e, @, g

View File

@ -1,10 +1,12 @@
Index =
init: ->
return if g.VIEW isnt 'index' or g.BOARD.ID is 'f'
return if g.BOARD.ID is 'f' or g.VIEW is 'catalog' or !Conf['JSON Navigation']
@board = "#{g.BOARD}"
@button = $.el 'a',
className: 'index-refresh-shortcut fa fa-refresh'
title: 'Refresh Index'
title: 'Refresh'
href: 'javascript:;'
textContent: 'Refresh Index'
$.on @button, 'click', @update
@ -68,7 +70,6 @@ Index =
subEntries: [repliesEntry, anchorEntry, refNavEntry, modeEntry, sortEntry]
$.addClass doc, 'index-loading'
@update()
@root = $.el 'div', className: 'board'
@pagelist = $.el 'div',
className: 'pagelist'
@ -79,40 +80,49 @@ Index =
innerHTML: <%= importHTML('Features/Index-navlinks') %>
@searchInput = $ '#index-search', @navLinks
@currentPage = @getCurrentPage()
$.on window, 'popstate', @cb.popstate
$.on d, 'scroll', Index.scroll
$.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
$.on $('#returnlink a', @navLinks), 'click', Navigate.navigate
$.on $('#cataloglink a', @navLinks), 'click', -> window.location = "//boards.4chan.org/#{g.BOARD}/catalog"
for navLink in $$ '.navLinks'
$.rm navLink
@update() if g.VIEW is 'index'
$.asap (-> $('.board', doc) or d.readyState isnt 'loading'), ->
if g.VIEW is 'index'
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
$.rm navLink for navLink in $$ '.navLinks'
$.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
$.asap (-> $('.pagelist', doc) or d.readyState isnt 'loading'), ->
if pagelist = $('.pagelist')
$.replace pagelist, Index.pagelist
else
$.after $.id('delform'), Index.pagelist
scroll: $.debounce 100, ->
return if Index.req or Conf['Index Mode'] isnt 'infinite' or ((d.body.scrollTop or doc.scrollTop) <= doc.scrollHeight - (300 + window.innerHeight))
pageNum = Index.getCurrentPage() + 1
return if Index.req or Conf['Index Mode'] isnt 'infinite' or (doc.scrollTop <= doc.scrollHeight - (300 + window.innerHeight)) or g.VIEW is 'thread'
Index.pageNum = Index.getCurrentPage() unless Index.pageNum? # Avoid having to pushState to keep track of the current page
pageNum = Index.pageNum++
return Index.endNotice() if pageNum >= Index.pagesNum
nodesPerPage = Index.threadsNumPerPage * 2
history.pushState null, '', "/#{g.BOARD}/#{pageNum}"
nodes = Index.sortedNodes[nodesPerPage * pageNum ... nodesPerPage * (pageNum + 1)]
Index.buildReplies nodes if Conf['Show Replies']
$.add Index.root, nodes
Index.setPage()
nodes = Index.buildSinglePage pageNum
Index.buildReplies nodes if Conf['Show Replies']
Index.buildStructure nodes
Index.setPage pageNum
endNotice: do ->
notify = false
@ -134,9 +144,6 @@ Index =
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
@ -149,11 +156,6 @@ Index =
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()
history.pushState null, '', @pathname
Index.update()
scrollToIndex: ->
Header.scrollToIfNeeded Index.root
@ -198,8 +200,8 @@ Index =
$.rmAll pagesRoot
$.add pagesRoot, nodes
Index.togglePagelist()
setPage: ->
pageNum = Index.getCurrentPage()
setPage: (pageNum) ->
pageNum or= Index.getCurrentPage()
maxPageNum = Index.getMaxPageNum()
pagesRoot = $ '.pages', Index.pagelist
# Previous/Next buttons
@ -223,19 +225,37 @@ Index =
update: (pageNum) ->
return unless navigator.onLine
if g.VIEW is 'thread'
return ThreadUpdater.update() if Conf['Thread Updater']
return
unless d.readyState is 'loading' or Index.root.parentElement
$.replace $('.board'), Index.root
delete Index.pageNum
Index.req?.abort()
Index.notice?.close()
# This notice only displays if Index Refresh is taking too long
now = Date.now()
$.ready ->
Index.nTimeout = setTimeout (->
if Index.req and !Index.notice
Index.notice = new Notice 'info', 'Refreshing index...', 2
), 3 * $.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
whenModified: Index.board is "#{g.BOARD}"
$.addClass Index.button, 'fa-spin'
load: (e, pageNum) ->
$.rmClass Index.button, 'fa-spin'
{req, notice} = Index
{req, notice, nTimeout} = Index
clearTimeout nTimeout if nTimeout
delete Index.nTimeout
delete Index.req
delete Index.notice
@ -244,26 +264,40 @@ Index =
notice.close()
return
if req.status not in [200, 304]
err = "Index refresh failed. Error #{req.statusText} (#{req.status})"
if notice
notice.setType 'warning'
notice.el.lastElementChild.textContent = err
setTimeout notice.close, $.SECOND
else
new Notice 'warning', err, 1
return
Navigate.title()
Index.board = "#{g.BOARD}"
try
if req.status is 200
Index.parse JSON.parse(req.response), pageNum
Index.parse req.response, pageNum
else if req.status is 304 and pageNum?
Index.pageNav pageNum
catch err
c.error 'Index failure:', err.stack
c.error "Index failure: #{err.message}", 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
setTimeout notice.close, $.SECOND
else
new Notice 'error', 'Index refresh failed.', 2
new Notice 'error', 'Index refresh failed.', 1
return
timeEl = $ '#index-last-refresh', Index.navLinks
timeEl = $ '#index-last-refresh time', Index.navLinks
timeEl.dataset.utc = Date.parse req.getResponseHeader 'Last-Modified'
RelativeDates.update timeEl
Index.scrollToIndex()
parse: (pages, pageNum) ->
Index.parseThreadList pages
Index.buildThreads()
@ -274,36 +308,38 @@ Index =
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()
g.BOARD.threads.forEach (thread) ->
thread.collect() unless thread.ID in Index.liveThreadIDs
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
threadRoot = Build.thread g.BOARD, threadData
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
Index.nodes.push threadRoot
continue if thread.ID of thread.posts
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."
message: "Parsing of Thread No.#{thread} failed. Thread will be skipped."
error: err
Main.handleErrors errors if errors
@ -312,9 +348,10 @@ Index =
Main.callbackNodes Thread, threads
Main.callbackNodes Post, posts
$.event 'IndexRefresh'
buildReplies: (threadRoots) ->
posts = []
for threadRoot in threadRoots by 2
for threadRoot in threadRoots
thread = Get.threadFromRoot threadRoot
i = Index.liveThreadIDs.indexOf thread.ID
continue unless lastReplies = Index.liveThreadData[i].last_replies
@ -336,57 +373,99 @@ Index =
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
{liveThreadIDs, liveThreadData} = Index
sortedThreadIDs = {
lastreply:
[liveThreadData...].sort((a, b) ->
a = num[num.length - 1] if (num = a.last_replies)
b = num[num.length - 1] if (num = b.last_replies)
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 = []
).map (post) -> post.no
bump: liveThreadIDs
birth: [liveThreadIDs... ].sort (a, b) -> b - a
replycount: [liveThreadData...].sort((a, b) -> b.replies - a.replies).map (post) -> post.no
filecount: [liveThreadData...].sort((a, b) -> b.images - a.images ).map (post) -> post.no
}[Conf['Index Sort']]
Index.sortedNodes = sortedNodes = new RandomAccessList
{nodes} = Index
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']
sortedNodes.push nodes[Index.liveThreadIDs.indexOf(threadID)]
if Index.isSearching and nodes = Index.querySearch(Index.searchInput.value)
Index.sortedNodes = new RandomAccessList nodes
items = [
# Sticky threads
fn: (thread) -> thread.isSticky
cnd: true
, # Highlighted threads
fn: (thread) -> thread.isOnTop
cnd: Conf['Filter']
, # Non-hidden threads
fn: (thread) -> !thread.isHidden
cnd: Conf['Anchor Hidden Threads']
]
i = 0
while item = items[i++]
{fn, cnd} = item
Index.sortOnTop fn if cnd
return
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)...
{sortedNodes} = Index
threadRoot = sortedNodes.first
while threadRoot
if match Get.threadFromRoot threadRoot.data
target = sortedNodes.first
j = 0
while j++ < offset
target = target.next
unless threadRoot is target
offset++
sortedNodes.before target, threadRoot
threadRoot = threadRoot.next
return
buildIndex: ->
if Conf['Index Mode'] isnt 'all pages'
pageNum = Index.getCurrentPage()
nodesPerPage = Index.threadsNumPerPage * 2
nodes = Index.sortedNodes[nodesPerPage * pageNum ... nodesPerPage * (pageNum + 1)]
nodes = Index.buildSinglePage Index.getCurrentPage()
else
nodes = Index.sortedNodes
nodes = [(target = Index.sortedNodes.first).data]
while target = target.next
nodes.push target.data
$.rmAll Index.root
$.rmAll Header.hover
Index.buildReplies nodes if Conf['Show Replies']
$.event 'IndexBuild', nodes
$.add Index.root, nodes
Index.buildStructure nodes
buildSinglePage: (pageNum) ->
nodes = []
nodesPerPage = Index.threadsNumPerPage
offset = nodesPerPage * pageNum
end = offset + nodesPerPage
target = Index.sortedNodes.order()[offset]
Index.sortedNodes
while (offset++ <= end) and target
nodes.push target.data
target = target.next
nodes
buildStructure: (nodes) ->
result = $.frag()
i = 0
$.add result, [node, $.el 'hr'] while node = nodes[i++]
$.add Index.root, result
$.rm hr for hr in $$ 'hr + hr', Index.root # Temp fix until I figure out where I fucked up
$.event 'IndexBuild', result
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
@ -396,6 +475,7 @@ Index =
else
pageNum = Index.getCurrentPage()
else
return unless Index.searchInput.dataset.searching
pageNum = Index.pageBeforeSearch
delete Index.pageBeforeSearch
<% if (type === 'userscript') { %>
@ -413,15 +493,21 @@ Index =
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]
search: (keywords) ->
found = []
target = Index.sortedNodes.first
while target
{data} = target
if Index.searchMatch Get.threadFromRoot(data), keywords
found.push data
target = target.next
found
searchMatch: (thread, keywords) ->
{info, file} = thread.OP
text = []

View File

@ -1,5 +1,8 @@
Main =
init: ->
g.threads = new SimpleDict
g.posts = new SimpleDict
pathname = location.pathname.split '/'
g.BOARD = new Board pathname[1]
return if g.BOARD.ID in ['z', 'fk']
@ -54,81 +57,17 @@ Main =
location.replace URL if URL
return
init = (features) ->
for name, module of features
# c.time "#{name} initialization"
try
module.init()
catch err
Main.handleErrors
message: "\"#{name}\" initialization crashed."
error: err
# finally
# c.timeEnd "#{name} initialization"
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
'Color User IDs': IDColor
'Custom CSS': CustomCSS
'Linkify': Linkify
'Reveal Spoilers': RemoveSpoilers
'Resurrect Quotes': Quotify
'Filter': Filter
'Thread Hiding Buttons': ThreadHiding
'Reply Hiding Buttons': PostHiding
'Recursive': Recursive
'Strike-through Quotes': QuoteStrikeThrough
'Quick Reply': QR
'Menu': Menu
'Report Link': ReportLink
'Thread Hiding (Menu)': ThreadHiding.menu
'Reply Hiding (Menu)': PostHiding.menu
'Delete Link': DeleteLink
'Filter (Menu)': Filter.menu
'Download Link': DownloadLink
'Archive Link': ArchiveLink
'Quote Inlining': QuoteInline
'Quote Previewing': QuotePreview
'Quote Backlinks': QuoteBacklink
'Mark Quotes of You': QuoteYou
'Mark OP Quotes': QuoteOP
'Mark Cross-thread Quotes': QuoteCT
'Anonymize': Anonymize
'Time Formatting': Time
'Relative Post Dates': RelativeDates
'File Info Formatting': FileInfo
'Fappe Tyme': FappeTyme
'Gallery': Gallery
'Gallery (menu)': Gallery.menu
'Sauce': Sauce
'Image Expansion': ImageExpand
'Image Expansion (Menu)': ImageExpand.menu
'Reveal Spoiler Thumbnails': RevealSpoilers
'Image Loading': ImageLoader
'Image Hover': ImageHover
'Thread Expansion': ExpandThread
'Thread Excerpt': ThreadExcerpt
'Favicon': Favicon
'Unread': Unread
'Quote Threading': QuoteThreading
'Thread Stats': ThreadStats
'Thread Updater': ThreadUpdater
'Thread Watcher': ThreadWatcher
'Thread Watcher (Menu)': ThreadWatcher.menu
'Index Navigation': Nav
'Keybinds': Keybinds
'Show Dice Roll': Dice
'Banner': Banner
for [name, feature] in Main.features
# c.time "#{name} initialization"
try
feature.init()
catch err
Main.handleErrors
message: "\"#{name}\" initialization crashed."
error: err
# finally
# c.timeEnd "#{name} initialization"
# c.timeEnd 'All initializations'
$.on d, 'AddCallback', Main.addCallback
@ -139,16 +78,12 @@ Main =
return if !Main.isThisPageLegit() or $.hasClass doc, 'fourchan-x'
# disable the mobile layout
$('link[href*=mobile]', d.head)?.disabled = true
<% if (type === 'crx') { %>
$.addClass doc, 'blink'
<% } else { %>
$.addClass doc, 'gecko'
<% } %>
$.addClass doc, 'fourchan-x'
$.addClass doc, 'seaweedchan'
$.addClass doc, g.VIEW
$.addClass doc, 'fourchan-x', 'seaweedchan', g.VIEW, '<% if (type === 'crx') { %>blink<% } else { %>gecko<% } %>'
$.addStyle Main.css
Main.setClass()
setClass: ->
if g.VIEW is 'catalog'
$.addClass doc, $.id('base-css').href.match(/catalog_(\w+)/)[1].replace('_new', '').replace /_+/g, '-'
return
@ -182,11 +117,7 @@ Main =
# Something might have gone wrong!
Main.initStyle()
if g.VIEW is 'thread'
Main.initThread()
else
$.event '4chanXInitFinished'
# 4chan Pass Link
if styleSelector = $.id 'styleSelector'
passLink = $.el 'a',
textContent: '4chan Pass'
@ -197,7 +128,18 @@ Main =
'left=0,top=0,width=500,height=255,toolbar=0,resizable=0'
$.before styleSelector.previousSibling, [$.tn '['; passLink, $.tn ']\u00A0\u00A0']
# Parse HTML or skip it and start building from JSON.
unless Conf['JSON Navigation'] and g.VIEW is 'index'
Main.initThread()
else
$.event '4chanXInitFinished'
<% if (type === 'userscript') { %>
test = $.el 'span'
test.classList.add 'a', 'b'
if test.className isnt 'a b'
new Notice 'warning', "Your version of Firefox is outdated (v<%= meta.min.firefox %> minimum) and <%= meta.name %> may not operate correctly.", 30
GMver = GM_info.version.split '.'
for v, i in "<%= meta.min.greasemonkey %>".split '.'
continue if v is GMver[i]
@ -211,23 +153,39 @@ Main =
new Notice 'warning', 'Cookies need to be enabled on 4chan for <%= meta.name %> to operate properly.', 30
initThread: ->
return unless threadRoot = $ '.thread'
thread = new Thread +threadRoot.id[1..], g.BOARD
posts = []
for postRoot in $$ '.thread > .postContainer', threadRoot
try
posts.push 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
if board = $ '.board'
threads = []
posts = []
Main.callbackNodes Thread, [thread]
Main.callbackNodesDB Post, posts, ->
$.event '4chanXInitFinished'
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
Main.handleErrors errors if errors
Main.callbackNodes Thread, threads
Main.callbackNodesDB Post, posts, ->
$.event '4chanXInitFinished'
$.get 'previousversion', null, ({previousversion}) ->
return if previousversion is g.VERSION
if previousversion
changelog = '<%= meta.repo %>blob/<%= meta.mainBranch %>/CHANGELOG.md'
el = $.el 'span',
innerHTML: "<%= meta.name %> has been updated to <a href='#{changelog}' target=_blank>version #{g.VERSION}</a>."
new Notice 'info', el, 15
else
Settings.open()
$.set 'previousversion', g.VERSION
callbackNodes: (klass, nodes) ->
i = 0
@ -237,24 +195,21 @@ Main =
return
callbackNodesDB: (klass, nodes, cb) ->
errors = null
len = 0
i = 0
i = 0
cbs = klass.callbacks
fn = ->
node = nodes[i++]
fn = ->
return false unless node = nodes[i]
cbs.execute node
i % 25
++i % 25
softTask = ->
while fn()
if len is i
cb() if cb
return
continue
unless nodes[i]
cb() if cb
return
setTimeout softTask, 0
len = nodes.length
softTask()
addCallback: (e) ->
@ -322,4 +277,68 @@ Main =
<%= grunt.file.read('src/General/css/photon.css').replace(/\s+/g, ' ').trim() %>
"""
features: [
['Polyfill', Polyfill]
['Redirect', Redirect]
['Header', Header]
['Catalog Links', CatalogLinks]
['Settings', Settings]
['Index Generator', Index]
['Announcement Hiding', PSAHiding]
['Fourchan thingies', Fourchan]
['Emoji', Emoji]
['Color User IDs', IDColor]
['Custom CSS', CustomCSS]
['Linkify', Linkify]
['Reveal Spoilers', RemoveSpoilers]
['Resurrect Quotes', Quotify]
['Filter', Filter]
['Thread Hiding Buttons', ThreadHiding]
['Reply Hiding Buttons', PostHiding]
['Recursive', Recursive]
['Strike-through Quotes', QuoteStrikeThrough]
['Quick Reply', QR]
['Menu', Menu]
['Report Link', ReportLink]
['Thread Hiding (Menu)', ThreadHiding.menu]
['Reply Hiding (Menu)', PostHiding.menu]
['Delete Link', DeleteLink]
['Filter (Menu)', Filter.menu]
['Download Link', DownloadLink]
['Archive Link', ArchiveLink]
['Quote Inlining', QuoteInline]
['Quote Previewing', QuotePreview]
['Quote Backlinks', QuoteBacklink]
['Mark Quotes of You', QuoteYou]
['Mark OP Quotes', QuoteOP]
['Mark Cross-thread Quotes', QuoteCT]
['Anonymize', Anonymize]
['Time Formatting', Time]
['Relative Post Dates', RelativeDates]
['File Info Formatting', FileInfo]
['Fappe Tyme', FappeTyme]
['Gallery', Gallery]
['Gallery (menu)', Gallery.menu]
['Sauce', Sauce]
['Image Expansion', ImageExpand]
['Image Expansion (Menu)', ImageExpand.menu]
['Reveal Spoiler Thumbnails', RevealSpoilers]
['Image Loading', ImageLoader]
['Image Hover', ImageHover]
['Thread Expansion', ExpandThread]
['Thread Excerpt', ThreadExcerpt]
['Favicon', Favicon]
['Unread', Unread]
['Quote Threading', QuoteThreading]
['Thread Stats', ThreadStats]
['Thread Updater', ThreadUpdater]
['Thread Watcher', ThreadWatcher]
['Thread Watcher (Menu)', ThreadWatcher.menu]
['Index Navigation', Nav]
['Keybinds', Keybinds]
['Show Dice Roll', Dice]
['Banner', Banner]
['Navigate', Navigate]
]
Main.init()

310
src/General/Navigate.coffee Normal file
View File

@ -0,0 +1,310 @@
Navigate =
path: window.location.pathname
init: ->
return if g.VIEW is 'catalog' or g.BOARD.ID is 'f' or !Conf['JSON Navigation']
# blink/webkit throw a popstate on page load. Not what we want.
$.ready -> $.on window, 'popstate', Navigate.popstate
@title = -> return
Thread.callbacks.push
name: 'Navigate'
cb: @thread
Post.callbacks.push
name: 'Navigate'
cb: @post
thread: ->
return if g.VIEW is 'thread' # The reply link only exists in index view
replyLink = $ 'a.replylink', @OP.nodes.info
$.on replyLink, 'click', Navigate.navigate
post: ->
# We don't need to reload the thread inside the thread
return if g.VIEW is 'thread' and @thread.ID is g.THREADID
postlink = $ 'a[title="Highlight this post"]', @nodes.info
$.on postlink, 'click', Navigate.navigate
return unless Conf['Quote Hash Navigation']
for hashlink in $$ '.hashlink', @nodes.comment
$.on hashlink, 'click', Navigate.navigate
return
clean: ->
# Garbage collection
g.threads.forEach (thread) -> thread.collect()
QuoteBacklink.containers = {}
$.rmAll $('.board')
features: [
['Thread Excerpt', ThreadExcerpt]
['Unread Count', Unread]
['Quote Threading', QuoteThreading]
['Thread Stats', ThreadStats]
['Thread Updater', ThreadUpdater]
['Thread Expansion', ExpandThread]
]
disconnect: ->
for [name, feature] in Navigate.features
try
feature.disconnect()
catch err
errors = [] unless errors
errors.push
message: "Failed to disconnect feature #{name}."
error: err
Main.handleErrors errors if errors
return
reconnect: ->
for [name, feature] in Navigate.features
try
feature.init()
catch err
errors = [] unless errors
errors.push
message: "Failed to reconnect feature #{name}."
error: err
Main.handleErrors errors if errors
return
ready: (name, feature, condition) ->
try
feature() if condition
catch err
error = [
message: "#{name} Failed."
error: err
]
Main.handleErrors error if error
QR.generatePostableThreadsList()
updateContext: (view) ->
g.DEAD = false
unless view is g.VIEW
$.rmClass doc, g.VIEW
$.addClass doc, view
oldView = g.VIEW
g.VIEW = view
{
index: ->
return if oldView is g.VIEW
delete g.THREADID
QR.link.textContent = 'Start a Thread'
$.off d, 'ThreadUpdate', QR.statusCheck
$.on d, 'IndexRefresh', QR.generatePostableThreadsList
thread: ->
g.THREADID = +window.location.pathname.split('/')[3]
return if oldView is g.VIEW
QR.link.textContent = 'Reply to Thread'
$.on d, 'ThreadUpdate', QR.statusCheck
$.off d, 'IndexRefresh', QR.generatePostableThreadsList
}[g.VIEW]()
updateBoard: (boardID) ->
fullBoardList = $ '#full-board-list', Header.boardList
$.rmClass $('.current', fullBoardList), 'current'
$.addClass $("a[href*='/#{boardID}/']", fullBoardList), 'current'
Header.generateBoardList Conf['boardnav'].replace /(\r\n|\n|\r)/g, ' '
QR.flagsInput()
onload = (e) ->
if e.type is 'abort'
req.onloadend = null
return
return unless req.status is 200
try
for aboard in req.response.boards when aboard.board is boardID
board = aboard
break
catch err
Main.handleErrors [
message: "Navigation failed to update board name."
error: err
]
return false
return unless board
Navigate.updateTitle board
Navigate.updateSFW !!board.ws_board
req = $.ajax '//a.4cdn.org/boards.json',
onabort: onload
onloadend: onload
updateSFW: (sfw) ->
# TODO: think of a better name for this. Changes style, too.
Favicon.el.href = "//s.4cdn.org/image/favicon#{if sfw then '-ws' else ''}.ico"
$.add d.head, Favicon.el # Changing the href alone doesn't update the icon on Firefox
return if Favicon.SFW is sfw # Board SFW status hasn't changed
Favicon.SFW = sfw
Favicon.update()
findStyle = (type, base) ->
style = d.cookie.match new RegExp "\b#{type}\_style\=([^;]+);\b"
return ["#{type}_style", (if style then style[1] else base)]
style = findStyle (if sfw
['ws', 'Yotsuba B New']
else
['nws', 'Yotsuba New'])...
$.globalEval "var style_group = '#{style[0]}'"
$('link[title=switch]', d.head).href = $("link[title='#{style[1]}']", d.head).href
Main.setClass()
updateTitle: ({board, title}) ->
$.rm subtitle if subtitle = $ '.boardSubtitle'
$('.boardTitle').textContent = d.title = "/#{board}/ - #{title}"
navigate: (e) ->
return if @hostname isnt 'boards.4chan.org' or window.location.hostname is 'rs.4chan.org' or
(e and (e.shiftKey or (e.type is 'click' and e.button isnt 0))) # Not simply a left click
$.addClass Index.button, 'fa-spin'
path = @pathname.split '/'
path.shift() if path[0] is ''
[boardID, view, threadID] = path
return if view is 'catalog' or 'f' in [boardID, g.BOARD.ID]
e.preventDefault() if e
Navigate.title = -> return
delete Index.pageNum
path = @pathname
path += @hash if @hash
history.pushState null, '', path unless @id is 'popState'
Navigate.path = @pathname
if threadID
view = 'thread'
else
pageNum = view
view = 'index' # path is "/boardID/". See the problem?
if view is g.VIEW and boardID is g.BOARD.ID
Navigate.updateContext view
else # We've navigated somewhere we weren't before!
Navigate.disconnect()
Navigate.updateContext view
Navigate.clean()
Navigate.reconnect()
if boardID is g.BOARD.ID
Navigate.title = -> d.title = $('.boardTitle').textContent if view is 'index'
else
g.BOARD = new Board boardID
Navigate.title = -> Navigate.updateBoard boardID
if view is 'index'
Index.update pageNum
# Moving from index to thread or thread to thread
else
Navigate.updateSFW Favicon.SFW
{load} = Navigate
Navigate.req = $.ajax "//a.4cdn.org/#{boardID}/res/#{threadID}.json",
onabort: load
onloadend: load
setTimeout (->
if Navigate.req and !Navigate.notice
Navigate.notice = new Notice 'info', 'Loading thread...'
), 3 * $.SECOND
load: (e) ->
$.rmClass Index.button, 'fa-spin'
{req, notice} = Navigate
notice?.close()
delete Navigate.req
delete Navigate.notice
if e.type is 'abort' or req.status isnt 200
req.onloadend = null
new Notice 'warning', "Failed to load thread.#{if req.status then " #{req.status}" else ''}"
return
Navigate.title()
try
Navigate.parse req.response.posts
catch err
console.error 'Navigate failure:'
console.log err
# network error or non-JSON content for example.
if notice
notice.setType 'error'
notice.el.lastElementChild.textContent = 'Navigation Failed.'
setTimeout notice.close, 2 * $.SECOND
else
new Notice 'error', 'Navigation Failed.', 2
return
parse: (data) ->
board = g.BOARD
Navigate.threadRoot = threadRoot = Build.thread board, OP = data.shift(), true
thread = new Thread OP.no, board
posts = []
errors = null
makePost = (postNode) ->
try
posts.push new Post postNode, thread, board
catch err
# Skip posts that we failed to parse.
errors = [] unless errors
errors.push
message: "Parsing of Post No.#{thread.ID} failed. Post will be skipped."
error: err
makePost $('.opContainer', threadRoot)
for obj in data
post = Build.postFromObject obj, board
makePost post
$.add threadRoot, post
Main.handleErrors errors if errors
Main.callbackNodes Thread, [thread]
Main.callbackNodes Post, posts
Navigate.ready 'Quote Threading', QuoteThreading.force, Conf['Quote Threading'] and not Conf['Unread Count']
Navigate.buildThread()
Header.hashScroll.call window
buildThread: ->
board = $ '.board'
$.rmAll board
$.add board, [Navigate.threadRoot, $.el 'hr']
if Conf['Unread Count']
Navigate.ready 'Unread Count', Unread.ready, Conf['Unread Count']
popstate: ->
return if window.location.pathname is Navigate.path
a = $.el 'a',
href: window.location
id: 'popState'
Navigate.navigate.call a

View File

@ -9,19 +9,6 @@ Settings =
Header.addShortcut link
$.get 'previousversion', null, (item) ->
if previous = item['previousversion']
return if previous is g.VERSION
changelog = '<%= meta.repo %>blob/<%= meta.mainBranch %>/CHANGELOG.md'
el = $.el 'span',
innerHTML: "<%= meta.name %> has been updated to <a href='#{changelog}' target=_blank>version #{g.VERSION}</a>."
if Conf['Show Updated Notifications']
new Notice 'info', el, 30
else
$.on d, '4chanXInitFinished', Settings.open
$.set 'previousversion', g.VERSION
Settings.addSection 'Main', Settings.main
Settings.addSection 'Filter', Settings.filter
Settings.addSection 'Sauce', Settings.sauce
@ -37,7 +24,6 @@ Settings =
localStorage.setItem '4chan-settings', JSON.stringify settings
open: (openSection) ->
$.off d, '4chanXInitFinished', Settings.open
return if Settings.dialog
$.event 'CloseMenu'
@ -53,6 +39,7 @@ Settings =
$.on $('.export', Settings.dialog), 'click', Settings.export
$.on $('.import', Settings.dialog), 'click', Settings.import
$.on $('.reset', Settings.dialog), 'click', Settings.reset
$.on $('input', Settings.dialog), 'change', Settings.onImport
links = []
@ -124,56 +111,39 @@ Settings =
div = $.el 'div',
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) ->
for ID, board of item.hiddenThreads.boards
$.get {hiddenThreads: {}, hiddenPosts: {}}, ({hiddenThreads, hiddenPosts}) ->
hiddenNum = 0
for ID, board of hiddenThreads.boards
hiddenNum += Object.keys(board).length
for ID, board of hiddenPosts.boards
for ID, thread of board
hiddenNum++
button.textContent = "Hidden: #{hiddenNum}"
$.get 'hiddenPosts', boards: {}, (item) ->
for ID, board of item.hiddenPosts.boards
for ID, thread of board
for ID, post of thread
hiddenNum++
hiddenNum += Object.keys(thread).length
button.textContent = "Hidden: #{hiddenNum}"
$.on button, 'click', ->
@textContent = 'Hidden: 0'
$.get 'hiddenThreads', boards: {}, (item) ->
for boardID of item.hiddenThreads.boards
$.get 'hiddenThreads', {}, ({hiddenThreads}) ->
for boardID of hiddenThreads.boards
localStorage.removeItem "4chan-hide-t-#{boardID}"
$.delete ['hiddenThreads', 'hiddenPosts']
$.after $('input[name="Stubs"]', section).parentNode.parentNode, div
export: (now, data) ->
unless typeof now is 'number'
now = Date.now()
data =
version: g.VERSION
date: now
for db in DataBoard.keys
Conf[db] = boards: {}
# Make sure to export the most recent data.
$.get Conf, (Conf) ->
# XXX don't export archives.
delete Conf['archives']
data.Conf = Conf
Settings.export now, data
return
export: ->
# Make sure to export the most recent data.
$.get Conf, (Conf) ->
# XXX don't export archives.
delete Conf['archives']
Settings.downloadExport {version: g.VERSION, date: Date.now(), Conf}
downloadExport: (data) ->
a = $.el 'a',
className: 'warning'
textContent: 'Save me!'
download: "<%= meta.name %> v#{g.VERSION}-#{now}.json"
download: "<%= meta.name %> v#{g.VERSION}-#{data.date}.json"
href: "data:application/json;base64,#{btoa unescape encodeURIComponent JSON.stringify data, null, 2}"
target: '_blank'
<% if (type === 'userscript') { %>
# XXX Firefox won't let us download automatically.
p = $ '.imp-exp-result', Settings.dialog
$.rmAll p
$.add p, a
<% } else { %>
a.click()
<% } %>
a.click()
import: ->
@nextElementSibling.click()
$('input', @parentNode).click()
onImport: ->
return unless file = @files[0]
output = $('.imp-exp-result')
@ -183,8 +153,7 @@ Settings =
reader = new FileReader()
reader.onload = (e) ->
try
data = JSON.parse e.target.result
Settings.loadSettings data
Settings.loadSettings JSON.parse e.target.result
if confirm 'Import successful. Reload now?'
window.location.reload()
catch err
@ -194,6 +163,11 @@ Settings =
loadSettings: (data) ->
version = data.version.split '.'
if version[0] is '2'
convertSettings = (data, map) ->
for prevKey, newKey of map
data.Conf[newKey] = data.Conf[prevKey] if newKey
delete data.Conf[prevKey]
data
data = Settings.convertSettings data,
# General confs
'Disable 4chan\'s extension': ''
@ -265,11 +239,9 @@ Settings =
data.Conf['watchedThreads'] = boards: ThreadWatcher.convert data.Conf['WatchedThreads']
delete data.Conf['WatchedThreads']
$.set data.Conf
convertSettings: (data, map) ->
for prevKey, newKey of map
data.Conf[newKey] = data.Conf[prevKey] if newKey
delete data.Conf[prevKey]
data
reset: ->
if confirm 'Your current settings will be entirely wiped, are you sure?'
$.clear -> window.location.reload() if confirm 'Reset successful. Reload now?'
filter: (section) ->
section.innerHTML = <%= importHTML('Settings/Filter-select') %>
@ -333,20 +305,20 @@ Settings =
$.on $('input[name="Custom CSS"]', section), 'change', Settings.togglecss
$.on $.id('apply-css'), 'click', Settings.usercss
boards = {}
for name, archive of Redirect.archives
for boardID in archive.boards
data = boards[boardID] or=
archBoards = {}
for {name, boards, files, data} in Redirect.archives
for boardID in boards
o = archBoards[boardID] or=
thread: []
post: []
file: []
data.thread.push name
data.post.push name if archive.software is 'foolfuuka'
data.file.push name if boardID in archive.files
o.thread.push name
o.post.push name if data.software is 'foolfuuka'
o.file.push name if boardID in files
rows = []
boardOptions = []
for boardID in Object.keys(boards).sort() # Alphabetical order
for boardID in Object.keys(archBoards).sort() # Alphabetical order
row = $.el 'tr',
className: "board-#{boardID}"
row.hidden = boardID isnt g.BOARD.ID
@ -356,8 +328,8 @@ Settings =
value: "board-#{boardID}"
selected: boardID is g.BOARD.ID
data = boards[boardID]
$.add row, Settings.addArchiveCell boardID, data, item for item in ['thread', 'post', 'file']
o = archBoards[boardID]
$.add row, Settings.addArchiveCell boardID, o, item for item in ['thread', 'post', 'file']
rows.push row
$.add $('tbody', section), rows

View File

@ -24,6 +24,7 @@ UI = do ->
constructor: (@type) ->
# Doc here: https://github.com/MayhemYDG/4chan-x/wiki/Menu-API
$.on d, 'AddMenuEntry', @addEntry
$.on d, 'rmMenuEntry', @rmEntry
@entries = []
makeMenu: ->
@ -156,6 +157,9 @@ UI = do ->
e.preventDefault()
e.stopPropagation()
onFocus: (e) =>
e.stopPropagation()
@focus e.target
focus: (entry) ->
while focused = $.x 'parent::*/child::*[contains(@class,"focused")]', entry
$.rmClass focused, 'focused'
@ -189,13 +193,16 @@ UI = do ->
@parseEntry entry
@entries.push entry
rmEntry: (e) =>
entry = e.detail
return if entry.type isnt @type
index = @entries.indexOf entry
@entries.splice index, 1
parseEntry: (entry) ->
{el, subEntries} = entry
$.addClass el, 'entry'
$.on el, 'focus mouseover', ((e) ->
e.stopPropagation()
@focus el
).bind @
$.on el, 'focus mouseover', @onFocus
el.style.order = entry.order or 100
return unless subEntries
$.addClass el, 'has-submenu'

View File

@ -90,6 +90,11 @@ div.navLinks {
.reply > .file > .fileText {
margin: 0 20px;
}
.hashlink::before {
content: ' ';
visibility: hidden;
}
.inline + .hashlink,
[hidden] {
display: none !important;
}
@ -273,12 +278,12 @@ div.center:not(.ad-cnt) {
font-weight: bold;
}
/* 4chan X link brackets */
.brackets-wrap::after {
content: "]";
}
.brackets-wrap::before {
content: "[";
}
.brackets-wrap::after {
content: "]";
}
/* Notifications */
#notifications {
position: fixed;
@ -501,7 +506,13 @@ div.center:not(.ad-cnt) {
.summary {
text-decoration: none;
}
.index #returnlink,
.index #bottomlink,
.thread #index-last-refresh,
.thread #index-search-clear,
.thread #index-search {
display: none;
}
/* Announcement Hiding */
:root.hide-announcement #globalMessage {
@ -642,6 +653,15 @@ span.hide-announcement {
text-decoration: none;
border-bottom: 1px dashed;
}
@supports (text-decoration-style: dashed) or (-moz-text-decoration-style: dashed) {
.quotelink.forwardlink,
.backlink.forwardlink {
text-decoration: underline;
-moz-text-decoration-style: dashed;
text-decoration-style: dashed;
border-bottom: none;
}
}
.filtered {
text-decoration: underline line-through;
}
@ -777,7 +797,7 @@ span.hide-announcement {
:root.hide-original-post-form .postingMode,
:root.hide-original-post-form #togglePostForm,
#qr.autohide:not(.has-focus):not(:hover) > form,
.postingMode ~ #qr select[data-name=thread],
.thread #qr select[data-name=thread],
#file-n-submit:not(.has-file) #qr-filerm {
display: none;
}
@ -1187,7 +1207,7 @@ a:only-of-type > .remove {
left: 0px;
width: 200px;
}
.export, .import {
.export, .import, .reset {
cursor: pointer;
text-decoration: none !important;
}

View File

@ -1,4 +1,6 @@
[<a href="./catalog">Catalog</a>]&nbsp;
[<time id="index-last-refresh" title="Last index refresh">...</time>]&nbsp;
<span class=brackets-wrap id=returnlink><a href=.././>Return</a></span>
<span class=brackets-wrap id=cataloglink><a href=javascript:;>Catalog</a></span>
<span class=brackets-wrap id=bottomlink><a href="#bottom">Bottom</a></span>
<span class=brackets-wrap id="index-last-refresh"><time title="Last index refresh">...</time></span>
<input type="search" id="index-search" class="field" placeholder="Search">
<a id="index-search-clear" href="javascript:;" title="Clear search">×</a>

View File

@ -65,7 +65,7 @@
</fieldset>
<fieldset>
<legend>Quick Reply Personas <span class="warning" #{if Conf['Quick Reply'] then 'hidden' else ''}>is disabled.</span></legend>
<legend>Quick Reply Personas</legend>
<textarea class=personafield name="QR.personas" class="field" spellcheck="false"></textarea>
<p>
One item per line.<br>
@ -86,6 +86,7 @@
<select name=favicon>
<option value=ferongr>ferongr</option>
<option value=xat->xat-</option>
<option value=4chanJS>4chanJS</option>
<option value=Mayhem>Mayhem</option>
<option value=Original>Original</option>
<option value=Metro>Metro</option>

View File

@ -5,6 +5,7 @@
<li><code>%TURL</code>: Thumbnail URL.</li>
<li><code>%URL</code>: Full image URL.</li>
<li><code>%MD5</code>: MD5 hash.</li>
<li><code>%name</code>: Original file name.</li>
<li><code>%board</code>: Current board.</li>
</ul>
<textarea name=sauces class=field spellcheck=false></textarea>
<textarea name=sauces class=field spellcheck=false></textarea>

View File

@ -2,13 +2,14 @@
<div class=sections-list></div>
<p class='imp-exp-result warning'></p>
<div class=credits>
<a class=export>Export</a> |
<a class=import>Import</a> |
<input type=file style='display: none;'>
<a class=export>Export</a>&nbsp|&nbsp
<a class=import>Import</a>&nbsp|&nbsp
<a class=reset>Reset Settings</a>&nbsp|&nbsp
<input type=file hidden>
<a href='<%= meta.page %>' target=_blank><%= meta.name %></a> |
<a href='<%= meta.repo %>blob/<%= meta.mainBranch %>/CHANGELOG.md' target=_blank>#{g.VERSION}</a> |
<a href='<%= meta.repo %>blob/<%= meta.mainBranch %>/README.md#reporting-bugs-and-suggestions' target=_blank>Issues</a> |
<a href=javascript:; class=close title=Close>×</a>
<a href=javascript:; class='close fa fa-times' title=Close></a>
</div>
</nav>
<div class=section-container><section></section></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 B

After

Width:  |  Height:  |  Size: 247 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 B

After

Width:  |  Height:  |  Size: 311 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 B

After

Width:  |  Height:  |  Size: 252 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 B

After

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 B

After

Width:  |  Height:  |  Size: 256 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 B

After

Width:  |  Height:  |  Size: 310 B

View File

@ -54,6 +54,8 @@ $.ajax = do ->
if whenModified
r.setRequestHeader 'If-Modified-Since', lastModified[url] if url of lastModified
$.on r, 'load', -> lastModified[url] = r.getResponseHeader 'Last-Modified'
if /\.json$/.test url
r.responseType = 'json'
$.extend r, options
$.extend r.upload, upCallbacks
r.send form
@ -113,11 +115,11 @@ $.X = (path, root) ->
# XPathResult.ORDERED_NODE_SNAPSHOT_TYPE === 7
d.evaluate path, root, null, 7, null
$.addClass = (el, className) ->
el.classList.add className
$.addClass = (el, className...) ->
el.classList.add className...
$.rmClass = (el, className) ->
el.classList.remove className
$.rmClass = (el, className...) ->
el.classList.remove className...
$.toggleClass = (el, className) ->
el.classList.toggle className
@ -125,18 +127,12 @@ $.toggleClass = (el, className) ->
$.hasClass = (el, className) ->
className in el.classList
$.rm = do ->
if 'remove' of Element::
(el) -> el.remove()
else
(el) -> el.parentNode?.removeChild el
$.rm = (el) ->
el.remove()
$.rmAll = (root) ->
# jsperf.com/emptify-element
for node in [root.childNodes...]
# HTMLSelectElement.remove !== Element.remove
root.removeChild node
return
# https://gist.github.com/MayhemYDG/8646194
root.textContent = null
$.tn = (s) ->
d.createTextNode s
@ -268,6 +264,7 @@ $.item = (key, val) ->
item
$.syncing = {}
<% if (type === 'crx') { %>
$.sync = do ->
chrome.storage.onChanged.addListener (changes) ->
@ -277,6 +274,8 @@ $.sync = do ->
return
(key, cb) -> $.syncing[key] = cb
$.desync = (key) -> delete $.syncing[key]
$.localKeys = [
# filters
'name',
@ -329,29 +328,50 @@ $.get = (key, val, cb) ->
chrome.storage.sync.get syncItems, done
$.set = do ->
items = {}
localItems = {}
items =
sync: {}
local: {}
timeout = {}
set = $.debounce $.SECOND, ->
setArea = (area) ->
data = items[area]
return if !Object.keys(data).length or timeout[area]
items[area] = {}
chrome.storage[area].set data, ->
if chrome.runtime.lastError
c.error chrome.runtime.lastError.message
for key, val of data when key not of items[area]
items[area][key] = val
timeout[area] = setTimeout setArea, $.MINUTE, area
return
delete timeout[area]
setAll = $.debounce $.SECOND, ->
for key in $.localKeys
if key of items
(localItems or= {})[key] = items[key]
delete items[key]
if key of items.sync
items.local[key] = items.sync[key]
delete items.sync[key]
try
chrome.storage.local.set localItems
chrome.storage.sync.set items
items = {}
localItems = {}
setArea 'local'
setArea 'sync'
catch err
c.error err.stack
(key, val) ->
if typeof key is 'string'
items[key] = val
items.sync[key] = val
else
$.extend items, key
set()
$.extend items.sync, key
setAll()
$.clear = (cb) ->
count = 2
done = ->
if chrome.runtime.lastError
c.error chrome.runtime.lastError.message
return
cb?() unless --count
chrome.storage.local.clear done
chrome.storage.sync.clear done
<% } else { %>
# http://wiki.greasespot.net/Main_Page
@ -361,6 +381,8 @@ $.sync = do ->
cb JSON.parse(newValue), key
(key, cb) -> $.syncing[g.NAMESPACE + key] = cb
$.desync = (key) -> delete $.syncing[g.NAMESPACE + key]
$.delete = (keys) ->
unless keys instanceof Array
keys = [keys]
@ -397,6 +419,9 @@ $.set = do ->
for key, val of keys
set key, val
return
$.clear = (cb) ->
$.delete GM_listValues().map (key) -> key.replace g.NAMESPACE, ''
cb?()
<% } %>
$$ = (selector, root=d.body) ->

View File

@ -2,7 +2,7 @@ class Board
toString: -> @ID
constructor: (@ID) ->
@threads = {}
@posts = {}
@threads = new SimpleDict
@posts = new SimpleDict
g.boards[@] = @

View File

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

View File

@ -5,4 +5,5 @@
<%= 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/randomaccesslist.class') %>
<%= grunt.file.read('src/General/lib/randomaccesslist.class') %>
<%= grunt.file.read('src/General/lib/simpledict.class') %>

View File

@ -13,8 +13,7 @@ class DataBoard
@sync = sync
$.on d, '4chanXInitFinished', init
save: ->
$.set @key, @data
save: -> $.set @key, @data
delete: ({boardID, threadID, postID}) ->
if postID
@ -79,7 +78,7 @@ class DataBoard
return
board = @data.boards[boardID]
threads = {}
for page in JSON.parse e.target.response
for page in e.target.response
for thread in page.threads
if thread.no of board
threads[thread.no] = board[thread.no]
@ -90,3 +89,8 @@ class DataBoard
onSync: (data) =>
@data = data or boards: {}
@sync?()
disconnect: ->
$.desync @key
delete @sync
delete @data

View File

@ -1,5 +1,5 @@
class Post
@callbacks = new Callbacks()
@callbacks = new Callbacks 'Post'
toString: -> @ID
constructor: (root, @thread, @board, that={}) ->
@ -48,18 +48,13 @@ class Post
if date = $ '.dateTime', info
@nodes.date = date
@info.date = new Date date.dataset.utc * 1000
if Conf['Quick Reply']
@info.yours = QR.db.get
boardID: @board
threadID: @thread
postID: @ID
@parseComment()
@parseQuotes()
@parseFile that
@clones = []
g.posts[@fullID] = thread.posts[@] = board.posts[@] = @
g.posts.push @fullID, thread.posts.push @, board.posts.push @, @
@kill() if that.isArchived
parseComment: ->
@ -185,7 +180,7 @@ class Post
# Get quotelinks/backlinks to this post
# and paint them (Dead).
for quotelink in Get.allQuotelinksLinkingTo @ when not $.hasClass quotelink, 'deadlink'
$.add quotelink, $.tn '\u00A0(Dead)'
quotelink.textContent = quotelink.textContent + '\u00A0(Dead)'
$.addClass quotelink, 'deadlink'
return
# XXX tmp fix for 4chan's racing condition
@ -213,9 +208,9 @@ class Post
collect: ->
@kill()
delete g.posts[@fullID]
delete @thread.posts[@]
delete @board.posts[@]
g.posts.rm @fullID
@thread.posts.rm @
@board.posts.rm @
addClone: (context) ->
new Clone @, context

View File

@ -1,19 +1,36 @@
class RandomAccessList
constructor: ->
constructor: (items) ->
@length = 0
@push item for item in items if items
push: (item) ->
{ID} = item
push: (data) ->
{ID} = data
ID or= data.id
return if @[ID]
{last} = @
@[ID] = item =
prev: last
next: null
data: data
ID: ID
item.prev = last
@[ID] = item
@last = if last
last.next = item
else
@first = item
@length++
before: (root, item) ->
return if item.next is root
@rmi item
{prev} = root
root.prev = item
item.next = root
item.prev = prev
prev.next = item if prev
after: (root, item) ->
return if item.prev is root
@ -23,8 +40,8 @@ class RandomAccessList
root.next = item
item.prev = root
item.next = next
next.prev = item
next.prev = item if next
prepend: (item) ->
{first} = @
return if item is first or not @[item.ID]
@ -36,6 +53,11 @@ class RandomAccessList
shift: ->
@rm @first.ID
order: ->
order = [item = @first]
order.push item while item = item.next
order
rm: (ID) ->
item = @[ID]

View File

@ -0,0 +1,16 @@
class SimpleDict
constructor: ->
@keys = []
push: (key, data) ->
key = "#{key}"
@keys.push key unless @[key]
@[key] = data
rm: (key) ->
key = "#{key}"
if (i = @keys.indexOf key) isnt -1
@keys.splice i, 1
delete @[key]
forEach: (fn) -> fn @[key] for key in [@keys...]

View File

@ -1,16 +1,16 @@
class Thread
@callbacks = new Callbacks()
@callbacks = new Callbacks 'Thread'
toString: -> @ID
constructor: (@ID, @board) ->
@fullID = "#{@board}.#{@ID}"
@posts = {}
@posts = new SimpleDict
@isSticky = false
@isClosed = false
@postLimit = false
@fileLimit = false
g.threads[@fullID] = board.threads[@] = @
g.threads.push @fullID, board.threads.push @, @
setPage: (pageNum) ->
icon = $ '.page-num', @OP.nodes.post
@ -44,7 +44,6 @@ class Thread
@timeOfDeath = Date.now()
collect: ->
for postID, post in @posts
post.collect()
delete g.threads[@fullID]
delete @board.threads[@]
@posts.forEach (post) -> post.collect()
g.threads.rm @fullID
@board.threads.rm @

View File

@ -14,6 +14,7 @@
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM_openInTab
// @grant GM_xmlhttpRequest
// @run-at document-start

View File

@ -201,7 +201,8 @@ Gallery =
$.ajax "//api.4chan.org/#{post.board}/res/#{post.thread}.json", onload: ->
return if @status isnt 200
i = 0
while postObj = JSON.parse(@response).posts[i++]
{posts} = @response
while postObj = posts[i++]
break if postObj.no is post.ID
unless postObj.no
return post.kill()

View File

@ -30,8 +30,18 @@ ImageExpand =
return if e.shiftKey or e.altKey or e.ctrlKey or e.metaKey or e.button isnt 0
e.preventDefault()
ImageExpand.toggle Get.postFromNode @
toggleAll: ->
$.event 'CloseMenu'
toggle = (post) ->
{file} = post
return 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 Header.getTopOf(file.thumb) < 0)
return
$.queueTask func, post
if ImageExpand.on = $.hasClass ImageExpand.EAI, 'expand-all-shortcut'
ImageExpand.EAI.className = 'contract-all-shortcut fa fa-compress'
ImageExpand.EAI.title = 'Contract All Images'
@ -40,16 +50,12 @@ ImageExpand =
ImageExpand.EAI.className = 'expand-all-shortcut fa fa-expand'
ImageExpand.EAI.title = 'Expand All Images'
func = ImageExpand.contract
for ID, post of g.posts
for post in [post].concat post.clones
{file} = post
continue unless file and file.isImage and doc.contains post.nodes.root
if ImageExpand.on and
(!Conf['Expand spoilers'] and file.isSpoiler or
Conf['Expand from here'] and Header.getTopOf(file.thumb) < 0)
continue
$.queueTask func, post
return
g.posts.forEach (post) ->
toggle post
toggle post for post in post.clones
return
setFitness: ->
(if @checked then $.addClass else $.rmClass) doc, @name.toLowerCase().replace /\s+/g, '-'
@ -147,10 +153,19 @@ ImageExpand =
return
timeoutID = setTimeout ImageExpand.expand, 10000, post
<% if (type === 'crx') { %>
$.ajax @src,
onloadend: ->
return if @status isnt 404
clearTimeout timeoutID
post.kill true
,
type: 'head'
<% } else { %>
# XXX CORS for i.4cdn.org WHEN?
$.ajax "//a.4cdn.org/#{post.board}/res/#{post.thread}.json", onload: ->
return if @status isnt 200
for postObj in JSON.parse(@response).posts
for postObj in @response.posts
break if postObj.no is post.ID
if postObj.no isnt post.ID
clearTimeout timeoutID
@ -158,6 +173,7 @@ ImageExpand =
else if postObj.filedeleted
clearTimeout timeoutID
post.kill true
<% } %>
menu:
init: ->

View File

@ -38,10 +38,19 @@ ImageHover =
return
timeoutID = setTimeout (=> @src = post.file.URL + '?' + Date.now()), 3000
<% if (type === 'crx') { %>
$.ajax @src,
onloadend: ->
return if @status isnt 404
clearTimeout timeoutID
post.kill true
,
type: 'head'
<% } else { %>
# XXX CORS for i.4cdn.org WHEN?
$.ajax "//a.4cdn.org/#{post.board}/res/#{post.thread}.json", onload: ->
return if @status isnt 200
for postObj in JSON.parse(@response).posts
for postObj in @response.posts
break if postObj.no is post.ID
if postObj.no isnt post.ID
clearTimeout timeoutID
@ -49,3 +58,4 @@ ImageHover =
else if postObj.filedeleted
clearTimeout timeoutID
post.kill true
<% } %>

View File

@ -7,6 +7,10 @@ ImageLoader =
name: 'Image Replace'
cb: @node
Thread.callbacks.push
name: 'Image Replace'
cb: @thread
return unless Conf['Image Prefetching'] and g.VIEW is 'thread'
prefetch = $.el 'label',
@ -19,6 +23,9 @@ ImageLoader =
type: 'header'
el: prefetch
order: 104
thread: ->
ImageLoader.thread = @
node: ->
return if @isClone or @isHidden or @thread.isHidden or !@file?.isImage
@ -38,5 +45,5 @@ ImageLoader =
toggle: ->
enabled = Conf['prefetch'] = @checked
if enabled
ImageLoader.node.call post for id, post of g.threads["#{g.BOARD.ID}.#{g.THREADID}"].posts
ImageLoader.thread.posts.forEach ImageLoader.node.call
return

View File

@ -15,19 +15,17 @@ Sauce =
name: 'Sauce'
cb: @node
createSauceLink: (link) ->
link = link.replace /%(T?URL|MD5|board)/ig, (parameter) ->
switch parameter
when '%TURL'
"' + encodeURIComponent(post.file.thumbURL) + '"
when '%URL'
"' + encodeURIComponent(post.file.URL) + '"
when '%MD5'
"' + encodeURIComponent(post.file.MD5) + '"
when '%board'
"' + encodeURIComponent(post.board) + '"
else
parameter
link = link.replace /%(T?URL|MD5|board|name)/g, (parameter) ->
return (if type = {
'%TURL': 'post.file.thumbURL'
'%URL': 'post.file.URL'
'%MD5': 'post.file.MD5'
'%board': 'post.board'
'%name': 'post.file.name'
}[parameter]
"' + encodeURIComponent(#{type}) + '"
else
parameter)
text = if m = link.match(/;text:(.+)$/) then m[1] else link.match(/(\w+)\.\w+\//)[1]
link = link.replace /;text:.+$/, ''
Function 'post', 'a', """

View File

@ -189,8 +189,10 @@ Linkify =
embed.dataset.title = title[0]
else
try
$.cache service.api(uid), ->
title = Linkify.cb.title @, data
$.cache service.api(uid),
-> title = Linkify.cb.title @, data
,
responseType: 'json'
catch err
if link
link.innerHTML = "[#{key}] <span class=warning>Title Link Blocked</span> (are you using NoScript?)</a>"
@ -241,7 +243,7 @@ Linkify =
service = Linkify.types[key].title
switch response.status
when 200, 304
text = "#{service.text JSON.parse response.responseText}"
text = "#{service.text response.response}"
if Conf['Embedding']
embed.dataset.title = text
when 404
@ -310,7 +312,7 @@ Linkify =
$.cache "https://mediacru.sh/#{a.dataset.uid}.json", ->
{status} = @
return div.innerHTML = "ERROR #{status}" unless status in [200, 304]
{files} = JSON.parse @response
{files} = @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
if file.type is type

View File

@ -1,10 +1,9 @@
PSAHiding =
init: ->
return if !Conf['Announcement Hiding']
$.addClass doc, 'hide-announcement'
$.on d, '4chanXInitFinished', @setup
setup: ->
$.off d, '4chanXInitFinished', PSAHiding.setup

View File

@ -1,21 +1,26 @@
ExpandThread =
statuses: {}
init: ->
return if g.VIEW isnt 'index' or !Conf['Thread Expansion']
@statuses = {}
return if g.VIEW is 'thread' or !Conf['Thread Expansion']
$.on d, 'IndexRefresh', @onIndexRefresh
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
onIndexRefresh: ->
disconnect: (refresh) ->
return if g.VIEW is 'thread' or !Conf['Thread Expansion']
for threadID, status of ExpandThread.statuses
status.req?.abort()
delete ExpandThread.statuses[threadID]
for threadID, thread of g.BOARD.threads
$.off d, 'IndexRefresh', @onIndexRefresh unless refresh
onIndexRefresh: ->
ExpandThread.disconnect true
g.BOARD.threads.forEach (thread) ->
ExpandThread.setButton thread
return
text: (status, posts, files) ->
"#{status} #{posts} post#{if posts > 1 then 's' else ''}" +
@ -72,13 +77,13 @@ ExpandThread =
a.textContent = "Error #{req.statusText} (#{req.status})"
return
data = JSON.parse(req.response).posts
Build.spoilerRange[thread.board] = data.shift().custom_spoiler
Build.spoilerRange[thread.board] = req.response.posts[0].custom_spoiler
posts = []
postsRoot = []
filesCount = 0
for postData in data
for postData in req.response.posts
continue if postData.no is thread.ID
if post = thread.posts[postData.no]
filesCount++ if 'file' of post
postsRoot.push post.nodes.root

View File

@ -1,64 +1,82 @@
Favicon =
init: ->
$.ready ->
Favicon.el = $ 'link[rel="shortcut icon"]', d.head
Favicon.el.type = 'image/x-icon'
{href} = Favicon.el
Favicon.SFW = /ws\.ico$/.test href
Favicon.default = href
Favicon.switch()
$.asap (-> Favicon.el = $ 'link[rel="shortcut icon"]', d.head), Favicon.initAsap
initAsap: ->
Favicon.el.type = 'image/x-icon'
{href} = Favicon.el
Favicon.SFW = /ws\.ico$/.test href
Favicon.default = href
Favicon.switch()
switch: ->
if Favicon.SFW
Favicon.default = 'https://s.4cdn.org/image/favicon-ws.ico'
items = {
ferongr: [
'<%= grunt.file.read("src/General/img/favicons/ferongr/unreadDead.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/ferongr/unreadDeadY.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/ferongr/unreadSFW.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/ferongr/unreadSFWY.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/ferongr/unreadNSFW.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/ferongr/unreadNSFWY.png", {encoding: "base64"}) %>'
]
'xat-': [
'<%= grunt.file.read("src/General/img/favicons/xat-/unreadDead.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/xat-/unreadDeadY.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/xat-/unreadSFW.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/xat-/unreadSFWY.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/xat-/unreadNSFW.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/xat-/unreadNSFWY.png", {encoding: "base64"}) %>'
]
Mayhem: [
'<%= grunt.file.read("src/General/img/favicons/Mayhem/unreadDead.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/Mayhem/unreadDeadY.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/Mayhem/unreadSFW.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/Mayhem/unreadSFWY.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/Mayhem/unreadNSFW.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/Mayhem/unreadNSFWY.png", {encoding: "base64"}) %>'
]
'4chanJS': [
'<%= grunt.file.read("src/General/img/favicons/4chanJS/unreadDead.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/4chanJS/unreadDeadY.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/4chanJS/unreadSFW.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/4chanJS/unreadSFWY.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/4chanJS/unreadNSFW.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/4chanJS/unreadNSFWY.png", {encoding: "base64"}) %>'
]
Original: [
'<%= grunt.file.read("src/General/img/favicons/Original/unreadDead.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/Original/unreadDeadY.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/Original/unreadSFW.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/Original/unreadSFWY.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/Original/unreadNSFW.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/Original/unreadNSFWY.png", {encoding: "base64"}) %>'
]
'Metro': [
'<%= grunt.file.read("src/General/img/favicons/Metro/unreadDead.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/Metro/unreadDeadY.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/Metro/unreadSFW.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/Metro/unreadSFWY.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/Metro/unreadNSFW.png", {encoding: "base64"}) %>'
'<%= grunt.file.read("src/General/img/favicons/Metro/unreadNSFWY.png", {encoding: "base64"}) %>'
]
}[Conf['favicon']]
f = Favicon
t = 'data:image/png;base64,'
i = 0
while items[i]
items[i] = t + items[i++]
[f.unreadDead, funreadDeadY, f.unreadSFW, f.unreadSFWY, f.unreadNSFW, f.unreadNSFWY] = items
f.update()
update: ->
if @SFW
@unread = @unreadSFW
@unreadY = @unreadSFWY
else
Favicon.default = 'https://s.4cdn.org/image/favicon.ico'
switch Conf['favicon']
when 'ferongr'
Favicon.unreadDead = 'data:image/gif;base64,<%= grunt.file.read("src/General/img/favicons/ferongr/unreadDead.gif", {encoding: "base64"}) %>'
Favicon.unreadDeadY = 'data:image/png;base64,<%= grunt.file.read("src/General/img/favicons/ferongr/unreadDeadY.png", {encoding: "base64"}) %>'
Favicon.unreadSFW = 'data:image/gif;base64,<%= grunt.file.read("src/General/img/favicons/ferongr/unreadSFW.gif", {encoding: "base64"}) %>'
Favicon.unreadSFWY = 'data:image/png;base64,<%= grunt.file.read("src/General/img/favicons/ferongr/unreadSFWY.png", {encoding: "base64"}) %>'
Favicon.unreadNSFW = 'data:image/gif;base64,<%= grunt.file.read("src/General/img/favicons/ferongr/unreadNSFW.gif", {encoding: "base64"}) %>'
Favicon.unreadNSFWY = 'data:image/png;base64,<%= grunt.file.read("src/General/img/favicons/ferongr/unreadNSFWY.png", {encoding: "base64"}) %>'
when 'xat-'
Favicon.unreadDead = 'data:image/png;base64,<%= grunt.file.read("src/General/img/favicons/xat-/unreadDead.png", {encoding: "base64"}) %>'
Favicon.unreadDeadY = 'data:image/png;base64,<%= grunt.file.read("src/General/img/favicons/xat-/unreadDeadY.png", {encoding: "base64"}) %>'
Favicon.unreadSFW = 'data:image/png;base64,<%= grunt.file.read("src/General/img/favicons/xat-/unreadSFW.png", {encoding: "base64"}) %>'
Favicon.unreadSFWY = 'data:image/png;base64,<%= grunt.file.read("src/General/img/favicons/xat-/unreadSFWY.png", {encoding: "base64"}) %>'
Favicon.unreadNSFW = 'data:image/png;base64,<%= grunt.file.read("src/General/img/favicons/xat-/unreadNSFW.png", {encoding: "base64"}) %>'
Favicon.unreadNSFWY = 'data:image/png;base64,<%= grunt.file.read("src/General/img/favicons/xat-/unreadNSFWY.png", {encoding: "base64"}) %>'
when 'Mayhem'
Favicon.unreadDead = 'data:image/png;base64,<%= grunt.file.read("src/General/img/favicons/Mayhem/unreadDead.png", {encoding: "base64"}) %>'
Favicon.unreadDeadY = 'data:image/png;base64,<%= grunt.file.read("src/General/img/favicons/Mayhem/unreadDeadY.png", {encoding: "base64"}) %>'
Favicon.unreadSFW = 'data:image/png;base64,<%= grunt.file.read("src/General/img/favicons/Mayhem/unreadSFW.png", {encoding: "base64"}) %>'
Favicon.unreadSFWY = 'data:image/png;base64,<%= grunt.file.read("src/General/img/favicons/Mayhem/unreadSFWY.png", {encoding: "base64"}) %>'
Favicon.unreadNSFW = 'data:image/png;base64,<%= grunt.file.read("src/General/img/favicons/Mayhem/unreadNSFW.png", {encoding: "base64"}) %>'
Favicon.unreadNSFWY = 'data:image/png;base64,<%= grunt.file.read("src/General/img/favicons/Mayhem/unreadNSFWY.png", {encoding: "base64"}) %>'
when 'Original'
Favicon.unreadDead = 'data:image/gif;base64,<%= grunt.file.read("src/General/img/favicons/Original/unreadDead.gif", {encoding: "base64"}) %>'
Favicon.unreadDeadY = 'data:image/png;base64,<%= grunt.file.read("src/General/img/favicons/Original/unreadDeadY.png", {encoding: "base64"}) %>'
Favicon.unreadSFW = 'data:image/gif;base64,<%= grunt.file.read("src/General/img/favicons/Original/unreadSFW.gif", {encoding: "base64"}) %>'
Favicon.unreadSFWY = 'data:image/png;base64,<%= grunt.file.read("src/General/img/favicons/Original/unreadSFWY.png", {encoding: "base64"}) %>'
Favicon.unreadNSFW = 'data:image/gif;base64,<%= grunt.file.read("src/General/img/favicons/Original/unreadNSFW.gif", {encoding: "base64"}) %>'
Favicon.unreadNSFWY = 'data:image/png;base64,<%= grunt.file.read("src/General/img/favicons/Original/unreadNSFWY.png", {encoding: "base64"}) %>'
when 'Metro'
Favicon.unreadDead = 'data:image/gif;base64,<%= grunt.file.read("src/General/img/favicons/Metro/unreadDead.png", {encoding: "base64"}) %>'
Favicon.unreadDeadY = 'data:image/png;base64,<%= grunt.file.read("src/General/img/favicons/Metro/unreadDeadY.png", {encoding: "base64"}) %>'
Favicon.unreadSFW = 'data:image/gif;base64,<%= grunt.file.read("src/General/img/favicons/Metro/unreadSFW.png", {encoding: "base64"}) %>'
Favicon.unreadSFWY = 'data:image/png;base64,<%= grunt.file.read("src/General/img/favicons/Metro/unreadSFWY.png", {encoding: "base64"}) %>'
Favicon.unreadNSFW = 'data:image/gif;base64,<%= grunt.file.read("src/General/img/favicons/Metro/unreadNSFW.png", {encoding: "base64"}) %>'
Favicon.unreadNSFWY = 'data:image/png;base64,<%= grunt.file.read("src/General/img/favicons/Metro/unreadNSFWY.png", {encoding: "base64"}) %>'
if Favicon.SFW
Favicon.default = 'data:image/png;base64,<%= grunt.file.read("src/General/img/favicons/Metro/readSFW.png", {encoding: "base64"}) %>'
else
Favicon.default = 'data:image/png;base64,<%= grunt.file.read("src/General/img/favicons/Metro/readNSFW.png", {encoding: "base64"}) %>'
if Favicon.SFW
Favicon.unread = Favicon.unreadSFW
Favicon.unreadY = Favicon.unreadSFWY
else
Favicon.unread = Favicon.unreadNSFW
Favicon.unreadY = Favicon.unreadNSFWY
@unread = @unreadNSFW
@unreadY = @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"}) %>'

View File

@ -5,5 +5,7 @@ ThreadExcerpt =
Thread.callbacks.push
name: 'Thread Excerpt'
cb: @node
node: ->
d.title = Get.threadExcerpt @
node: -> d.title = Get.threadExcerpt @
disconnect: ->
return if g.VIEW isnt 'thread' or !Conf['Thread Excerpt']
Thread.callbacks.disconnect 'Thread Excerpt'

View File

@ -9,11 +9,11 @@ ThreadStats =
title: 'Post Count / File Count' + (if Conf["Page Count in Stats"] then " / Page Count" else "")
$.ready ->
Header.addShortcut sc
else
else
@dialog = sc = UI.dialog 'thread-stats', 'bottom: 0px; right: 0px;',
"<div class=move title='Post Count / File Count#{if Conf["Page Count in Stats"] then " / Page Count" else ""}'><span id=post-count>0</span> / <span id=file-count>0</span>#{if Conf["Page Count in Stats"] then " / <span id=page-count>0</span>" else ""}</div>"
$.ready =>
$.add d.body, sc
$.ready =>
$.add d.body, sc
@postCountEl = $ '#post-count', sc
@fileCountEl = $ '#file-count', sc
@ -26,7 +26,7 @@ ThreadStats =
node: ->
postCount = 0
fileCount = 0
for ID, post of @posts
@posts.forEach (post) ->
postCount++
fileCount++ if post.file
ThreadStats.thread = @
@ -34,6 +34,25 @@ ThreadStats =
ThreadStats.update postCount, fileCount
$.on d, 'ThreadUpdate', ThreadStats.onUpdate
disconnect: ->
return if g.VIEW isnt 'thread' or !Conf['Thread Stats']
if Conf['Updater and Stats in Header']
Header.rmShortcut @dialog
else
$.rm d.body, sc
clearTimeout @timeout # a possible race condition might be that this won't clear in time, but the resulting error will prevent issues anyways.
delete @timeout
delete @thread
delete @postCountEl
delete @fileCountEl
delete @pageCountEl
Thread.callbacks.disconnect 'Thread Stats'
$.off d, 'ThreadUpdate', ThreadStats.onUpdate
onUpdate: (e) ->
return if e.detail[404]
{postCount, fileCount} = e.detail
@ -48,20 +67,18 @@ ThreadStats =
fetchPage: ->
return if !Conf["Page Count in Stats"]
if ThreadStats.thread.isDead
if ThreadStats.thread.isDead
ThreadStats.pageCountEl.textContent = 'Dead'
$.addClass ThreadStats.pageCountEl, 'warning'
return
setTimeout ThreadStats.fetchPage, 2 * $.MINUTE
ThreadStats.timeout = setTimeout ThreadStats.fetchPage, 2 * $.MINUTE
$.ajax "//a.4cdn.org/#{ThreadStats.thread.board}/threads.json", onload: ThreadStats.onThreadsLoad,
whenModified: true
onThreadsLoad: ->
return unless Conf["Page Count in Stats"] and @status is 200
pages = JSON.parse @response
for page in pages
for thread in page.threads
if thread.no is ThreadStats.thread.ID
ThreadStats.pageCountEl.textContent = page.page
(if page.page is pages.length - 1 then $.addClass else $.rmClass) ThreadStats.pageCountEl, 'warning'
return
for page in @response
for thread in page.threads when thread.no is ThreadStats.thread.ID
ThreadStats.pageCountEl.textContent = page.page
(if page.page is @response.length - 1 then $.addClass else $.rmClass) ThreadStats.pageCountEl, 'warning'
return

View File

@ -22,8 +22,8 @@ ThreadUpdater =
@status = $ '#update-status', sc
@isUpdating = Conf['Auto Update']
$.on @timer, 'click', ThreadUpdater.update
$.on @status, 'click', ThreadUpdater.update
$.on @timer, 'click', @update
$.on @status, 'click', @update
subEntries = []
for name, conf of Config.updater.checkbox
@ -34,20 +34,20 @@ ThreadUpdater =
input = el.firstElementChild
$.on input, 'change', $.cb.checked
if input.name is 'Scroll BG'
$.on input, 'change', ThreadUpdater.cb.scrollBG
ThreadUpdater.cb.scrollBG()
$.on input, 'change', @cb.scrollBG
@cb.scrollBG()
else if input.name is 'Auto Update'
$.on input, 'change', ThreadUpdater.cb.update
$.on input, 'change', @cb.update
subEntries.push el: el
settings = $.el 'span',
@settings = $.el 'span',
innerHTML: '<a href=javascript:;>Interval</a>'
$.on settings, 'click', @intervalShortcut
$.on @settings, 'click', @intervalShortcut
subEntries.push el: settings
subEntries.push el: @settings
$.event 'AddMenuEntry',
$.event 'AddMenuEntry', @entry =
type: 'header'
el: $.el 'span',
textContent: 'Updater'
@ -57,6 +57,40 @@ ThreadUpdater =
Thread.callbacks.push
name: 'Thread Updater'
cb: @node
disconnect: ->
return if g.VIEW isnt 'thread' or !Conf['Thread Updater']
$.off @timer, 'click', @update
$.off @status, 'click', @update
clearTimeout @timeoutID if @timeoutID
for entry in @entry.subEntries
{el} = entry
input = el.firstElementChild
$.off input, 'change', $.cb.checked
$.off input, 'change', @cb.scrollBG
$.off input, 'change', @cb.update
$.off @settings, 'click', @intervalShortcut
$.off window, 'online offline', @cb.online
$.off d, 'QRPostSuccessful', @cb.checkpost
$.off d, 'visibilitychange', @cb.visibility
@set 'timer', null
@set 'status', 'Offline'
$.event 'rmMenuEntry', @entry
if Conf['Updater and Stats in Header']
Header.rmShortcut @dialog
else
$.rmClass doc, 'float'
$.rm @dialog
delete @[name] for name in ['checkPostCount', 'timer', 'status', 'isUpdating', 'entry', 'dialog', 'thread', 'root', 'lastPost', 'outdateCount', 'online', 'seconds', 'timeoutID']
Thread.callbacks.disconnect 'Thread Updater'
node: ->
ThreadUpdater.thread = @
@ -124,7 +158,7 @@ ThreadUpdater =
switch req.status
when 200
g.DEAD = false
ThreadUpdater.parse JSON.parse(req.response).posts
ThreadUpdater.parse req.response.posts
ThreadUpdater.setInterval()
when 404
g.DEAD = true
@ -256,11 +290,11 @@ ThreadUpdater =
deletedFiles = []
# Check for deleted posts/files.
for ID, post of ThreadUpdater.thread.posts
ThreadUpdater.thread.posts.forEach (post) ->
# XXX tmp fix for 4chan's racing condition
# giving us false-positive dead posts.
# continue if post.isDead
ID = +ID
ID = +post.ID
unless ID in index
post.kill()
@ -271,6 +305,7 @@ ThreadUpdater =
post.kill true
deletedFiles.push post
# Fetching your own posts after posting
if ThreadUpdater.postID and ThreadUpdater.postID is ID
ThreadUpdater.foundPost = true
@ -291,8 +326,7 @@ ThreadUpdater =
scroll = Conf['Auto Scroll'] and ThreadUpdater.scrollBG() and
ThreadUpdater.root.getBoundingClientRect().bottom - doc.clientHeight < 25
for key, post of posts
continue unless posts.hasOwnProperty key
for post in posts
root = post.nodes.root
if post.cb
unless post.cb()

View File

@ -180,7 +180,9 @@ ThreadWatcher =
$.rmAll list
$.add list, nodes
for threadID, thread of g.BOARD.threads
{threads} = g.BOARD
for threadID in threads.keys
thread = threads[threadID]
toggler = $ '.watch-thread-link', thread.OP.nodes.post
watched = ThreadWatcher.db.get {boardID: thread.board.ID, threadID}
helper = if watched then ['addClass', 'Unwatch'] else ['rmClass', 'Watch']

View File

@ -12,6 +12,21 @@ Unread =
name: 'Unread'
cb: @node
disconnect: ->
return if g.VIEW isnt 'thread' or !Conf['Unread Count'] and !Conf['Unread Favicon'] and !Conf['Desktop Notifications']
Unread.db.disconnect()
$.rm hr if {hr} = Unread
delete @[name] for name in ['db', 'hr', 'posts', 'postsQuotingYou', 'thread', 'title', 'lastReadPost']
$.off d, '4chanXInitFinished', @ready
$.off d, 'ThreadUpdate', @onUpdate
$.off d, 'scroll visibilitychange', @read
$.off d, 'visibilitychange', @setLine if Conf['Unread Line']
Thread.callbacks.disconnect 'Unread'
node: ->
Unread.thread = @
Unread.title = d.title
@ -26,9 +41,10 @@ Unread =
ready: ->
$.off d, '4chanXInitFinished', Unread.ready
posts = []
posts.push post for ID, post of Unread.thread.posts when post.isReply
Unread.addPosts posts
unless Conf['Quote Threading']
posts = []
Unread.thread.posts.forEach (post) -> posts.push post if post.isReply
Unread.addPosts posts
QuoteThreading.force() if Conf['Quote Threading']
Unread.scroll() if Conf['Scroll to Last Read Post']
@ -37,14 +53,15 @@ Unread =
return if (hash = location.hash.match /\d+/) and hash[0] of Unread.thread.posts
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
while root = $.x 'preceding-sibling::div[contains(@class,"replyContainer")][1]', post.data.nodes.root
break unless (post = Get.postFromRoot root).isHidden
return unless 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
{posts} = Unread.thread
{keys} = posts
{root} = posts[keys[keys.length - 1]].nodes
# Scroll to the target unless we scrolled past it.
Header.scrollTo root, down if Header.getBottomOf(root) < 0
@ -75,16 +92,15 @@ Unread =
threadID: post.thread.ID
postID: ID
}
Unread.posts.push post unless post.prev or post.next
Unread.posts.push post
Unread.addPostQuotingYou post
if Conf['Unread Line']
# Force line on visible threads if there were no unread posts previously.
Unread.setLine Unread.posts.first in posts
Unread.setLine Unread.posts.first?.data in posts
Unread.read()
Unread.update()
addPostQuotingYou: (post) ->
return unless QR.db
for quotelink in post.nodes.quotelinks when QR.db.get Get.postDataFromLink quotelink
Unread.postsQuotingYou.push post
Unread.openNotification post
@ -110,16 +126,20 @@ Unread =
onUpdate: (e) ->
if e.detail[404]
Unread.update()
else
else if !Conf['Quote Threading']
Unread.addPosts e.detail.newPosts
else
Unread.read()
Unread.update()
readSinglePost: (post) ->
{ID} = post
return unless Unread.posts[ID]
if post is Unread.posts.first
{posts} = Unread
return unless posts[ID]
if post is posts.first
Unread.lastReadPost = ID
Unread.saveLastReadPost()
Unread.posts.rm ID
posts.rm ID
if (i = Unread.postsQuotingYou.indexOf post) isnt -1
Unread.postsQuotingYou.splice i, 1
Unread.update()
@ -135,12 +155,16 @@ Unread =
{posts} = Unread
while post = posts.first
break unless Header.getBottomOf(post.nodes.root) > -1 # post is not completely read
{ID} = post
break unless Header.getBottomOf(post.data.nodes.root) > -1 # post is not completely read
{ID, data} = post
posts.rm ID
if Conf['Mark Quotes of You'] and post.info.yours
QuoteYou.lastRead = post.nodes.root
if Conf['Mark Quotes of You'] and QR.db.get {
boardID: data.board.ID
threadID: data.thread.ID
postID: ID
}
QuoteYou.lastRead = data.nodes.root
return unless ID
@ -159,8 +183,8 @@ Unread =
setLine: (force) ->
return unless d.hidden or force is true
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
if $.x 'preceding-sibling::div[contains(@class,"replyContainer")]', post.data.nodes.root # not the first reply
$.before post.data.nodes.root, Unread.hr
update: <% if (type === 'crx') { %>(dontrepeat) <% } %>->
count = Unread.posts.length

View File

@ -1,5 +1,6 @@
QR =
mimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'application/pdf', 'application/x-shockwave-flash', '']
init: ->
return if !Conf['Quick Reply']
@ -46,6 +47,8 @@ QR =
link = $.el 'h1',
innerHTML: "<a href=javascript:; class='qr-link'>#{if g.VIEW is 'thread' then 'Reply to Thread' else 'Start a Thread'}</a>"
className: "qr-link-container"
QR.link = link.firstElementChild
$.on link.firstChild, 'click', ->
$.event 'CloseMenu'
@ -67,15 +70,20 @@ QR =
$.on d, 'dragover', QR.dragOver
$.on d, 'drop', QR.dropFile
$.on d, 'dragstart dragend', QR.drag
switch g.VIEW
when 'index'
{
catalog: ->
QR.open() if Conf["Persistent QR"]
index: ->
$.on d, 'IndexRefresh', QR.generatePostableThreadsList
when 'thread'
$.on d, 'ThreadUpdate', ->
if g.DEAD
QR.abort()
else
QR.status()
thread: ->
$.on d, 'ThreadUpdate', QR.statusCheck
}[g.VIEW]()
statusCheck: ->
if g.DEAD
QR.abort()
else
QR.status()
node: ->
$.on $('a[title="Quote this post"]', @nodes.info), 'click', QR.quote
@ -385,7 +393,7 @@ QR =
return unless QR.nodes
list = QR.nodes.thread
options = [list.firstChild]
for thread of g.BOARD.threads
for thread in g.BOARD.threads.keys
options.push $.el 'option',
value: thread
textContent: "Thread No.#{thread}"
@ -430,8 +438,7 @@ QR =
status: '[type=submit]'
fileInput: '[type=file]'
}
# Allow only this board's supported files.
nodes.fileInput.max = $('input[name=MAX_FILE_SIZE]').value
QR.spoiler = !!$ 'input[name=spoiler]'
@ -454,11 +461,8 @@ QR =
"""
nodes.flashTag.dataset.default = '4'
$.add nodes.form, nodes.flashTag
if flagSelector = $ '.flagSelector'
nodes.flag = flagSelector.cloneNode true
nodes.flag.dataset.name = 'flag'
nodes.flag.dataset.default = '0'
$.add nodes.form, nodes.flag
QR.flagsInput()
$.on nodes.filename.parentNode, 'click keydown', QR.openFileInput
@ -509,6 +513,54 @@ QR =
# Use it to extend the QR's functionalities, or for XTRM RICE.
$.event 'QRDialogCreation', null, dialog
flags: ->
fn = (val) -> $.el 'option',
value: val[0]
textContent: val[1]
select = $.el 'select',
name: 'flag'
className: 'flagSelector'
$.add select, fn flag for flag in [
['0', 'None']
['US', 'American']
['KP', 'Best Korean']
['BL', 'Black Nationalist']
['CM', 'Communist']
['CF', 'Confederate']
['RE', 'Conservative']
['EU', 'European']
['GY', 'Gay']
['PC', 'Hippie']
['IL', 'Israeli']
['DM', 'Liberal']
['RP', 'Libertarian']
['MF', 'Muslim']
['NZ', 'Nazi']
['OB', 'Obama']
['PR', 'Pirate']
['RB', 'Rebel']
['TP', 'Tea Partier']
['TX', 'Texan']
['TR', 'Tree Hugger']
['WP', 'White Supremacist']
]
select
flagsInput: ->
{nodes} = QR
if nodes.flagSelector
$.rm nodes.flagSelector
delete nodes.flagSelector
if g.BOARD.ID is 'pol'
flag = QR.flags()
flag.dataset.name = 'flag'
flag.dataset.default = '0'
nodes.flag = flag
$.add nodes.form, flag
preSubmitHooks: []
submit: (e) ->
@ -589,13 +641,15 @@ QR =
responseType: 'document'
withCredentials: true
onload: QR.response
onerror: ->
# Connection error, or
# www.4chan.org/banned
onerror: (err, url, line) ->
# Connection error, or www.4chan.org/banned
delete QR.req
post.unlock()
QR.cooldown.auto = false
QR.status()
console.log err
console.log url
console.log line
QR.error $.el 'span',
innerHTML: """
4chan X encountered an error while posting.
@ -615,7 +669,7 @@ QR =
QR.req.progress = "#{Math.round e.loaded / e.total * 100}%"
QR.status()
QR.req = $.ajax $.id('postForm').parentNode.action, options, extra
QR.req = $.ajax "https://sys.4chan.org/#{g.BOARD}/post", options, extra
# Starting to upload might take some time.
# Provide some feedback that we're starting to submit.
QR.req.uploadStartTime = Date.now()
@ -667,7 +721,7 @@ QR =
# Too many frequent mistyped captchas will auto-ban you!
# On connection error, the post most likely didn't go through.
QR.cooldown.set delay: 2
else if err.textContent and m = err.textContent.match /wait\s(\d+)\ssecond/i
else if err.textContent and m = err.textContent.match /wait\s+(\d+)\s+second/i
QR.cooldown.auto = if QR.captcha.isEnabled
!!QR.captcha.captchas.length
else
@ -700,8 +754,6 @@ QR =
ThreadUpdater.postID = postID
# Post/upload confirmed as successful.
$.event 'QRPostSuccessful', {
board: g.BOARD

View File

@ -10,12 +10,12 @@ QuoteBacklink =
# Second callback adds relevant containers into posts.
# This is is so that fetched posts can get their backlinks,
# and that as much backlinks are appended in the background as possible.
containers: {}
init: ->
return if g.VIEW is 'catalog' or !Conf['Quote Backlinks']
format = Conf['backlink'].replace /%id/g, "' + id + '"
@funk = Function 'id', "return '#{format}'"
@containers = {}
Post.callbacks.push
name: 'Quote Backlinking Part 1'
cb: @firstNode
@ -36,13 +36,13 @@ QuoteBacklink =
for clone in post.clones
containers.push clone.nodes.backlinkContainer
for container in containers
frag = [$.tn(' '), link = a.cloneNode true]
nodes = [$.tn(' '), link = a.cloneNode true]
if Conf['Quote Previewing']
$.on link, 'mouseover', QuotePreview.mouseover
if Conf['Quote Inlining']
$.on link, 'click', QuoteInline.toggle
frag.push.apply frag, QuoteInline.qiQuote link, $.hasClass link, 'filtered' if Conf['Quote Hash Navigation']
$.add container, frag
nodes.push QuoteInline.qiQuote link, $.hasClass link, 'filtered' if Conf['Quote Hash Navigation']
$.add container, nodes
return
secondNode: ->
if @isClone and (@origin.isReply or Conf['OP Backlinks'])

View File

@ -2,18 +2,14 @@ QuoteInline =
init: ->
return if g.VIEW is 'catalog' or !Conf['Quote Inlining']
if Conf['Quote Hash Navigation']
@node = ->
for link in @nodes.quotelinks.concat [@nodes.backlinks...]
$.after link, QuoteInline.qiQuote link, $.hasClass link, 'filtered' unless @isClone
$.on link, 'click', QuoteInline.toggle
return
@process = if Conf['Quote Hash Navigation']
(link, clone) ->
$.after link, QuoteInline.qiQuote link, $.hasClass link, 'filtered' unless clone
$.on link, 'click', QuoteInline.toggle
else
@node = ->
for link in @nodes.quotelinks.concat [@nodes.backlinks...]
$.on link, 'click', QuoteInline.toggle
return
(link) ->
$.on link, 'click', QuoteInline.toggle
if Conf['Comment Expansion']
ExpandComment.callbacks.push @node
@ -22,14 +18,18 @@ QuoteInline =
name: 'Quote Inlining'
cb: @node
node: ->
{process} = QuoteInline
{isClone} = @
process link, isClone for link in @nodes.quotelinks
process link, isClone for link in @nodes.backlinks
return
qiQuote: (link, hidden) ->
[
$.tn(' ')
$.el 'a',
className: if hidden then 'hashlink filtered' else 'hashlink'
textContent: '#'
href: link.href
]
$.el 'a',
className: "hashlink#{if hidden then ' filtered' else ''}"
textContent: '#'
href: link.href
toggle: (e) ->
return if e.shiftKey or e.altKey or e.ctrlKey or e.metaKey or e.button isnt 0

View File

@ -13,31 +13,48 @@ QuoteThreading =
input = $ 'input', @controls
$.on input, 'change', @toggle
$.event 'AddMenuEntry',
$.event 'AddMenuEntry', @entry =
type: 'header'
el: @controls
order: 98
$.on d, '4chanXInitFinished', @setup unless Conf['Unread Count']
$.on d, '4chanXInitFinished', @ready unless Conf['Unread Count']
Post.callbacks.push
name: 'Quote Threading'
cb: @node
setup: ->
$.off d, '4chanXInitFinished', QuoteThreading.setup
disconnect: ->
return unless Conf['Quote Threading'] and g.VIEW is 'thread'
input = $ 'input', @controls
$.off input, 'change', @toggle
$.event 'rmMenuEntry', @entry
delete @enabled
delete @controls
delete @entry
Post.callbacks.disconnect 'Quote Threading'
ready: ->
$.off d, '4chanXInitFinished', QuoteThreading.ready
QuoteThreading.force()
force: ->
post.cb true for ID, post of g.posts when post.cb
return
g.posts.forEach (post) ->
post.cb true if post.cb
if Conf['Unread Count'] and Unread.thread.OP.nodes.root.parentElement.parentElement
Unread.read()
Unread.update()
node: ->
{posts} = g
return if @isClone or not QuoteThreading.enabled
Unread.posts.push @ if Conf['Unread Count']
return if @thread.OP is @ or !(post = posts[@fullID]) or post.isHidden # Filtered
Unread.posts.push @ if Conf['Unread Count']
return if @thread.OP is @ or @isHidden # Filtered
keys = []
len = g.BOARD.ID.length + 1
@ -77,11 +94,11 @@ QuoteThreading =
return true unless Conf['Unread Count']
if posts[post.ID]
posts.after post, @
if post = posts[post.ID]
posts.after post, posts[@ID]
else
posts.prepend @
posts.prepend posts[@ID]
return true
@ -93,8 +110,10 @@ QuoteThreading =
thread = $('.thread')
posts = []
nodes = []
g.posts.forEach (post) ->
posts.push post unless post is post.thread.OP or post.isClone
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
@ -103,7 +122,7 @@ QuoteThreading =
containers = $$ '.threadContainer', thread
$.rm container for container in containers
$.rmClass post, 'threadOP' for post in $$ '.threadOP'
return
kb: ->

View File

@ -20,7 +20,7 @@ QuoteYou =
node: ->
return if @isClone
if @info.yours
if QR.db.get {boardID: @board.ID, threadID: @thread.ID, postID: @ID}
$.addClass @nodes.root, 'yourPost'
# Stop there if there's no quotes in that post.

View File

@ -8,6 +8,7 @@ Quotify =
Post.callbacks.push
name: 'Resurrect Quotes'
cb: @node
node: ->
for deadlink in $$ '.deadlink', @nodes.comment
if @isClone