From 6261160891a982c5e7e16b2d0d72b33bccd35bc9 Mon Sep 17 00:00:00 2001 From: Mayhem Date: Mon, 12 Aug 2013 19:20:16 +0200 Subject: [PATCH] Start the Thread Watcher rewrite. #99 Fix #1112. --- CHANGELOG.md | 9 + css/style.css | 49 ++++-- html/Monitoring/ThreadWatcher.html | 5 + src/General/Config.coffee | 6 +- src/General/DataBoard.coffee | 16 +- src/General/Settings.coffee | 6 +- src/Images/ImageExpand.coffee | 19 +-- src/Monitoring/ThreadWatcher.coffee | 251 ++++++++++++++++++++-------- 8 files changed, 256 insertions(+), 105 deletions(-) create mode 100644 html/Monitoring/ThreadWatcher.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f7c26991..002e98a51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +- **Thread Watcher** rewrite: + - 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 possible. + - A button to prune all 404'd threads from the list is now available. + - 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: - Your browser/userscript manager should handle updates itself automatically. diff --git a/css/style.css b/css/style.css index 28dfb5bed..b29c6769c 100644 --- a/css/style.css +++ b/css/style.css @@ -83,13 +83,13 @@ a[href="javascript:;"] { #qr { z-index: 30; } -#watcher:hover { +#thread-watcher:hover { z-index: 20; } #header { z-index: 10; } -#watcher { +#thread-watcher { z-index: 5; } @@ -432,28 +432,40 @@ a.hide-announcement { } /* Thread Watcher */ -#watcher { - padding-bottom: 3px; +#thread-watcher { + max-width: 200px; + min-width: 150px; + padding: 3px; 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; +} +#watched-threads div { + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } -#watcher:not(:hover) { - max-height: 220px; +#watched-threads .current { + font-weight: 700; } -#watcher > .move { - padding-top: 3px; -} -#watcher > div { - max-width: 200px; - overflow: hidden; - padding-left: 3px; - padding-right: 3px; - text-overflow: ellipsis; -} -#watcher a { +#watched-threads a { text-decoration: none; } +#watched-threads .dead-thread a[title] { + text-decoration: line-through; +} /* Thread Stats */ #thread-stats { @@ -878,6 +890,9 @@ a.hide-announcement { text-decoration: none; white-space: nowrap; } +.entry.disabled { + color: graytext !important; +} .entry.has-submenu { padding-right: 20px; } diff --git a/html/Monitoring/ThreadWatcher.html b/html/Monitoring/ThreadWatcher.html new file mode 100644 index 000000000..d3878c4cd --- /dev/null +++ b/html/Monitoring/ThreadWatcher.html @@ -0,0 +1,5 @@ +
+ Thread Watcher + [] +
+
diff --git a/src/General/Config.coffee b/src/General/Config.coffee index 7277f3897..23e36d461 100644 --- a/src/General/Config.coffee +++ b/src/General/Config.coffee @@ -46,8 +46,6 @@ Config = 'Thread Excerpt': [true, 'Show an excerpt of the thread in the tab title.'] 'Thread Stats': [true, 'Display reply, image, and page count.'] 'Thread Watcher': [true, 'Bookmark threads.'] - 'Auto Watch': [true, 'Automatically watch threads you start.'] - 'Auto Watch Reply': [false, 'Automatically watch threads you reply to.'] 'Posting': '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.'] @@ -78,6 +76,10 @@ Config = 'Fit height': [false, ''] 'Expand spoilers': [false, 'Expand all images along with spoilers.'] '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.'] filter: name: """ # Filter any namefags: diff --git a/src/General/DataBoard.coffee b/src/General/DataBoard.coffee index bbdbbccac..aa6ef84f8 100644 --- a/src/General/DataBoard.coffee +++ b/src/General/DataBoard.coffee @@ -1,10 +1,10 @@ -DataBoards = ['hiddenThreads', 'hiddenPosts', 'lastReadPosts', 'yourPosts'] +DataBoards = ['hiddenThreads', 'hiddenPosts', 'lastReadPosts', 'yourPosts', 'watchedThreads'] class DataBoard - constructor: (@key, sync) -> + constructor: (@key, sync, dontClean) -> @data = Conf[key] $.sync key, @onSync.bind @ - @clean() + @clean() unless dontClean return unless sync # Chrome also fires the onChanged callback on the current tab, # so we only start syncing when we're ready. @@ -13,6 +13,8 @@ class DataBoard @sync = sync $.on d, '4chanXInitFinished', init + save: -> + $.set @key, @data delete: ({boardID, threadID, postID}) -> if postID delete @data.boards[boardID][threadID][postID] @@ -22,7 +24,7 @@ class DataBoard @deleteIfEmpty {boardID} else delete @data.boards[boardID] - $.set @key, @data + @save() deleteIfEmpty: ({boardID, threadID}) -> if threadID unless Object.keys(@data.boards[boardID][threadID]).length @@ -37,7 +39,7 @@ class DataBoard (@data.boards[boardID] or= {})[threadID] = val else @data.boards[boardID] = val - $.set @key, @data + @save() get: ({boardID, threadID, postID, defaultValue}) -> if board = @data.boards[boardID] unless threadID @@ -65,7 +67,7 @@ class DataBoard for boardID of @data.boards @ajaxClean boardID - $.set @key, @data + @save() ajaxClean: (boardID) -> $.cache "//api.4chan.org/#{boardID}/threads.json", (e) => if e.target.status is 404 @@ -80,7 +82,7 @@ class DataBoard threads[thread.no] = board[thread.no] @data.boards[boardID] = threads @deleteIfEmpty {boardID} - $.set @key, @data + @save() onSync: (data) -> @data = data or boards: {} diff --git a/src/General/Settings.coffee b/src/General/Settings.coffee index e5241d37d..89ad940ca 100644 --- a/src/General/Settings.coffee +++ b/src/General/Settings.coffee @@ -169,7 +169,6 @@ Settings = data = version: g.VERSION date: now - Conf['WatchedThreads'] = {} for db in DataBoards Conf[db] = boards: {} # Make sure to export the most recent data. @@ -279,7 +278,10 @@ Settings = 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) -> "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 convertSettings: (data, map) -> for prevKey, newKey of map diff --git a/src/Images/ImageExpand.coffee b/src/Images/ImageExpand.coffee index 9a4d8dd3f..6a3a06f20 100644 --- a/src/Images/ImageExpand.coffee +++ b/src/Images/ImageExpand.coffee @@ -159,8 +159,8 @@ ImageExpand = {createSubEntry} = ImageExpand.menu subEntries = [] - for key, conf of Config.imageExpansion - subEntries.push createSubEntry key, conf + for name, conf of Config.imageExpansion + subEntries.push createSubEntry name, conf[1] $.event 'AddMenuEntry', type: 'header' @@ -168,15 +168,14 @@ ImageExpand = order: 80 subEntries: subEntries - createSubEntry: (type, config) -> + createSubEntry: (name, desc) -> label = $.el 'label', - innerHTML: " #{type}" + innerHTML: " #{name}" + title: desc input = label.firstElementChild - if type in ['Fit width', 'Fit height'] + if name in ['Fit width', 'Fit height'] $.on input, 'change', ImageExpand.cb.setFitness - if config - label.title = config[1] - input.checked = Conf[type] - $.event 'change', null, input - $.on input, 'change', $.cb.checked + input.checked = Conf[name] + $.event 'change', null, input + $.on input, 'change', $.cb.checked el: label diff --git a/src/Monitoring/ThreadWatcher.coffee b/src/Monitoring/ThreadWatcher.coffee index 8fbd3d757..1629209d9 100644 --- a/src/Monitoring/ThreadWatcher.coffee +++ b/src/Monitoring/ThreadWatcher.coffee @@ -1,99 +1,216 @@ ThreadWatcher = init: -> - return if g.VIEW is 'catalog' or !Conf['Thread Watcher'] - @dialog = UI.dialog 'watcher', 'top: 50px; left: 0px;', - '
Thread Watcher
' + return if !Conf['Thread Watcher'] + @db = new DataBoard 'watchedThreads', @refresh, true + @dialog = UI.dialog 'thread-watcher', 'top: 50px; left: 0px;', """ + <%= grunt.file.read('html/Monitoring/ThreadWatcher.html').replace(/>\s+<').trim() %> + """ + @list = @dialog.lastElementChild + + @menuInit() + @addHeaderMenuEntry() + + $.on $('.menu-button', @dialog), 'click', @cb.menuToggle $.on d, 'QRPostSuccessful', @cb.post + $.on d, 'ThreadUpdate', @cb.threadUpdate if g.VIEW is 'thread' $.on d, '4chanXInitFinished', @ready - $.sync 'WatchedThreads', @refresh + + # 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 name: 'Thread Watcher' cb: @node node: -> - favicon = $.el 'img', - className: 'favicon' - $.on favicon, 'click', ThreadWatcher.cb.toggle - $.before $('input', @OP.nodes.post), favicon - return if g.VIEW isnt 'thread' - $.get 'AutoWatch', 0, (item) => - return if item['AutoWatch'] isnt @ID - ThreadWatcher.watch @ + toggler = $.el 'img', + className: 'watcher-toggler' + $.on toggler, 'click', ThreadWatcher.cb.toggle + $.before $('input', @OP.nodes.post), toggler + + return if g.VIEW isnt 'thread' or !Conf['Auto Watch'] + $.get 'AutoWatch', 0, ({AutoWatch}) => + return if AutoWatch isnt @ID + ThreadWatcher.add @ $.delete 'AutoWatch' + menuInit: -> + ThreadWatcher.menu = new UI.Menu 'thread watcher' + + # `Open all` entry + entry = + type: 'thread watcher' + el: $.el 'a', + textContent: 'Open all threads' + href: 'javascript:;' + open: -> + (if ThreadWatcher.list.firstElementChild then $.rmClass else $.addClass) @el, 'disabled' + true + $.event 'AddMenuEntry', entry + $.on entry.el, 'click', ThreadWatcher.cb.openAll + + # `Prune 404'd threads` entry + entry = + type: 'thread watcher' + el: $.el 'a', + textContent: 'Prune 404\'d threads' + href: 'javascript:;' + open: -> + (if $('.dead-thread', ThreadWatcher.list) then $.rmClass else $.addClass) @el, 'disabled' + true + $.event 'AddMenuEntry', entry + $.on entry.el, 'click', ThreadWatcher.cb.pruneDeads + + # `Settings` entries: + subEntries = [] + for name, conf of Config.threadWatcher + subEntries.push ThreadWatcher.createSubEntry name, conf[1] + $.event 'AddMenuEntry', + type: 'thread watcher' + el: $.el 'span', textContent: 'Settings' + subEntries: subEntries + createSubEntry: (name, desc) -> + entry = + type: 'thread watcher' + el: $.el 'label', + innerHTML: " #{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 + addHeaderMenuEntry: -> + return if g.VIEW isnt 'thread' + ThreadWatcher.entryEl = $.el 'a', href: 'javascript:;' + entry = + type: 'header' + el: ThreadWatcher.entryEl + order: 60 + $.event 'AddMenuEntry', entry + $.on entry.el, 'click', -> ThreadWatcher.toggle g.threads["#{g.BOARD}.#{g.THREADID}"] + ready: -> $.off d, '4chanXInitFinished', ThreadWatcher.ready return unless Main.isThisPageLegit() ThreadWatcher.refresh() $.add d.body, ThreadWatcher.dialog - refresh: (watched) -> - unless watched - $.get 'WatchedThreads', {}, (item) -> - ThreadWatcher.refresh item['WatchedThreads'] - return - 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: + menuToggle: (e) -> + ThreadWatcher.menu.toggle e, @, ThreadWatcher + openAll: -> + return if $.hasClass @, 'disabled' + for a in $$ 'a[title]', ThreadWatcher.list + $.open a.href + $.event 'CloseMenu' + pruneDeads: -> + return if $.hasClass @, 'disabled' + for boardID, threads of ThreadWatcher.db.data.boards + if Conf['Current Board'] and boardID isnt g.BOARD.ID + continue + for threadID, data of threads + continue unless data.isDead + delete threads[threadID] + ThreadWatcher.db.deleteIfEmpty {boardID} + ThreadWatcher.db.save() + ThreadWatcher.refresh() + $.event 'CloseMenu' toggle: -> ThreadWatcher.toggle Get.postFromNode(@).thread - x: -> - thread = @nextElementSibling.pathname.split '/' - ThreadWatcher.unwatch thread[1], thread[3] + rm: -> + [boardID, threadID] = @parentNode.dataset.fullid.split '.' + ThreadWatcher.rm boardID, +threadID post: (e) -> {board, postID, threadID} = e.detail if postID is threadID if Conf['Auto Watch'] $.set 'AutoWatch', threadID else if Conf['Auto Watch Reply'] - ThreadWatcher.watch board.threads[threadID] + ThreadWatcher.add board.threads[threadID] + threadUpdate: (e) -> + # Update 404 status. + return unless e.detail[404] + {thread} = e.detail + return unless ThreadWatcher.db.get {boardID: thread.board.ID, threadID: thread.ID} + ThreadWatcher.add thread + + refresh: -> + nodes = [] + for boardID, threads of ThreadWatcher.db.data.boards + if Conf['Current Board'] and boardID isnt g.BOARD.ID + continue + for threadID, data of threads + 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 + + nodes.push div = $.el 'div' + div.setAttribute 'data-fullid', "#{boardID}.#{threadID}" + $.addClass div, 'dead-thread' if data.isDead + $.add div, [x, $.tn(' '), link] + + list = ThreadWatcher.dialog.lastElementChild + $.rmAll list + $.add list, nodes + + if g.VIEW is 'thread' + {entryEl} = ThreadWatcher + if div = $ "div[data-fullid='#{g.BOARD}.#{g.THREADID}']", list + $.addClass div, 'current' + $.addClass entryEl, 'unwatch-thread' + $.rmClass entryEl, 'watch-thread' + entryEl.textContent = 'Unwatch thread' + else + $.addClass entryEl, 'watch-thread' + $.rmClass entryEl, 'unwatch-thread' + entryEl.textContent = 'Watch thread' + + 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 + return toggle: (thread) -> - if $('.favicon', thread.OP.nodes.post).src is Favicon.empty - ThreadWatcher.watch thread + boardID = thread.board.ID + threadID = thread.ID + if ThreadWatcher.db.get {boardID, threadID} + ThreadWatcher.rm boardID, threadID else - ThreadWatcher.unwatch thread.board, thread.ID + ThreadWatcher.add thread + add: (thread) -> + data = excerpt: Get.threadExcerpt thread + if thread.isDead + data.isDead = true + ThreadWatcher.db.set + boardID: thread.board.ID + threadID: thread.ID + val: data + ThreadWatcher.refresh() + rm: (boardID, threadID) -> + ThreadWatcher.db.delete {boardID, threadID} + ThreadWatcher.refresh() - unwatch: (board, threadID) -> - $.get 'WatchedThreads', {}, (item) -> - watched = item['WatchedThreads'] - delete watched[board][threadID] - delete watched[board] unless Object.keys(watched[board]).length - ThreadWatcher.refresh watched - $.set 'WatchedThreads', watched - - watch: (thread) -> - $.get 'WatchedThreads', {}, (item) -> - watched = item['WatchedThreads'] - watched[thread.board] or= {} - watched[thread.board][thread] = - href: "/#{thread.board}/res/#{thread}" - textContent: Get.threadExcerpt thread - ThreadWatcher.refresh watched - $.set 'WatchedThreads', watched + convert: (oldFormat) -> + newFormat = {} + for boardID, threads of oldFormat + for threadID, data of threads + (newFormat[boardID] or= {})[threadID] = excerpt: data.textContent + newFormat