From 659bf231b42af414a5357f3a4e39ecc743da1759 Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Fri, 14 Sep 2012 00:16:29 +0200 Subject: [PATCH] Add Thread Updater. --- 4chan_x.user.js | 249 +++++++++++++++++++++++++++++++++++++++++++++++- script.coffee | 219 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 466 insertions(+), 2 deletions(-) diff --git a/4chan_x.user.js b/4chan_x.user.js index cac22769a..5c4dc6ab7 100644 --- a/4chan_x.user.js +++ b/4chan_x.user.js @@ -79,7 +79,7 @@ */ (function() { - var $, $$, AutoGIF, Board, Build, Clone, Conf, Config, FileInfo, Get, ImageHover, Main, Post, QuoteBacklink, QuoteCT, QuoteInline, QuoteOP, QuotePreview, Quotify, Redirect, RevealSpoilers, Sauce, Thread, Time, UI, d, g, + var $, $$, AutoGIF, Board, Build, Clone, Conf, Config, FileInfo, Get, ImageHover, Main, Post, QuoteBacklink, QuoteCT, QuoteInline, QuoteOP, QuotePreview, Quotify, Redirect, RevealSpoilers, Sauce, Thread, ThreadUpdater, Time, UI, d, g, __hasProp = {}.hasOwnProperty, __extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }; @@ -1030,6 +1030,13 @@ $.log(err, 'Image Hover'); } } + if (Conf['Thread Updater']) { + try { + ThreadUpdater.init(); + } catch (err) { + $.log(err, 'Thread Updater'); + } + } return $.ready(Main.initFeaturesReady); }, initFeaturesReady: function() { @@ -1087,7 +1094,7 @@ settings: function() { return alert('Here be settings'); }, - css: "/* general */\n.dialog.reply {\n display: block;\n border: 1px solid rgba(0, 0, 0, .25);\n padding: 0;\n}\n.move {\n cursor: move;\n}\nlabel {\n cursor: pointer;\n}\na[href=\"javascript:;\"] {\n text-decoration: none;\n}\n.warning {\n color: red;\n}\n\n/* 4chan style fixes */\n.opContainer, .op {\n display: block !important;\n}\n.post {\n overflow: visible !important;\n}\n\n/* header */\nbody.fourchan_x {\n margin-top: 2.5em;\n}\n#boardNavDesktop.reply {\n border-width: 0 0 1px;\n padding: 4px;\n position: fixed;\n top: 0;\n right: 0;\n left: 0;\n transition: opacity .1s ease-in-out;\n -o-transition: opacity .1s ease-in-out;\n -moz-transition: opacity .1s ease-in-out;\n -webkit-transition: opacity .1s ease-in-out;\n z-index: 1;\n}\n#boardNavDesktop.reply:not(:hover) {\n opacity: .4;\n transition: opacity 1.5s .5s ease-in-out;\n -o-transition: opacity 1.5s .5s ease-in-out;\n -moz-transition: opacity 1.5s .5s ease-in-out;\n -webkit-transition: opacity 1.5s .5s ease-in-out;\n}\n#boardNavDesktop.reply a {\n margin: -1px;\n}\n#settings {\n float: right;\n}\n\n/* quote */\n.quotelink.deadlink {\n text-decoration: underline !important;\n}\n.deadlink:not(.quotelink) {\n text-decoration: none !important;\n}\n.inlined {\n opacity: .5;\n}\n#qp input, .forwarded {\n display: none;\n}\n.quotelink.forwardlink,\n.backlink.forwardlink {\n text-decoration: none;\n border-bottom: 1px dashed;\n}\n.inline {\n border: 1px solid rgba(128, 128, 128, .5);\n display: table;\n margin: 2px 0;\n}\n.inline .post {\n border: 0 !important;\n display: table !important;\n margin: 0 !important;\n padding: 1px 2px !important;\n}\n#qp {\n position: fixed;\n padding: 2px 2px 5px;\n}\n#qp .post {\n border: none;\n margin: 0;\n padding: 0;\n}\n#qp img {\n max-height: 300px;\n max-width: 500px;\n}\n.qphl {\n outline: 2px solid rgba(216, 94, 49, .7);\n}\n\n/* file */\n.fileText:hover .fntrunc,\n.fileText:not(:hover) .fnfull {\n display: none;\n}\n#ihover {\n box-sizing: border-box;\n -moz-box-sizing: border-box;\n max-height: 100%;\n max-width: 75%;\n position: fixed;\n padding-bottom: 16px;\n}" + css: "/* general */\n.dialog.reply {\n display: block;\n border: 1px solid rgba(0, 0, 0, .25);\n padding: 0;\n}\n.move {\n cursor: move;\n}\nlabel {\n cursor: pointer;\n}\na[href=\"javascript:;\"] {\n text-decoration: none;\n}\n.warning {\n color: red;\n}\n\n/* 4chan style fixes */\n.opContainer, .op {\n display: block !important;\n}\n.post {\n overflow: visible !important;\n}\n\n/* header */\nbody.fourchan_x {\n margin-top: 2.5em;\n}\n#boardNavDesktop.reply {\n border-width: 0 0 1px;\n padding: 4px;\n position: fixed;\n top: 0;\n right: 0;\n left: 0;\n transition: opacity .1s ease-in-out;\n -o-transition: opacity .1s ease-in-out;\n -moz-transition: opacity .1s ease-in-out;\n -webkit-transition: opacity .1s ease-in-out;\n z-index: 1;\n}\n#boardNavDesktop.reply:not(:hover) {\n opacity: .4;\n transition: opacity 1.5s .5s ease-in-out;\n -o-transition: opacity 1.5s .5s ease-in-out;\n -moz-transition: opacity 1.5s .5s ease-in-out;\n -webkit-transition: opacity 1.5s .5s ease-in-out;\n}\n#boardNavDesktop.reply a {\n margin: -1px;\n}\n#settings {\n float: right;\n}\n\n/* thread updater */\n#updater {\n position: fixed;\n text-align: right;\n}\n#updater:not(:hover) {\n background: none;\n border: none;\n}\n#updater input[type=number] {\n width: 4em;\n}\n#updater:not(:hover) > div:not(.move) {\n display: none;\n}\n.new {\n color: limegreen;\n}\n\n/* quote */\n.quotelink.deadlink {\n text-decoration: underline !important;\n}\n.deadlink:not(.quotelink) {\n text-decoration: none !important;\n}\n.inlined {\n opacity: .5;\n}\n#qp input, .forwarded {\n display: none;\n}\n.quotelink.forwardlink,\n.backlink.forwardlink {\n text-decoration: none;\n border-bottom: 1px dashed;\n}\n.inline {\n border: 1px solid rgba(128, 128, 128, .5);\n display: table;\n margin: 2px 0;\n}\n.inline .post {\n border: 0 !important;\n display: table !important;\n margin: 0 !important;\n padding: 1px 2px !important;\n}\n#qp {\n position: fixed;\n padding: 2px 2px 5px;\n}\n#qp .post {\n border: none;\n margin: 0;\n padding: 0;\n}\n#qp img {\n max-height: 300px;\n max-width: 500px;\n}\n.qphl {\n outline: 2px solid rgba(216, 94, 49, .7);\n}\n\n/* file */\n.fileText:hover .fntrunc,\n.fileText:not(:hover) .fnfull {\n display: none;\n}\n#ihover {\n box-sizing: border-box;\n -moz-box-sizing: border-box;\n max-height: 100%;\n max-width: 75%;\n position: fixed;\n padding-bottom: 16px;\n}" }; Redirect = { @@ -2362,6 +2369,244 @@ } }; + ThreadUpdater = { + init: function() { + if (!g.REPLY) { + return; + } + return Thread.prototype.callbacks.push({ + name: 'Thread Updater', + cb: this.node + }); + }, + node: function() { + return new ThreadUpdater.Updater(this); + }, + Updater: (function() { + + function _Class(thread) { + var checked, dialog, html, input, name, title, val, _i, _len, _ref, _ref1; + this.thread = thread; + html = '
'; + _ref = Config.updater.checkbox; + for (name in _ref) { + val = _ref[name]; + title = val[1]; + checked = Conf[name] ? 'checked' : ''; + html += "
"; + } + checked = Conf['Auto Update'] ? 'checked' : ''; + html += "
\n
\n
"; + dialog = UI.dialog('updater', 'bottom: 0; right: 0;', html); + this.timer = $('#timer', dialog); + this.status = $('#status', dialog); + this.unsuccessfulFetchCount = 0; + this.lastModified = '0'; + this.threadRoot = thread.posts[thread].nodes.root.parentNode; + this.lastPost = +this.threadRoot.lastElementChild.id.slice(2); + _ref1 = $$('input', dialog); + for (_i = 0, _len = _ref1.length; _i < _len; _i++) { + input = _ref1[_i]; + switch (input.type) { + case 'checkbox': + $.on(input, 'click', this.cb.checkbox.bind(this)); + input.dispatchEvent(new Event('click')); + $.on(input, 'click', $.cb.checked); + break; + case 'number': + $.on(input, 'change', this.cb.interval.bind(this)); + input.dispatchEvent(new Event('change')); + break; + case 'button': + $.on(input, 'click', this.update.bind(this)); + } + } + $.on(d, 'QRPostSuccessful', this.cb.post.bind(this)); + $.on(d, 'visibilitychange ovisibilitychange mozvisibilitychange webkitvisibilitychange', this.cb.visibility.bind(this)); + this.set('timer', this.getInterval()); + $.add(d.body, dialog); + } + + _Class.prototype.cb = { + post: function(e) { + if (!(this['Auto Update This'] && +e.detail.threadID === this.thread.ID)) { + return; + } + this.unsuccessfulFetchCount = 0; + if (this.seconds > 2) { + return setTimeout(this.update.bind(this), 1000); + } + }, + visibility: function() { + var state; + state = d.visibilityState || d.oVisibilityState || d.mozVisibilityState || d.webkitVisibilityState; + if (state !== 'visible') { + return; + } + this.unsuccessfulFetchCount = 0; + if (this.seconds > this.interval) { + return this.set('timer', this.getInterval()); + } + }, + checkbox: function(e) { + var checked, input, name; + input = e.target; + checked = input.checked, name = input.name; + this[name] = checked; + switch (name) { + case 'Scroll BG': + return this.scrollBG = checked ? function() { + return true; + } : function() { + return !(d.hidden || d.oHidden || d.mozHidden || d.webkitHidden); + }; + case 'Auto Update This': + if (checked) { + return this.timeoutID = setTimeout(this.timeout.bind(this), 1000); + } else { + return clearTimeout(this.timeoutID); + } + } + }, + interval: function(e) { + var input, val; + input = e.target; + val = Math.max(5, parseInt(input.value, 10)); + this.interval = input.value = val; + return $.cb.value.call(input); + }, + load: function() { + switch (this.req.status) { + case 404: + this.set('timer', null); + this.set('status', '404'); + this.status.className = 'warning'; + clearTimeout(this.timeoutID); + this.thread.isDead = true; + break; + case 0: + case 304: + /* + Status Code 304: Not modified + By sending the `If-Modified-Since` header we get a proper status code, and no response. + This saves bandwidth for both the user and the servers and avoid unnecessary computation. + */ + + this.unsuccessfulFetchCount++; + this.set('timer', this.getInterval()); + this.set('status', null); + this.status.className = null; + break; + case 200: + this.lastModified = this.req.getResponseHeader('Last-Modified'); + this.parse(JSON.parse(this.req.response).posts); + this.set('timer', this.getInterval()); + break; + default: + this.unsuccessfulFetchCount++; + this.set('timer', this.getInterval()); + this.set('status', this.req.statusText); + this.status.className = 'warning'; + } + return delete this.req; + } + }; + + _Class.prototype.getInterval = function() { + var i, j; + i = this.interval; + j = Math.min(this.unsuccessfulFetchCount, 10); + if (!(d.hidden || d.oHidden || d.mozHidden || d.webkitHidden)) { + j = Math.min(j, 7); + } + return this.seconds = Math.max(i, [0, 5, 10, 15, 20, 30, 60, 90, 120, 240, 300][j]); + }; + + _Class.prototype.set = function(name, text) { + var el, node; + el = this[name]; + if (node = el.firstChild) { + return node.data = text; + } else { + return el.textContent = text; + } + }; + + _Class.prototype.timeout = function() { + var n; + this.timeoutID = setTimeout(this.timeout.bind(this), 1000); + if (!(n = --this.seconds)) { + return this.update(); + } else if (n <= -60) { + this.set('status', 'Retrying'); + this.status.className = null; + return this.update(); + } else if (n > 0) { + return this.set('timer', n); + } + }; + + _Class.prototype.update = function() { + var url; + this.seconds = 0; + this.set('timer', '...'); + if (this.req) { + this.req.onloadend = null; + this.req.abort(); + } + url = "//api.4chan.org/" + this.thread.board + "/res/" + this.thread + ".json"; + return this.req = $.ajax(url, { + onloadend: this.cb.load.bind(this) + }, { + headers: { + 'If-Modified-Since': this.lastModified + } + }); + }; + + _Class.prototype.parse = function(postObjects) { + var count, node, nodes, postObject, posts, scroll, spoilerRange, _i, _len, _ref; + if (spoilerRange = postObjects[0].custom_spoiler) { + Build.spoilerRange[this.thread.board] = spoilerRange; + } + nodes = []; + posts = []; + count = 0; + _ref = postObjects.reverse(); + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + postObject = _ref[_i]; + if (postObject.no <= this.lastPost) { + break; + } + count++; + node = Build.postFromObject(postObject, this.thread.board.ID); + nodes.unshift(node); + posts.unshift(new Post(node, this.thread, this.thread.board)); + } + if (count) { + this.set('status', "+" + count); + this.status.className = 'new'; + this.unsuccessfulFetchCount = 0; + } else { + this.set('status', null); + this.status.className = null; + this.unsuccessfulFetchCount++; + return; + } + this.lastPost = posts[count - 1].ID; + Main.callbackNodes(Post, posts); + scroll = this['Auto Scroll'] && this.scrollBG() && this.threadRoot.getBoundingClientRect().bottom - d.documentElement.clientHeight < 25; + $.add(this.threadRoot, nodes); + if (scroll) { + return nodes[0].scrollIntoView(); + } + }; + + return _Class; + + })() + }; + Main.init(); }).call(this); diff --git a/script.coffee b/script.coffee index 02060e216..f128963b2 100644 --- a/script.coffee +++ b/script.coffee @@ -830,6 +830,13 @@ Main = # XXX handle error $.log err, 'Image Hover' + if Conf['Thread Updater'] + try + ThreadUpdater.init() + catch err + # XXX handle error + $.log err, 'Thread Updater' + $.ready Main.initFeaturesReady initFeaturesReady: -> if d.title is '4chan - 404 Not Found' @@ -932,6 +939,25 @@ body.fourchan_x { float: right; } +/* thread updater */ +#updater { + position: fixed; + text-align: right; +} +#updater:not(:hover) { + background: none; + border: none; +} +#updater input[type=number] { + width: 4em; +} +#updater:not(:hover) > div:not(.move) { + display: none; +} +.new { + color: limegreen; +} + /* quote */ .quotelink.deadlink { text-decoration: underline !important; @@ -2087,6 +2113,199 @@ ImageHover = $.off @, 'mousemove', UI.hover $.off @, 'mouseout', ImageHover.mouseout +ThreadUpdater = + init: -> + return unless g.REPLY + Thread::callbacks.push + name: 'Thread Updater' + cb: @node + node: -> + new ThreadUpdater.Updater @ + + Updater: class + constructor: (@thread) -> + html = '
' + for name, val of Config.updater.checkbox + title = val[1] + checked = if Conf[name] then 'checked' else '' + html += "
" + + checked = if Conf['Auto Update'] then 'checked' else '' + html += """ +
+
+
+ """ + + dialog = UI.dialog 'updater', 'bottom: 0; right: 0;', html + + @timer = $ '#timer', dialog + @status = $ '#status', dialog + + @unsuccessfulFetchCount = 0 + @lastModified = '0' + @threadRoot = thread.posts[thread].nodes.root.parentNode + @lastPost = +@threadRoot.lastElementChild.id[2..] + + for input in $$ 'input', dialog + switch input.type + when 'checkbox' + $.on input, 'click', @cb.checkbox.bind @ + input.dispatchEvent new Event 'click' + $.on input, 'click', $.cb.checked + when 'number' + $.on input, 'change', @cb.interval.bind @ + input.dispatchEvent new Event 'change' + when 'button' + $.on input, 'click', @update.bind @ + + $.on d, 'QRPostSuccessful', @cb.post.bind @ + $.on d, 'visibilitychange ovisibilitychange mozvisibilitychange webkitvisibilitychange', @cb.visibility.bind @ + + @set 'timer', @getInterval() + $.add d.body, dialog + + cb: + post: (e) -> + return unless @['Auto Update This'] and +e.detail.threadID is @thread.ID + @unsuccessfulFetchCount = 0 + setTimeout @update.bind(@), 1000 if @seconds > 2 + visibility: -> + state = d.visibilityState or d.oVisibilityState or d.mozVisibilityState or d.webkitVisibilityState + return if state isnt 'visible' + # Reset the counter when we focus this tab. + @unsuccessfulFetchCount = 0 + if @seconds > @interval + @set 'timer', @getInterval() + checkbox: (e) -> + input = e.target + {checked, name} = input + @[name] = checked + switch name + when 'Scroll BG' + @scrollBG = + if checked + -> true + else + -> !(d.hidden or d.oHidden or d.mozHidden or d.webkitHidden) + when 'Auto Update This' + if checked + @timeoutID = setTimeout @timeout.bind(@), 1000 + else + clearTimeout @timeoutID + interval: (e) -> + input = e.target + val = Math.max 5, parseInt input.value, 10 + @interval = input.value = val + $.cb.value.call input + load: -> + switch @req.status + when 404 + @set 'timer', null + @set 'status', '404' + @status.className = 'warning' + clearTimeout @timeoutID + @thread.isDead = true + # if Conf['Unread Count'] + # Unread.title = Unread.title.match(/^.+-/)[0] + ' 404' + # else + # d.title = d.title.match(/^.+-/)[0] + ' 404' + # Unread.update true + # QR.abort() + # XXX 304 -> 0 in Opera + when 0, 304 + ### + Status Code 304: Not modified + By sending the `If-Modified-Since` header we get a proper status code, and no response. + This saves bandwidth for both the user and the servers and avoid unnecessary computation. + ### + @unsuccessfulFetchCount++ + @set 'timer', @getInterval() + @set 'status', null + @status.className = null + when 200 + @lastModified = @req.getResponseHeader 'Last-Modified' + @parse JSON.parse(@req.response).posts + @set 'timer', @getInterval() + else + @unsuccessfulFetchCount++ + @set 'timer', @getInterval() + @set 'status', @req.statusText + @status.className = 'warning' + delete @req + + getInterval: -> + i = @interval + j = Math.min @unsuccessfulFetchCount, 10 + unless d.hidden or d.oHidden or d.mozHidden or d.webkitHidden + # Lower the max refresh rate limit on visible tabs. + j = Math.min j, 7 + @seconds = Math.max i, [0, 5, 10, 15, 20, 30, 60, 90, 120, 240, 300][j] + + set: (name, text) -> + el = @[name] + if node = el.firstChild + # Prevent the creation of a new DOM Node + # by setting the text node's data. + node.data = text + else + el.textContent = text + + timeout: -> + @timeoutID = setTimeout @timeout.bind(@), 1000 + unless n = --@seconds + @update() + else if n <= -60 + @set 'status', 'Retrying' + @status.className = null + @update() + else if n > 0 + @set 'timer', n + + update: -> + @seconds = 0 + @set 'timer', '...' + if @req + # abort() triggers onloadend, we don't want that. + @req.onloadend = null + @req.abort() + url = "//api.4chan.org/#{@thread.board}/res/#{@thread}.json" + @req = $.ajax url, onloadend: @cb.load.bind @, + headers: 'If-Modified-Since': @lastModified + + parse: (postObjects) -> + if spoilerRange = postObjects[0].custom_spoiler + Build.spoilerRange[@thread.board] = spoilerRange + + nodes = [] + posts = [] + count = 0 + for postObject in postObjects.reverse() + break if postObject.no <= @lastPost # Make sure to not insert older posts. + count++ + node = Build.postFromObject postObject, @thread.board.ID + nodes.unshift node + posts.unshift new Post node, @thread, @thread.board + + if count + @set 'status', "+#{count}" + @status.className = 'new' + @unsuccessfulFetchCount = 0 + else + @set 'status', null + @status.className = null + @unsuccessfulFetchCount++ + return + + @lastPost = posts[count - 1].ID + Main.callbackNodes Post, posts + + scroll = @['Auto Scroll'] and @scrollBG() and + @threadRoot.getBoundingClientRect().bottom - d.documentElement.clientHeight < 25 + $.add @threadRoot, nodes + if scroll + nodes[0].scrollIntoView() + Main.init()