Merge pull request #1230 from MayhemYDG/watcher

Thread Watcher rewrite, close #99
This commit is contained in:
Mayhem 2013-08-12 10:24:30 -07:00
commit e695713a80
9 changed files with 311 additions and 107 deletions

View File

@ -1,3 +1,13 @@
- **Thread Watcher** improvements:
- It is now possible to open all watched threads via the `Open all threads` button in the Thread Watcher's menu.
- Added the `Current Board` setting to switch between showing watched threads from the current board or all boards, disabled by default.
- About dead (404'd) threads:
- Dead threads will be typographically indicated with a strikethrough.
- Dead threads will directly link to the corresponding archive when available.
- A button to prune all 404'd threads from the list is now available.
- Added the `Auto Prune` setting to automatically prune 404'd threads, disabled by default.
- The current thread is now highlighted in the list of watched threads.
- Watching the current thread can be done in the Header's menu too.
- Removed the `Check for Updates` setting: - Removed the `Check for Updates` setting:
- Your browser/userscript manager should handle updates itself automatically. - Your browser/userscript manager should handle updates itself automatically.

View File

@ -36,7 +36,7 @@
.move { .move {
cursor: move; cursor: move;
} }
label, .favicon { label, .watcher-toggler {
cursor: pointer; cursor: pointer;
} }
a[href="javascript:;"] { a[href="javascript:;"] {
@ -83,13 +83,13 @@ a[href="javascript:;"] {
#qr { #qr {
z-index: 30; z-index: 30;
} }
#watcher:hover { #thread-watcher:hover {
z-index: 20; z-index: 20;
} }
#header { #header {
z-index: 10; z-index: 10;
} }
#watcher { #thread-watcher {
z-index: 5; z-index: 5;
} }
@ -432,28 +432,40 @@ a.hide-announcement {
} }
/* Thread Watcher */ /* Thread Watcher */
#watcher { #thread-watcher {
padding-bottom: 3px; max-width: 200px;
min-width: 150px;
padding: 3px;
position: absolute; position: absolute;
}
#thread-watcher > div:first-child {
display: -webkit-flex;
display: flex;
-webkit-align-items: center;
align-items: center;
}
#thread-watcher .move {
-webkit-flex: 1;
flex: 1;
}
#watched-threads:not(:hover) {
max-height: 150px;
overflow: hidden; overflow: hidden;
}
#watched-threads div {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
#watcher:not(:hover) { #watched-threads .current {
max-height: 220px; font-weight: 700;
} }
#watcher > .move { #watched-threads a {
padding-top: 3px;
}
#watcher > div {
max-width: 200px;
overflow: hidden;
padding-left: 3px;
padding-right: 3px;
text-overflow: ellipsis;
}
#watcher a {
text-decoration: none; text-decoration: none;
} }
#watched-threads .dead-thread a[title] {
text-decoration: line-through;
}
/* Thread Stats */ /* Thread Stats */
#thread-stats { #thread-stats {
@ -878,6 +890,9 @@ a.hide-announcement {
text-decoration: none; text-decoration: none;
white-space: nowrap; white-space: nowrap;
} }
.entry.disabled {
color: graytext !important;
}
.entry.has-submenu { .entry.has-submenu {
padding-right: 20px; padding-right: 20px;
} }

View File

@ -0,0 +1,5 @@
<div>
<span class="move">Thread Watcher</span>
<a class="menu-button" href="javascript:;">[<i></i>]</a>
</div>
<div id="watched-threads"></div>

View File

@ -46,8 +46,6 @@ Config =
'Thread Excerpt': [true, 'Show an excerpt of the thread in the tab title.'] 'Thread Excerpt': [true, 'Show an excerpt of the thread in the tab title.']
'Thread Stats': [true, 'Display reply, image, and page count.'] 'Thread Stats': [true, 'Display reply, image, and page count.']
'Thread Watcher': [true, 'Bookmark threads.'] 'Thread Watcher': [true, 'Bookmark threads.']
'Auto Watch': [true, 'Automatically watch threads you start.']
'Auto Watch Reply': [false, 'Automatically watch threads you reply to.']
'Posting': 'Posting':
'Quick Reply': [true, 'All-in-one form to reply, create threads, automate dumping and more.'] 'Quick Reply': [true, 'All-in-one form to reply, create threads, automate dumping and more.']
'Persistent QR': [false, 'The Quick reply won\'t disappear after posting.'] 'Persistent QR': [false, 'The Quick reply won\'t disappear after posting.']
@ -78,6 +76,11 @@ Config =
'Fit height': [false, ''] 'Fit height': [false, '']
'Expand spoilers': [false, 'Expand all images along with spoilers.'] 'Expand spoilers': [false, 'Expand all images along with spoilers.']
'Expand from here': [true, 'Expand all images only from current position to thread end.'] 'Expand from here': [true, 'Expand all images only from current position to thread end.']
threadWatcher:
'Current Board': [false, 'Only show watched threads from the current board.']
'Auto Watch': [true, 'Automatically watch threads you start.']
'Auto Watch Reply': [false, 'Automatically watch threads you reply to.']
'Auto Prune': [false, 'Automatically prune 404\'d threads.']
filter: filter:
name: """ name: """
# Filter any namefags: # Filter any namefags:

View File

@ -1,10 +1,10 @@
DataBoards = ['hiddenThreads', 'hiddenPosts', 'lastReadPosts', 'yourPosts'] DataBoards = ['hiddenThreads', 'hiddenPosts', 'lastReadPosts', 'yourPosts', 'watchedThreads']
class DataBoard class DataBoard
constructor: (@key, sync) -> constructor: (@key, sync, dontClean) ->
@data = Conf[key] @data = Conf[key]
$.sync key, @onSync.bind @ $.sync key, @onSync.bind @
@clean() @clean() unless dontClean
return unless sync return unless sync
# Chrome also fires the onChanged callback on the current tab, # Chrome also fires the onChanged callback on the current tab,
# so we only start syncing when we're ready. # so we only start syncing when we're ready.
@ -13,6 +13,8 @@ class DataBoard
@sync = sync @sync = sync
$.on d, '4chanXInitFinished', init $.on d, '4chanXInitFinished', init
save: ->
$.set @key, @data
delete: ({boardID, threadID, postID}) -> delete: ({boardID, threadID, postID}) ->
if postID if postID
delete @data.boards[boardID][threadID][postID] delete @data.boards[boardID][threadID][postID]
@ -22,7 +24,7 @@ class DataBoard
@deleteIfEmpty {boardID} @deleteIfEmpty {boardID}
else else
delete @data.boards[boardID] delete @data.boards[boardID]
$.set @key, @data @save()
deleteIfEmpty: ({boardID, threadID}) -> deleteIfEmpty: ({boardID, threadID}) ->
if threadID if threadID
unless Object.keys(@data.boards[boardID][threadID]).length unless Object.keys(@data.boards[boardID][threadID]).length
@ -37,7 +39,7 @@ class DataBoard
(@data.boards[boardID] or= {})[threadID] = val (@data.boards[boardID] or= {})[threadID] = val
else else
@data.boards[boardID] = val @data.boards[boardID] = val
$.set @key, @data @save()
get: ({boardID, threadID, postID, defaultValue}) -> get: ({boardID, threadID, postID, defaultValue}) ->
if board = @data.boards[boardID] if board = @data.boards[boardID]
unless threadID unless threadID
@ -65,7 +67,7 @@ class DataBoard
for boardID of @data.boards for boardID of @data.boards
@ajaxClean boardID @ajaxClean boardID
$.set @key, @data @save()
ajaxClean: (boardID) -> ajaxClean: (boardID) ->
$.cache "//api.4chan.org/#{boardID}/threads.json", (e) => $.cache "//api.4chan.org/#{boardID}/threads.json", (e) =>
if e.target.status is 404 if e.target.status is 404
@ -80,7 +82,7 @@ class DataBoard
threads[thread.no] = board[thread.no] threads[thread.no] = board[thread.no]
@data.boards[boardID] = threads @data.boards[boardID] = threads
@deleteIfEmpty {boardID} @deleteIfEmpty {boardID}
$.set @key, @data @save()
onSync: (data) -> onSync: (data) ->
@data = data or boards: {} @data = data or boards: {}

View File

@ -112,6 +112,7 @@ Main =
initFeature 'Thread Stats', ThreadStats initFeature 'Thread Stats', ThreadStats
initFeature 'Thread Updater', ThreadUpdater initFeature 'Thread Updater', ThreadUpdater
initFeature 'Thread Watcher', ThreadWatcher initFeature 'Thread Watcher', ThreadWatcher
initFeature 'Thread Watcher (Menu)', ThreadWatcher.menu
initFeature 'Index Navigation', Nav initFeature 'Index Navigation', Nav
initFeature 'Keybinds', Keybinds initFeature 'Keybinds', Keybinds
initFeature 'Show Dice Roll', Dice initFeature 'Show Dice Roll', Dice

View File

@ -169,7 +169,6 @@ Settings =
data = data =
version: g.VERSION version: g.VERSION
date: now date: now
Conf['WatchedThreads'] = {}
for db in DataBoards for db in DataBoards
Conf[db] = boards: {} Conf[db] = boards: {}
# Make sure to export the most recent data. # Make sure to export the most recent data.
@ -279,7 +278,10 @@ Settings =
for key, val of Config.hotkeys when key of data.Conf for key, val of Config.hotkeys when key of data.Conf
data.Conf[key] = data.Conf[key].replace(/ctrl|alt|meta/g, (s) -> "#{s[0].toUpperCase()}#{s[1..]}").replace /(^|.+\+)[A-Z]$/g, (s) -> data.Conf[key] = data.Conf[key].replace(/ctrl|alt|meta/g, (s) -> "#{s[0].toUpperCase()}#{s[1..]}").replace /(^|.+\+)[A-Z]$/g, (s) ->
"Shift+#{s[0...-1]}#{s[-1..].toLowerCase()}" "Shift+#{s[0...-1]}#{s[-1..].toLowerCase()}"
data.Conf.WatchedThreads = data.WatchedThreads data.Conf['WatchedThreads'] = data.WatchedThreads
if data.Conf['WatchedThreads']
data.Conf['watchedThreads'] = boards: ThreadWatcher.convert data.Conf['WatchedThreads']
delete data.Conf['WatchedThreads']
$.set data.Conf $.set data.Conf
convertSettings: (data, map) -> convertSettings: (data, map) ->
for prevKey, newKey of map for prevKey, newKey of map

View File

@ -159,8 +159,8 @@ ImageExpand =
{createSubEntry} = ImageExpand.menu {createSubEntry} = ImageExpand.menu
subEntries = [] subEntries = []
for key, conf of Config.imageExpansion for name, conf of Config.imageExpansion
subEntries.push createSubEntry key, conf subEntries.push createSubEntry name, conf[1]
$.event 'AddMenuEntry', $.event 'AddMenuEntry',
type: 'header' type: 'header'
@ -168,15 +168,14 @@ ImageExpand =
order: 80 order: 80
subEntries: subEntries subEntries: subEntries
createSubEntry: (type, config) -> createSubEntry: (name, desc) ->
label = $.el 'label', label = $.el 'label',
innerHTML: "<input type=checkbox name='#{type}'> #{type}" innerHTML: "<input type=checkbox name='#{name}'> #{name}"
title: desc
input = label.firstElementChild input = label.firstElementChild
if type in ['Fit width', 'Fit height'] if name in ['Fit width', 'Fit height']
$.on input, 'change', ImageExpand.cb.setFitness $.on input, 'change', ImageExpand.cb.setFitness
if config input.checked = Conf[name]
label.title = config[1] $.event 'change', null, input
input.checked = Conf[type] $.on input, 'change', $.cb.checked
$.event 'change', null, input
$.on input, 'change', $.cb.checked
el: label el: label

View File

@ -1,99 +1,266 @@
ThreadWatcher = ThreadWatcher =
init: -> init: ->
return if g.VIEW is 'catalog' or !Conf['Thread Watcher'] return if !Conf['Thread Watcher']
@dialog = UI.dialog 'watcher', 'top: 50px; left: 0px;',
'<div class=move>Thread Watcher</div>' @db = new DataBoard 'watchedThreads', @refresh, true
@dialog = UI.dialog 'thread-watcher', 'top: 50px; left: 0px;', """
<%= grunt.file.read('html/Monitoring/ThreadWatcher.html').replace(/>\s+</g, '><').trim() %>
"""
@list = @dialog.lastElementChild
$.on d, 'QRPostSuccessful', @cb.post $.on d, 'QRPostSuccessful', @cb.post
$.on d, 'ThreadUpdate', @cb.threadUpdate if g.VIEW is 'thread'
$.on d, '4chanXInitFinished', @ready $.on d, '4chanXInitFinished', @ready
$.sync 'WatchedThreads', @refresh
now = Date.now()
if (@db.data.lastChecked or 0) < now - 2 * $.HOUR
@db.data.lastChecked = now
ThreadWatcher.fetchAllStatus()
@db.save()
# XXX tmp conversion from old to new format
$.get 'WatchedThreads', null, ({WatchedThreads}) ->
return unless WatchedThreads
for boardID, threads of ThreadWatcher.convert WatchedThreads
for threadID, data of threads
ThreadWatcher.db.set {boardID, threadID, val: data}
$.delete 'WatchedThreads'
Thread::callbacks.push Thread::callbacks.push
name: 'Thread Watcher' name: 'Thread Watcher'
cb: @node cb: @node
node: -> node: ->
favicon = $.el 'img', toggler = $.el 'img',
className: 'favicon' className: 'watcher-toggler'
$.on favicon, 'click', ThreadWatcher.cb.toggle $.on toggler, 'click', ThreadWatcher.cb.toggle
$.before $('input', @OP.nodes.post), favicon $.before $('input', @OP.nodes.post), toggler
return if g.VIEW isnt 'thread'
$.get 'AutoWatch', 0, (item) =>
return if item['AutoWatch'] isnt @ID
ThreadWatcher.watch @
$.delete 'AutoWatch'
ready: -> ready: ->
$.off d, '4chanXInitFinished', ThreadWatcher.ready $.off d, '4chanXInitFinished', ThreadWatcher.ready
return unless Main.isThisPageLegit() return unless Main.isThisPageLegit()
ThreadWatcher.refresh() ThreadWatcher.refresh()
$.add d.body, ThreadWatcher.dialog $.add d.body, ThreadWatcher.dialog
refresh: (watched) -> return unless Conf['Auto Watch']
unless watched $.get 'AutoWatch', 0, ({AutoWatch}) ->
$.get 'WatchedThreads', {}, (item) -> return unless thread = g.BOARD.threads[AutoWatch]
ThreadWatcher.refresh item['WatchedThreads'] ThreadWatcher.add thread
return $.delete 'AutoWatch'
nodes = [$('.move', ThreadWatcher.dialog)]
for board of watched
for id, props of watched[board]
x = $.el 'a',
textContent: '×'
href: 'javascript:;'
$.on x, 'click', ThreadWatcher.cb.x
link = $.el 'a', props
link.title = link.textContent
div = $.el 'div'
$.add div, [x, $.tn(' '), link]
nodes.push div
$.rmAll ThreadWatcher.dialog
$.add ThreadWatcher.dialog, nodes
watched = watched[g.BOARD] or {}
for ID, thread of g.BOARD.threads
favicon = $ '.favicon', thread.OP.nodes.post
favicon.src = if ID of watched
Favicon.default
else
Favicon.empty
return
cb: cb:
openAll: ->
return if $.hasClass @, 'disabled'
for a in $$ 'a[title]', ThreadWatcher.list
$.open a.href
$.event 'CloseMenu'
checkThreads: ->
return if $.hasClass @, 'disabled'
ThreadWatcher.fetchAllStatus()
pruneDeads: ->
return if $.hasClass @, 'disabled'
for {boardID, threadID, data} in ThreadWatcher.getAll() when data.isDead
delete ThreadWatcher.db.data.boards[boardID][threadID]
ThreadWatcher.db.deleteIfEmpty {boardID}
ThreadWatcher.db.save()
ThreadWatcher.refresh()
$.event 'CloseMenu'
toggle: -> toggle: ->
ThreadWatcher.toggle Get.postFromNode(@).thread ThreadWatcher.toggle Get.postFromNode(@).thread
x: -> rm: ->
thread = @nextElementSibling.pathname.split '/' [boardID, threadID] = @parentNode.dataset.fullID.split '.'
ThreadWatcher.unwatch thread[1], thread[3] ThreadWatcher.rm boardID, +threadID
post: (e) -> post: (e) ->
{board, postID, threadID} = e.detail {board, postID, threadID} = e.detail
if postID is threadID if postID is threadID
if Conf['Auto Watch'] if Conf['Auto Watch']
$.set 'AutoWatch', threadID $.set 'AutoWatch', threadID
else if Conf['Auto Watch Reply'] else if Conf['Auto Watch Reply']
ThreadWatcher.watch board.threads[threadID] ThreadWatcher.add board.threads[threadID]
threadUpdate: (e) ->
{thread} = e.detail
return unless e.detail[404] and ThreadWatcher.db.get {boardID: thread.board.ID, threadID: thread.ID}
# Update 404 status.
ThreadWatcher.add thread
fetchAllStatus: ->
# XXX need visual feedback
for thread in ThreadWatcher.getAll()
ThreadWatcher.fetchStatus thread
return
fetchStatus: ({boardID, threadID, data}) ->
return if data.isDead
$.ajax "//api.4chan.org/#{boardID}/res/#{threadID}.json",
onload: ->
return if @status isnt 404
if Conf['Auto Prune']
ThreadWatcher.rm boardID, threadID
else
data.isDead = true
ThreadWatcher.db.set {boardID, threadID, val: data}
ThreadWatcher.refresh()
,
type: 'head'
getAll: ->
all = []
for boardID, threads of ThreadWatcher.db.data.boards
if Conf['Current Board'] and boardID isnt g.BOARD.ID
continue
for threadID, data of threads
all.push {boardID, threadID, data}
all
makeLine: (boardID, threadID, data) ->
x = $.el 'a',
textContent: '×'
href: 'javascript:;'
$.on x, 'click', ThreadWatcher.cb.rm
if data.isDead
href = Redirect.to 'thread', {boardID, threadID}
link = $.el 'a',
href: href or "/#{boardID}/res/#{threadID}"
textContent: data.excerpt
title: data.excerpt
div = $.el 'div'
fullID = "#{boardID}.#{threadID}"
div.dataset.fullID = fullID
$.addClass div, 'current' if g.VIEW is 'thread' and fullID is "#{g.BOARD}.#{g.THREADID}"
$.addClass div, 'dead-thread' if data.isDead
$.add div, [x, $.tn(' '), link]
div
refresh: ->
nodes = []
for {boardID, threadID, data} in ThreadWatcher.getAll()
nodes.push ThreadWatcher.makeLine boardID, threadID, data
{list} = ThreadWatcher
$.rmAll list
$.add list, nodes
for threadID, thread of g.BOARD.threads
toggler = $ '.watcher-toggler', thread.OP.nodes.post
toggler.src = if ThreadWatcher.db.get {boardID: thread.board.ID, threadID}
Favicon.default
else
Favicon.empty
for refresher in ThreadWatcher.menu.refreshers
refresher()
return
toggle: (thread) -> toggle: (thread) ->
if $('.favicon', thread.OP.nodes.post).src is Favicon.empty boardID = thread.board.ID
ThreadWatcher.watch thread threadID = thread.ID
if ThreadWatcher.db.get {boardID, threadID}
ThreadWatcher.rm boardID, threadID
else else
ThreadWatcher.unwatch thread.board, thread.ID ThreadWatcher.add thread
add: (thread) ->
data = {}
boardID = thread.board.ID
threadID = thread.ID
if thread.isDead
if Conf['Auto Prune'] and ThreadWatcher.db.get {boardID, threadID}
ThreadWatcher.rm boardID, threadID
return
data.isDead = true
data.excerpt = Get.threadExcerpt thread
ThreadWatcher.db.set {boardID, threadID, val: data}
ThreadWatcher.refresh()
rm: (boardID, threadID) ->
ThreadWatcher.db.delete {boardID, threadID}
ThreadWatcher.refresh()
unwatch: (board, threadID) -> convert: (oldFormat) ->
$.get 'WatchedThreads', {}, (item) -> newFormat = {}
watched = item['WatchedThreads'] for boardID, threads of oldFormat
delete watched[board][threadID] for threadID, data of threads
delete watched[board] unless Object.keys(watched[board]).length (newFormat[boardID] or= {})[threadID] = excerpt: data.textContent
ThreadWatcher.refresh watched newFormat
$.set 'WatchedThreads', watched
watch: (thread) -> menu:
$.get 'WatchedThreads', {}, (item) -> refreshers: []
watched = item['WatchedThreads'] init: ->
watched[thread.board] or= {} return if !Conf['Thread Watcher']
watched[thread.board][thread] = menu = new UI.Menu 'thread watcher'
href: "/#{thread.board}/res/#{thread}" $.on $('.menu-button', ThreadWatcher.dialog), 'click', (e) ->
textContent: Get.threadExcerpt thread menu.toggle e, @, ThreadWatcher
ThreadWatcher.refresh watched @addHeaderMenuEntry()
$.set 'WatchedThreads', watched @addMenuEntries()
addHeaderMenuEntry: ->
return if g.VIEW isnt 'thread'
entryEl = $.el 'a',
href: 'javascript:;'
$.event 'AddMenuEntry',
type: 'header'
el: entryEl
order: 60
$.on entryEl, 'click', -> ThreadWatcher.toggle g.threads["#{g.BOARD}.#{g.THREADID}"]
@refreshers.push ->
[addClass, rmClass, text] = if $ '.current', ThreadWatcher.list
['unwatch-thread', 'watch-thread', 'Unwatch thread']
else
['watch-thread', 'unwatch-thread', 'Watch thread']
$.addClass entryEl, addClass
$.rmClass entryEl, rmClass
entryEl.textContent = text
addMenuEntries: ->
entries = []
# `Open all` entry
entries.push
cb: ThreadWatcher.cb.openAll
entry:
type: 'thread watcher'
el: $.el 'a',
textContent: 'Open all threads'
refresh: -> (if ThreadWatcher.list.firstElementChild then $.rmClass else $.addClass) @el, 'disabled'
# `Check 404'd threads` entry
entries.push
cb: ThreadWatcher.cb.checkThreads
entry:
type: 'thread watcher'
el: $.el 'a',
textContent: 'Check 404\'d threads'
refresh: -> (if $('div:not(.dead-thread)', ThreadWatcher.list) then $.rmClass else $.addClass) @el, 'disabled'
# `Prune 404'd threads` entry
entries.push
cb: ThreadWatcher.cb.pruneDeads
entry:
type: 'thread watcher'
el: $.el 'a',
textContent: 'Prune 404\'d threads'
refresh: -> (if $('.dead-thread', ThreadWatcher.list) then $.rmClass else $.addClass) @el, 'disabled'
# `Settings` entries:
subEntries = []
for name, conf of Config.threadWatcher
subEntries.push @createSubEntry name, conf[1]
entries.push
entry:
type: 'thread watcher'
el: $.el 'span',
textContent: 'Settings'
subEntries: subEntries
for {entry, cb, refresh} in entries
entry.el.href = 'javascript:;' if entry.el.nodeName is 'A'
$.on entry.el, 'click', cb if cb
@refreshers.push refresh.bind entry if refresh
$.event 'AddMenuEntry', entry
createSubEntry: (name, desc) ->
entry =
type: 'thread watcher'
el: $.el 'label',
innerHTML: "<input type=checkbox name='#{name}'> #{name}"
title: desc
input = entry.el.firstElementChild
input.checked = Conf[name]
$.on input, 'change', $.cb.checked
$.on input, 'change', ThreadWatcher.refresh if name is 'Current Board'
entry