ThreadWatcher = init: -> return if not (@enabled = Conf['Thread Watcher']) @shortcut = sc = $.el 'a', id: 'watcher-link' textContent: 'Watcher' title: 'Thread Watcher' href: 'javascript:;' className: 'fa fa-eye' @db = new DataBoard 'watchedThreads', @refresh, true @dbLM = new DataBoard 'watcherLastModified', null, true @dialog = UI.dialog 'thread-watcher', <%= readHTML('ThreadWatcher.html') %> @status = $ '#watcher-status', @dialog @list = @dialog.lastElementChild @refreshButton = $ '.refresh', @dialog @closeButton = $('.move > .close', @dialog) @unreaddb = Unread.db or UnreadIndex.db or new DataBoard 'lastReadPosts' @unreadEnabled = Conf['Remember Last Read Post'] $.on d, 'QRPostSuccessful', @cb.post $.on sc, 'click', @toggleWatcher $.on @refreshButton, 'click', @buttonFetchAll $.on @closeButton, 'click', @toggleWatcher @menu.addHeaderMenuEntry() $.onExists doc, 'body', @addDialog switch g.VIEW when 'index' $.on d, 'IndexUpdate', @cb.onIndexUpdate when 'thread' $.on d, 'ThreadUpdate', @cb.onThreadRefresh if Conf['Fixed Thread Watcher'] $.addClass doc, 'fixed-watcher' if !Conf['Persistent Thread Watcher'] $.addClass ThreadWatcher.shortcut, 'disabled' @dialog.hidden = true Header.addShortcut 'watcher', sc, 510 ThreadWatcher.initLastModified() ThreadWatcher.fetchAuto() $.on window, 'visibilitychange focus', -> $.queueTask ThreadWatcher.fetchAuto if Conf['Menu'] and Index.enabled Menu.menu.addEntry el: $.el 'a', href: 'javascript:;' className: 'has-shortcut-text' , <%= html('Alt+click') %> order: 6 open: ({thread}) -> return false if Conf['Index Mode'] isnt 'catalog' @el.firstElementChild.textContent = if ThreadWatcher.isWatched thread 'Unwatch' else 'Watch' $.off @el, 'click', @cb if @cb @cb = -> $.event 'CloseMenu' ThreadWatcher.toggle thread $.on @el, 'click', @cb true return unless g.VIEW in ['index', 'thread'] Callbacks.Post.push name: 'Thread Watcher' cb: @node Callbacks.CatalogThread.push name: 'Thread Watcher' cb: @catalogNode isWatched: (thread) -> !!ThreadWatcher.db?.get {boardID: thread.board.ID, threadID: thread.ID} isWatchedRaw: (boardID, threadID) -> !!ThreadWatcher.db?.get {boardID, threadID} setToggler: (toggler, isWatched) -> toggler.classList.toggle 'watched', isWatched toggler.title = "#{if isWatched then 'Unwatch' else 'Watch'} Thread" node: -> return if @isReply if @isClone toggler = $ '.watch-thread-link', @nodes.info else toggler = $.el 'a', href: 'javascript:;' className: 'watch-thread-link' $.before $('input', @nodes.info), toggler siteID = g.SITE.ID boardID = @board.ID threadID = @thread.ID data = ThreadWatcher.db.get {siteID, boardID, threadID} ThreadWatcher.setToggler toggler, !!data $.on toggler, 'click', ThreadWatcher.cb.toggle # Add missing excerpt for threads added by Auto Watch if data and not data.excerpt? $.queueTask => ThreadWatcher.update siteID, boardID, threadID, val: {excerpt: Get.threadExcerpt @thread} catalogNode: -> $.addClass @nodes.root, 'watched' if ThreadWatcher.isWatched @thread $.on @nodes.root, 'mousedown click', (e) => return unless e.button is 0 and e.altKey ThreadWatcher.toggle @thread if e.type is 'click' e.preventDefault() # Also on mousedown to prevent highlighting thumbnail in Firefox. addDialog: -> return unless Main.isThisPageLegit() ThreadWatcher.build() $.prepend d.body, ThreadWatcher.dialog toggleWatcher: -> $.toggleClass ThreadWatcher.shortcut, 'disabled' ThreadWatcher.dialog.hidden = !ThreadWatcher.dialog.hidden cb: openAll: -> return if $.hasClass @, 'disabled' for a in $$ 'a[title]', ThreadWatcher.list $.open a.href $.event 'CloseMenu' pruneDeads: -> return if $.hasClass @, 'disabled' for {siteID, boardID, threadID, data} in ThreadWatcher.getAll() when data.isDead ThreadWatcher.db.delete {siteID, boardID, threadID} ThreadWatcher.refresh() $.event 'CloseMenu' toggle: -> {thread} = Get.postFromNode @ ThreadWatcher.toggle thread rm: -> {siteID} = @parentNode.dataset [boardID, threadID] = @parentNode.dataset.fullID.split '.' ThreadWatcher.rm siteID, boardID, +threadID post: (e) -> {boardID, threadID, postID} = e.detail if postID is threadID if Conf['Auto Watch'] ThreadWatcher.addRaw boardID, threadID, {} else if Conf['Auto Watch Reply'] ThreadWatcher.add g.threads[boardID + '.' + threadID] or new Thread(threadID, g.boards[boardID] or new Board(boardID)) onIndexUpdate: (e) -> {db} = ThreadWatcher siteID = g.SITE.ID boardID = g.BOARD.ID nKilled = 0 for threadID, data of db.data[siteID].boards[boardID] when not data?.isDead and "#{boardID}.#{threadID}" not in e.detail.threads # Don't prune threads that have yet to appear in index. continue unless e.detail.threads.some (fullID) -> +fullID.split('.')[1] > threadID if Conf['Auto Prune'] or not (data and typeof data is 'object') # corrupt data db.delete {boardID, threadID} nKilled++ else if ThreadWatcher.unreadEnabled and Conf['Show Unread Count'] ThreadWatcher.fetchStatus {siteID, boardID, threadID, data} else db.extend {boardID, threadID, val: {isDead: true, page: undefined, lastPage: undefined, unread: undefined, quotingYou: undefined}} nKilled++ ThreadWatcher.refresh() if nKilled onThreadRefresh: (e) -> thread = g.threads[e.detail.threadID] return unless e.detail[404] and ThreadWatcher.isWatched thread # Update dead status. ThreadWatcher.add thread requests: [] fetched: 0 fetch: (url, {siteID, force}, args, cb) -> if ThreadWatcher.requests.length is 0 ThreadWatcher.status.textContent = '...' $.addClass ThreadWatcher.refreshButton, 'fa-spin' onloadend = -> return if @finished @finished = true ThreadWatcher.fetched++ if ThreadWatcher.fetched is ThreadWatcher.requests.length ThreadWatcher.clearRequests() else ThreadWatcher.status.textContent = "#{Math.round(ThreadWatcher.fetched / ThreadWatcher.requests.length * 100)}%" cb.apply @, args ajax = if siteID is g.SITE.ID then $.ajax else CrossOrigin.ajax if force delete $.lastModified.ThreadWatcher?[url] req = $.whenModified( url, 'ThreadWatcher', onloadend, {timeout: $.MINUTE, ajax} ) ThreadWatcher.requests.push req clearRequests: -> ThreadWatcher.requests = [] ThreadWatcher.fetched = 0 ThreadWatcher.status.textContent = '' $.rmClass ThreadWatcher.refreshButton, 'fa-spin' abort: -> delete ThreadWatcher.syncing for req in ThreadWatcher.requests when !req.finished req.finished = true req.abort() ThreadWatcher.clearRequests() initLastModified: -> lm = ($.lastModified['ThreadWatcher'] or= {}) for siteID, boards of ThreadWatcher.dbLM.data for boardID, data of boards.boards if ThreadWatcher.db.get {siteID, boardID} for url, date of data lm[url] = date else ThreadWatcher.dbLM.delete {siteID, boardID} return fetchAuto: -> clearTimeout ThreadWatcher.timeout return unless Conf['Auto Update Thread Watcher'] {db} = ThreadWatcher interval = if Conf['Show Page'] or (ThreadWatcher.unreadEnabled and Conf['Show Unread Count']) then 5 * $.MINUTE else 2 * $.HOUR now = Date.now() unless now - interval < (db.data.lastChecked or 0) <= now or d.hidden or not d.hasFocus() ThreadWatcher.fetchAllStatus() ThreadWatcher.timeout = setTimeout ThreadWatcher.fetchAuto, interval buttonFetchAll: -> if ThreadWatcher.syncing or ThreadWatcher.requests.length ThreadWatcher.abort() else ThreadWatcher.fetchAllStatus() fetchAllStatus: -> ThreadWatcher.status.textContent = '...' $.addClass ThreadWatcher.refreshButton, 'fa-spin' ThreadWatcher.syncing = true dbs = [ThreadWatcher.db, ThreadWatcher.unreaddb, QuoteYou.db].filter((x) -> x) n = 0 for dbi in dbs dbi.forceSync -> if (++n) is dbs.length return if !ThreadWatcher.syncing # aborted delete ThreadWatcher.syncing # XXX On vichan boards, last_modified field of threads.json does not account for sage posts. # Occasionally check replies field of catalog.json to find these posts. {db} = ThreadWatcher now = Date.now() deep = !(now - 2 * $.HOUR < (db.data.lastChecked2 or 0) <= now) boards = ThreadWatcher.getAll(true) for board in boards ThreadWatcher.fetchBoard board, deep db.setLastChecked() db.setLastChecked('lastChecked2') if deep if ThreadWatcher.fetched is ThreadWatcher.requests.length ThreadWatcher.clearRequests() fetchBoard: (board, deep) -> return unless board.some (thread) -> !thread.data.isDead force = Conf['Show Page'] and board.some((thread) -> !thread.data.page? and !thread.data.isDead and thread.data.last isnt -1) {siteID, boardID} = board[0] software = Conf['siteProperties'][siteID]?.software urlF = if deep and software is 'tinyboard' then 'catalogJSON' else 'threadsListJSON' url = SW[software]?.urls[urlF]?({siteID, boardID}) return unless url ThreadWatcher.fetch url, {siteID, force}, [board, url], ThreadWatcher.parseBoard parseBoard: (board, url) -> return unless @status is 200 {siteID, boardID} = board[0] software = Conf['siteProperties'][siteID]?.software lmDate = @getResponseHeader('Last-Modified') ThreadWatcher.dbLM.extend {siteID, boardID, val: $.item(url, lmDate)} threads = {} pageLength = 0 nThreads = 0 oldest = null try pageLength = @response[0]?.threads.length or 0 for page, i in @response for item in page.threads threads[item.no] = page: i + 1 index: nThreads modified: item.last_modified replies: item.replies nThreads++ if !oldest? or item.no < oldest oldest = item.no catch for thread in board ThreadWatcher.fetchStatus thread for thread in board {threadID, data} = thread if threads[threadID] {page, index, modified, replies} = threads[threadID] if Conf['Show Page'] lastPage = if SW[software]?.isPrunedByAge?({siteID, boardID}) threadID is oldest else index >= nThreads - pageLength ThreadWatcher.update siteID, boardID, threadID, {page, lastPage} if ThreadWatcher.unreadEnabled and Conf['Show Unread Count'] if modified isnt data.modified or (replies? and replies isnt data.replies) ThreadWatcher.db.extend {siteID, boardID, threadID, val: {modified}} ThreadWatcher.fetchStatus thread else if ThreadWatcher.unreadEnabled and Conf['Show Unread Count'] ThreadWatcher.fetchStatus thread else ThreadWatcher.update siteID, boardID, threadID, {isDead: true} return fetchStatus: (thread) -> {siteID, boardID, threadID, data, force} = thread software = Conf['siteProperties'][siteID]?.software url = SW[software]?.urls.threadJSON?({siteID, boardID, threadID}) return unless url return if data.isDead and not force return if data.last is -1 # 404 or no JSON API ThreadWatcher.fetch url, {siteID, force}, [thread], ThreadWatcher.parseStatus parseStatus: ({siteID, boardID, threadID, data}) -> software = Conf['siteProperties'][siteID]?.software if @status is 200 and @response last = @response.posts[@response.posts.length-1].no replies = @response.posts.length-1 isDead = !!@response.posts[0].archived if isDead and Conf['Auto Prune'] ThreadWatcher.rm siteID, boardID, threadID return return if last is data.last and isDead is data.isDead lastReadPost = ThreadWatcher.unreaddb.get {siteID, boardID, threadID, defaultValue: 0} unread = 0 quotingYou = false youOP = !!QuoteYou.db?.get {siteID, boardID, threadID, postID: threadID} for postObj in @response.posts continue unless postObj.no > lastReadPost continue if QuoteYou.db?.get {siteID, boardID, threadID, postID: postObj.no} unread++ if !quotingYou and !Conf['Require OP Quote Link'] and youOP and not Filter.isHidden(Build.parseJSON postObj, boardID, siteID) quotingYou = true continue continue unless !quotingYou and QuoteYou.db and postObj.com quotesYou = false regexp = SW[software].regexp.quotelinkHTML regexp.lastIndex = 0 while match = regexp.exec postObj.com if QuoteYou.db.get { siteID boardID: if match[1] then encodeURIComponent(match[1]) else boardID threadID: match[2] or threadID postID: match[3] or match[2] or threadID } quotesYou = true break if quotesYou and not Filter.isHidden(Build.parseJSON postObj, boardID, siteID) quotingYou = true ThreadWatcher.update siteID, boardID, threadID, {last, replies, isDead, unread, quotingYou} else if @status is 404 if SW[software].mayLackJSON and !data.last? ThreadWatcher.update siteID, boardID, threadID, {last: -1} else ThreadWatcher.update siteID, boardID, threadID, {isDead: true} getAll: (groupByBoard) -> all = [] for siteID, boards of ThreadWatcher.db.data for boardID, threads of boards.boards if Conf['Current Board'] and (siteID isnt g.SITE.ID or boardID isnt g.BOARD.ID) continue if groupByBoard all.push (cont = []) for threadID, data of threads when data and typeof data is 'object' (if groupByBoard then cont else all).push {siteID, boardID, threadID, data} all makeLine: (siteID, boardID, threadID, data) -> software = Conf['siteProperties'][siteID]?.software x = $.el 'a', className: 'fa fa-times' href: 'javascript:;' $.on x, 'click', ThreadWatcher.cb.rm {excerpt} = data excerpt or= "/#{boardID}/ - No.#{threadID}" excerpt = ThreadWatcher.prefixes[siteID] + excerpt if Conf['Show Site Prefix'] link = $.el 'a', href: SW[software]?.urls.thread({siteID, boardID, threadID}) or '' title: excerpt className: 'watcher-link' if Conf['Show Page'] and data.page? page = $.el 'span', textContent: "[#{data.page}]" className: 'watcher-page' $.add link, page if ThreadWatcher.unreadEnabled and Conf['Show Unread Count'] and data.unread? count = $.el 'span', textContent: "(#{data.unread})" className: 'watcher-unread' $.add link, count title = $.el 'span', textContent: excerpt className: 'watcher-title' $.add link, title div = $.el 'div' fullID = "#{boardID}.#{threadID}" div.dataset.fullID = fullID div.dataset.siteID = siteID $.addClass div, 'current' if g.VIEW is 'thread' and fullID is "#{g.BOARD}.#{g.THREADID}" $.addClass div, 'dead-thread' if data.isDead if Conf['Show Page'] $.addClass div, 'last-page' if data.lastPage div.dataset.page = data.page if data.page? if ThreadWatcher.unreadEnabled and Conf['Show Unread Count'] $.addClass div, 'replies-read' if data.unread is 0 $.addClass div, 'replies-unread' if data.unread $.addClass div, 'replies-quoting-you' if data.quotingYou $.add div, [x, $.tn(' '), link] div setPrefixes: (threads) -> prefixes = {} for {siteID} in threads continue if siteID of prefixes len = 0 prefix = '' conflicts = Object.keys(prefixes) while conflicts.length > 0 len++ prefix = siteID[...len] conflicts2 = [] for siteID2 in conflicts if siteID2[...len] is prefix conflicts2.push siteID2 else if prefixes[siteID2].length < len prefixes[siteID2] = siteID2[...len] conflicts = conflicts2 prefixes[siteID] = prefix ThreadWatcher.prefixes = prefixes build: -> nodes = [] threads = ThreadWatcher.getAll() ThreadWatcher.setPrefixes threads for {siteID, boardID, threadID, data} in threads # Add missing excerpt for threads added by Auto Watch if not data.excerpt? and siteID is g.SITE.ID and (thread = g.threads["#{boardID}.#{threadID}"]) and thread.OP ThreadWatcher.db.extend {boardID, threadID, val: {excerpt: Get.threadExcerpt thread}} nodes.push ThreadWatcher.makeLine siteID, boardID, threadID, data {list} = ThreadWatcher $.rmAll list $.add list, nodes ThreadWatcher.refreshIcon() refresh: -> ThreadWatcher.build() g.threads.forEach (thread) -> isWatched = ThreadWatcher.isWatched thread if thread.OP for post in [thread.OP, thread.OP.clones...] if (toggler = $ '.watch-thread-link', post.nodes.info) ThreadWatcher.setToggler toggler, isWatched (thread.catalogView.nodes.root.classList.toggle 'watched', isWatched if thread.catalogView) if Conf['Pin Watched Threads'] $.event 'SortIndex', {deferred: Conf['Index Mode'] isnt 'catalog'} refreshIcon: -> for className in ['replies-unread', 'replies-quoting-you'] ThreadWatcher.shortcut.classList.toggle className, !!$(".#{className}", ThreadWatcher.dialog) return update: (siteID, boardID, threadID, newData) -> return if not (data = ThreadWatcher.db?.get {siteID, boardID, threadID}) if newData.isDead and Conf['Auto Prune'] ThreadWatcher.rm siteID, boardID, threadID return if newData.isDead or newData.last is -1 for key in ['page', 'lastPage', 'unread', 'quotingyou'] when key not of newData newData[key] = undefined n = 0 n++ for key, val of newData when data[key] isnt val return unless n ThreadWatcher.db.extend {siteID, boardID, threadID, val: newData} if (line = $ "#watched-threads > [data-site-i-d='#{siteID}'][data-full-i-d='#{boardID}.#{threadID}']", ThreadWatcher.dialog) newLine = ThreadWatcher.makeLine siteID, boardID, threadID, data $.replace line, newLine ThreadWatcher.refreshIcon() else ThreadWatcher.refresh() set404: (boardID, threadID, cb) -> return cb() if not (data = ThreadWatcher.db?.get {boardID, threadID}) if Conf['Auto Prune'] ThreadWatcher.db.delete {boardID, threadID} return cb() return cb() if data.isDead and not (data.page? or data.lastPage? or data.unread? or data.quotingYou?) ThreadWatcher.db.extend {boardID, threadID, val: {isDead: true, page: undefined, lastPage: undefined, unread: undefined, quotingYou: undefined}}, cb toggle: (thread) -> siteID = g.SITE.ID boardID = thread.board.ID threadID = thread.ID if ThreadWatcher.db.get {boardID, threadID} ThreadWatcher.rm siteID, boardID, threadID else ThreadWatcher.add thread add: (thread) -> data = {} siteID = g.SITE.ID boardID = thread.board.ID threadID = thread.ID if thread.isDead if Conf['Auto Prune'] and ThreadWatcher.db.get {boardID, threadID} ThreadWatcher.rm siteID, boardID, threadID return data.isDead = true data.excerpt = Get.threadExcerpt thread if thread.OP ThreadWatcher.addRaw boardID, threadID, data addRaw: (boardID, threadID, data) -> ThreadWatcher.db.set {boardID, threadID, val: data} ThreadWatcher.refresh() thread = {siteID: g.SITE.ID, boardID, threadID, data, force: true} if Conf['Show Page'] and !data.isDead ThreadWatcher.fetchBoard [thread] else if ThreadWatcher.unreadEnabled and Conf['Show Unread Count'] ThreadWatcher.fetchStatus thread rm: (siteID, boardID, threadID) -> ThreadWatcher.db.delete {siteID, boardID, threadID} ThreadWatcher.refresh() menu: init: -> return if !Conf['Thread Watcher'] menu = @menu = new UI.Menu 'thread watcher' $.on $('.menu-button', ThreadWatcher.dialog), 'click', (e) -> menu.toggle e, @, ThreadWatcher @addMenuEntries() addHeaderMenuEntry: -> return if g.VIEW isnt 'thread' entryEl = $.el 'a', href: 'javascript:;' Header.menu.addEntry el: entryEl order: 60 open: -> [addClass, rmClass, text] = if !!ThreadWatcher.db.get {boardID: g.BOARD.ID, threadID: g.THREADID} ['unwatch-thread', 'watch-thread', 'Unwatch thread'] else ['watch-thread', 'unwatch-thread', 'Watch thread'] $.addClass entryEl, addClass $.rmClass entryEl, rmClass entryEl.textContent = text true $.on entryEl, 'click', -> ThreadWatcher.toggle g.threads["#{g.BOARD}.#{g.THREADID}"] addMenuEntries: -> entries = [] # `Open all` entry entries.push text: 'Open all threads' cb: ThreadWatcher.cb.openAll open: -> @el.classList.toggle 'disabled', !ThreadWatcher.list.firstElementChild true # `Prune dead threads` entry entries.push text: 'Prune dead threads' cb: ThreadWatcher.cb.pruneDeads open: -> @el.classList.toggle 'disabled', !$('.dead-thread', ThreadWatcher.list) true for {text, cb, open} in entries entry = el: $.el 'a', textContent: text href: 'javascript:;' $.on entry.el, 'click', cb entry.open = open.bind(entry) @menu.addEntry entry # Settings checkbox entries: for name, conf of Config.threadWatcher @addCheckbox name, conf[1] return addCheckbox: (name, desc) -> entry = type: 'thread watcher' el: UI.checkbox name, name.replace(' Thread Watcher', '') entry.el.title = desc input = entry.el.firstElementChild if name is 'Show Unread Count' and not ThreadWatcher.unreadEnabled input.disabled = true $.addClass entry.el, 'disabled' entry.el.title += '\n[Remember Last Read Post is disabled.]' $.on input, 'change', $.cb.checked $.on input, 'change', ThreadWatcher.refresh if name in ['Current Board', 'Show Page', 'Show Unread Count', 'Show Site Prefix'] $.on input, 'change', ThreadWatcher.fetchAuto if name in ['Show Page', 'Show Unread Count', 'Auto Update Thread Watcher'] @menu.addEntry entry