From b49664a11ae81b1c52303079d39cba30cdad3049 Mon Sep 17 00:00:00 2001 From: queue Date: Thu, 7 Feb 2013 01:49:15 -0700 Subject: [PATCH 1/2] Implement relative dates, close #758 Squash of 21 commits: -- Document "%R" relative time formatter Sounds reasonable, don't you think? -- Implement %R time formatter original code do not steal -- Add absolute time as title on date elements -- Update relative post times if necessary Coupled code, I know. Would be better to have a 'threadupdate' event to subscribe to, oh well. -- Update changelog -- Rewrite relative date formatter function Aeosynth-approved, or at least acknoledged -- Reimplement relative post times in namespace Less quick-and-dirty, more right. Still need to polyfill visibility -- Polyfill and correct usage of page visibility Reselecting the tab always flushes (since ui thread block will be percieved as part of the tab switch), but background flushes don't queue when tab is not visible. This is actually a better implementation than html5chan has right now, will need to backport it later. -- Remove %R reference in documentation -- Clean up RelativeDates code Aeosynth approved (TM) (C) -- Move page visibility polyfill to Main -- Remove unnecessary ? -- Replace $.hidden() fn with d.hidden -- Use visibility polyfill for QR -- Remove all future relative dates Looks like it's all over after this. -- Compact pluralize condition -- Fix alignment -- Change RelativeDates.flush -> @flush -- Remove initial setTimeout Is unnecessary, as node is called, which will call flush at least once and set the timeout again. -- Improve absolute date display and cache diff Title tooltip now uses either time formatting rice or the original 4chan timestamp instead of toString(). the diff between a date and now is now shared between `relative` and `setUpdate` to avoid recalculation. -- Remove re-calling of Time formatting Relying on the ordering of `node` callbacks, so textContext will already have the formatted time. --- 4chan_x.user.js | 103 ++++++++++++++++++++++++++++++++++---- changelog | 2 + script.coffee | 129 +++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 216 insertions(+), 18 deletions(-) diff --git a/4chan_x.user.js b/4chan_x.user.js index a97adabe6..5c9007b86 100644 --- a/4chan_x.user.js +++ b/4chan_x.user.js @@ -81,7 +81,7 @@ */ (function() { - var $, $$, Anonymize, ArchiveLink, AutoGif, Build, CatalogLinks, Conf, Config, DeleteLink, DownloadLink, ExpandComment, ExpandThread, Favicon, FileInfo, Filter, Get, ImageExpand, ImageHover, Keybinds, Main, Menu, Nav, Options, QR, QuoteBacklink, QuoteCT, QuoteInline, QuoteOP, QuotePreview, Quotify, Redirect, ReplyHiding, ReportLink, RevealSpoilers, Sauce, StrikethroughQuotes, ThreadHiding, ThreadStats, Time, TitlePost, UI, Unread, Updater, Watcher, d, g, _base; + var $, $$, Anonymize, ArchiveLink, AutoGif, Build, CatalogLinks, Conf, Config, DeleteLink, DownloadLink, ExpandComment, ExpandThread, Favicon, FileInfo, Filter, Get, ImageExpand, ImageHover, Keybinds, Main, Menu, Nav, Options, QR, QuoteBacklink, QuoteCT, QuoteInline, QuoteOP, QuotePreview, Quotify, Redirect, RelativeDates, ReplyHiding, ReportLink, RevealSpoilers, Sauce, StrikethroughQuotes, ThreadHiding, ThreadStats, Time, TitlePost, UI, Unread, Updater, Watcher, d, g, _base; Config = { main: { @@ -91,6 +91,7 @@ '404 Redirect': [true, 'Redirect dead threads and images'], 'Keybinds': [true, 'Binds actions to keys'], 'Time Formatting': [true, 'Arbitrarily formatted timestamps, using your local time'], + 'Relative Post Dates': [false, 'Display post times as "3 minutes ago" or similar. Hover tooltip will display the original or formatted timestamp'], 'File Info Formatting': [true, 'Reformats the file information'], 'Comment Expansion': [true, 'Expand too long comments'], 'Thread Expansion': [true, 'View all replies'], @@ -516,8 +517,19 @@ size = unit > 1 ? Math.round(size * 100) / 100 : Math.round(size); return "" + size + " " + ['B', 'KB', 'MB', 'GB'][unit]; }, - hidden: function() { - return d.hidden || d.oHidden || d.mozHidden || d.webkitHidden; + debounce: function(wait, fn) { + var timeout; + timeout = null; + return function() { + if (timeout) { + clearTimeout(timeout); + } else { + fn.apply(this, arguments); + } + return timeout = setTimeout((function() { + return timeout = null; + }), wait); + }; } }); @@ -1841,7 +1853,7 @@ if (QR.captcha.isEnabled && /captcha|verification/i.test(el.textContent)) { $('[autocomplete]', QR.el).focus(); } - if ($.hidden()) { + if (d.hidden) { return alert(el.textContent); } }, @@ -3033,7 +3045,7 @@ } $.add(d.body, dialog); $.on(d, 'QRPostSuccessful', this.cb.post); - return $.on(d, 'visibilitychange ovisibilitychange mozvisibilitychange webkitvisibilitychange', this.cb.visibility); + return $.on(d, 'visibilitychange', this.cb.visibility); }, /* http://freesound.org/people/pierrecartoons1979/sounds/90112/ @@ -3052,7 +3064,7 @@ return setTimeout(Updater.update, 500); }, visibility: function() { - if ($.hidden()) { + if (d.hidden) { return; } Updater.unsuccessfulFetchCount = 0; @@ -3088,7 +3100,7 @@ return Updater.scrollBG = this.checked ? function() { return true; } : function() { - return !$.hidden(); + return !d.hidden; }; }, load: function() { @@ -3159,7 +3171,7 @@ Updater.count.className = count ? 'new' : null; } if (count) { - if (Conf['Beep'] && $.hidden() && (Unread.replies.length === 0)) { + if (Conf['Beep'] && d.hidden && (Unread.replies.length === 0)) { Updater.audio.play(); } Updater.unsuccessfulFetchCount = 0; @@ -3187,7 +3199,7 @@ var i, j; i = +Conf['Interval']; j = Math.min(this.unsuccessfulFetchCount, 9); - if (!$.hidden()) { + if (!d.hidden) { j = Math.min(j, 6); } return Math.max(i, [5, 10, 15, 20, 30, 60, 90, 120, 240, 300][j]); @@ -3537,6 +3549,59 @@ } }; + RelativeDates = { + INTERVAL: $.MINUTE, + init: function() { + Main.callbacks.push(this.node); + return $.on(d, 'visibilitychange', this.flush); + }, + node: function(post) { + var dateEl, diff, utc; + dateEl = $('.postInfo > .dateTime', post.el); + utc = dateEl.dataset.utc * 1000; + dateEl.title = dateEl.textContent; + diff = Date.now() - utc; + dateEl.textContent = RelativeDates.relative(diff); + RelativeDates.setUpdate(dateEl, diff); + return RelativeDates.flush(); + }, + relative: function(diff) { + var number, rounded, unit; + unit = (number = diff / $.DAY) > 1 ? 'day' : (number = diff / $.HOUR) > 1 ? 'hour' : (number = diff / $.MINUTE) > 1 ? 'minute' : (number = diff / $.SECOND, 'second'); + rounded = Math.round(number); + if (rounded !== 1) { + unit += 's'; + } + return "" + rounded + " " + unit + " ago"; + }, + stale: [], + flush: $.debounce($.SECOND, function() { + var dateEl, diff, _i, _len, _ref; + if (d.hidden) { + return; + } + _ref = RelativeDates.stale; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + dateEl = _ref[_i]; + if (d.contains(dateEl)) { + diff = Date.now() - dateEl.dataset.utc * 1000; + dateEl.textContent = RelativeDates.relative(diff); + RelativeDates.setUpdate(dateEl, diff); + } + } + RelativeDates.stale = []; + clearTimeout(RelativeDates.timeout); + return RelativeDates.timeout = setTimeout(RelativeDates.flush, RelativeDates.INTERVAL); + }), + setUpdate: function(dateEl, diff) { + var delay; + delay = diff > $.HOUR ? diff % $.HOUR : diff > $.MINUTE ? diff % $.MINUTE : diff % $.SECOND; + return setTimeout((function() { + return RelativeDates.stale.push(dateEl); + }), delay); + } + }; + FileInfo = { init: function() { if (g.BOARD === 'f') { @@ -4782,7 +4847,6 @@ return "//archive.foolz.us/" + board + "/full_image/" + filename; case 'u': return "//nsfw.foolz.us/" + board + "/full_image/" + filename; - case 'int': case 'po': return "http://archive.thedarkcave.org/" + board + "/full_image/" + filename; case 'ck': @@ -5308,12 +5372,28 @@ settings.disableAll = true; localStorage.setItem('4chan-settings', JSON.stringify(settings)); } + Main.polyfill(); if (g.CATALOG) { return $.ready(Main.catalog); } else { return Main.features(); } }, + polyfill: function() { + var event, prefix, property; + if (!('visibilityState' in document)) { + prefix = 'mozVisibilityState' in document ? 'moz' : 'webkitvisibilityState' in document ? 'webkit' : 'o'; + property = prefix + 'VisibilityState'; + event = prefix + 'visibilitychange'; + d.visibilityState = d[property]; + d.hidden = d.visibilityState === 'hidden'; + return $.on(d, event, function() { + d.visibilityState = d[property]; + d.hidden = d.visibilityState === 'hidden'; + return $.event(d, new CustomEvent('visibilitychange')); + }); + } + }, catalog: function() { if (Conf['Catalog Links']) { CatalogLinks.init(); @@ -5375,6 +5455,9 @@ if (Conf['Time Formatting']) { Time.init(); } + if (Conf['Relative Post Dates']) { + RelativeDates.init(); + } if (Conf['File Info Formatting']) { FileInfo.init(); } diff --git a/changelog b/changelog index aceb901c2..8e774c7b0 100644 --- a/changelog +++ b/changelog @@ -1,6 +1,8 @@ master - Mayhem Add /int/ archive redirection for threads, and post resurrection. +- Queue + Add relative time ("35 seconds ago") date formatting option. 2.37.6 - Mayhem diff --git a/script.coffee b/script.coffee index fd573c7ba..6acfa8868 100644 --- a/script.coffee +++ b/script.coffee @@ -6,6 +6,7 @@ Config = '404 Redirect': [true, 'Redirect dead threads and images'] 'Keybinds': [true, 'Binds actions to keys'] 'Time Formatting': [true, 'Arbitrarily formatted timestamps, using your local time'] + 'Relative Post Dates': [false, 'Display post times as "3 minutes ago" or similar. Hover tooltip will display the original or formatted timestamp'] 'File Info Formatting': [true, 'Reformats the file information'] 'Comment Expansion': [true, 'Expand too long comments'] 'Thread Expansion': [true, 'View all replies'] @@ -397,8 +398,19 @@ $.extend $, # Round to an integer otherwise. Math.round size "#{size} #{['B', 'KB', 'MB', 'GB'][unit]}" - hidden: -> - d.hidden or d.oHidden or d.mozHidden or d.webkitHidden + # a function that will execute at most every 'wait' ms. executes immediately + # if possible, else discards invocation + debounce: (wait, fn) -> + timeout = null + return -> + if timeout + # stop current reset + clearTimeout timeout + else + fn.apply this, arguments + + # after wait, let next invocation execute immediately + timeout = setTimeout (-> timeout = null), wait $.cache.requests = {} @@ -1422,7 +1434,7 @@ QR = if QR.captcha.isEnabled and /captcha|verification/i.test el.textContent # Focus the captcha input on captcha error. $('[autocomplete]', QR.el).focus() - alert el.textContent if $.hidden() + alert el.textContent if d.hidden cleanError: -> $('.warning', QR.el).textContent = null @@ -2458,7 +2470,7 @@ Updater = $.add d.body, dialog $.on d, 'QRPostSuccessful', @cb.post - $.on d, 'visibilitychange ovisibilitychange mozvisibilitychange webkitvisibilitychange', @cb.visibility + $.on d, 'visibilitychange', @cb.visibility ### http://freesound.org/people/pierrecartoons1979/sounds/90112/ @@ -2472,7 +2484,7 @@ Updater = Updater.unsuccessfulFetchCount = 0 setTimeout Updater.update, 500 visibility: -> - return if $.hidden() + return if d.hidden # Reset the counter when we focus this tab. Updater.unsuccessfulFetchCount = 0 if Updater.timer.textContent < -Conf['Interval'] @@ -2500,7 +2512,7 @@ Updater = if @checked -> true else - -> ! $.hidden() + -> ! d.hidden load: -> switch @status when 404 @@ -2555,7 +2567,7 @@ Updater = Updater.count.className = if count then 'new' else null if count - if Conf['Beep'] and $.hidden() and (Unread.replies.length is 0) + if Conf['Beep'] and d.hidden and (Unread.replies.length is 0) Updater.audio.play() Updater.unsuccessfulFetchCount = 0 else @@ -2580,7 +2592,7 @@ Updater = getInterval: -> i = +Conf['Interval'] j = Math.min @unsuccessfulFetchCount, 9 - unless $.hidden() + unless d.hidden # Don't increase the refresh rate too much on visible tabs. j = Math.min j, 6 Math.max i, [5, 10, 15, 20, 30, 60, 90, 120, 240, 300][j] @@ -2827,6 +2839,82 @@ Time = S: -> Time.zeroPad Time.date.getSeconds() y: -> Time.date.getFullYear() - 2000 +RelativeDates = + INTERVAL: $.MINUTE + init: -> + Main.callbacks.push @node + + # flush when page becomes visible again + $.on d, 'visibilitychange', @flush + node: (post) -> + dateEl = $ '.postInfo > .dateTime', post.el + + utc = dateEl.dataset.utc * 1000 # convert data-utc to milliseconds + + # Show original absolute time as tooltip so users can still know exact times + # Since "Time Formatting" runs `node` before us, the title tooltip will + # pick up the user-formatted time instead of 4chan time when enabled. + dateEl.title = dateEl.textContent + + diff = Date.now() - utc + + dateEl.textContent = RelativeDates.relative diff + RelativeDates.setUpdate dateEl, diff + + # Main calls @node whenever a DOM node is added (update, inline post, + # whatever), so use also this reflow opportunity to flush any other dates + # flush is debounced, so this won't burn too much cpu + RelativeDates.flush() + + # diff is milliseconds from now + relative: (diff) -> + unit = if (number = (diff / $.DAY)) > 1 + 'day' + else if (number = (diff / $.HOUR)) > 1 + 'hour' + else if (number = (diff / $.MINUTE)) > 1 + 'minute' + else + number = diff / $.SECOND + 'second' + + rounded = Math.round number + unit += 's' if rounded isnt 1 # pluralize + + "#{rounded} #{unit} ago" + + # changing all relative dates as soon as possible incurs many annoying + # redraws and scroll stuttering. Thus, sacrifice accuracy for UX/CPU economy, + # and perform redraws when the DOM is otherwise being manipulated (and scroll + # stuttering won't be noticed), falling back to INTERVAL while the page + # is visible. + # + # each individual dateTime element will be added to the stale list when it + # needs to change. + stale: [] + flush: $.debounce($.SECOND, -> + # no point in changing the dates until the user sees them + return if d.hidden + for dateEl in RelativeDates.stale + if d.contains dateEl # not removed from DOM + diff = Date.now() - dateEl.dataset.utc * 1000 + dateEl.textContent = RelativeDates.relative diff + RelativeDates.setUpdate dateEl, diff + RelativeDates.stale = [] + clearTimeout RelativeDates.timeout + RelativeDates.timeout = setTimeout RelativeDates.flush, RelativeDates.INTERVAL) + + # Add element to stale list when the relative date string would change + # diff is in milliseconds between dateEl and now + setUpdate: (dateEl, diff) -> + delay = if diff > $.HOUR + diff % $.HOUR + else if diff > $.MINUTE + diff % $.MINUTE + else + diff % $.SECOND + setTimeout (-> RelativeDates.stale.push dateEl), delay + FileInfo = init: -> return if g.BOARD is 'f' @@ -4274,11 +4362,33 @@ Main = settings.disableAll = true localStorage.setItem '4chan-settings', JSON.stringify settings + Main.polyfill() + if g.CATALOG $.ready Main.catalog else Main.features() + polyfill: -> + # page visibility API + unless 'visibilityState' of document + prefix = if 'mozVisibilityState' of document + 'moz' + else if 'webkitvisibilityState' of document + 'webkit' + else + 'o' + + property = prefix + 'VisibilityState' + event = prefix + 'visibilitychange' + + d.visibilityState = d[property] + d.hidden = d.visibilityState is 'hidden' + $.on d, event, -> + d.visibilityState = d[property] + d.hidden = d.visibilityState is 'hidden' + $.event d, new CustomEvent 'visibilitychange' + catalog: -> if Conf['Catalog Links'] CatalogLinks.init() @@ -4337,6 +4447,9 @@ Main = if Conf['Time Formatting'] Time.init() + if Conf['Relative Post Dates'] + RelativeDates.init() + if Conf['File Info Formatting'] FileInfo.init() From bed90cfd0317f1bce20a8924117549114c9805d9 Mon Sep 17 00:00:00 2001 From: queue Date: Thu, 7 Feb 2013 10:31:35 -0700 Subject: [PATCH 2/2] Reduce recalculation needed for RelativeDates Capture more state in closure scopes. Pretty clever, at the expense of simplicity. --- 4chan_x.user.js | 39 +++++++++++++++++++++-------------- script.coffee | 54 +++++++++++++++++++++++++++++++------------------ 2 files changed, 58 insertions(+), 35 deletions(-) diff --git a/4chan_x.user.js b/4chan_x.user.js index 5c9007b86..47e916545 100644 --- a/4chan_x.user.js +++ b/4chan_x.user.js @@ -3558,11 +3558,11 @@ node: function(post) { var dateEl, diff, utc; dateEl = $('.postInfo > .dateTime', post.el); - utc = dateEl.dataset.utc * 1000; dateEl.title = dateEl.textContent; + utc = dateEl.dataset.utc * 1000; diff = Date.now() - utc; dateEl.textContent = RelativeDates.relative(diff); - RelativeDates.setUpdate(dateEl, diff); + RelativeDates.setUpdate(dateEl, utc, diff); return RelativeDates.flush(); }, relative: function(diff) { @@ -3576,29 +3576,38 @@ }, stale: [], flush: $.debounce($.SECOND, function() { - var dateEl, diff, _i, _len, _ref; + var now, update, _i, _len, _ref; if (d.hidden) { return; } + now = Date.now(); _ref = RelativeDates.stale; for (_i = 0, _len = _ref.length; _i < _len; _i++) { - dateEl = _ref[_i]; - if (d.contains(dateEl)) { - diff = Date.now() - dateEl.dataset.utc * 1000; - dateEl.textContent = RelativeDates.relative(diff); - RelativeDates.setUpdate(dateEl, diff); - } + update = _ref[_i]; + update(now); } RelativeDates.stale = []; clearTimeout(RelativeDates.timeout); return RelativeDates.timeout = setTimeout(RelativeDates.flush, RelativeDates.INTERVAL); }), - setUpdate: function(dateEl, diff) { - var delay; - delay = diff > $.HOUR ? diff % $.HOUR : diff > $.MINUTE ? diff % $.MINUTE : diff % $.SECOND; - return setTimeout((function() { - return RelativeDates.stale.push(dateEl); - }), delay); + setUpdate: function(dateEl, utc, diff) { + var markStale, setOwnTimeout, update; + setOwnTimeout = function(diff) { + var delay; + delay = diff > $.HOUR ? diff % $.HOUR : diff > $.MINUTE ? diff % $.MINUTE : diff % $.SECOND; + return setTimeout(markStale, delay); + }; + update = function(now) { + if (d.contains(dateEl)) { + diff = now - utc; + dateEl.textContent = RelativeDates.relative(diff); + return setOwnTimeout(diff); + } + }; + markStale = function() { + return RelativeDates.stale.push(update); + }; + return setOwnTimeout(diff); } }; diff --git a/script.coffee b/script.coffee index 6acfa8868..7a004c54d 100644 --- a/script.coffee +++ b/script.coffee @@ -2849,17 +2849,18 @@ RelativeDates = node: (post) -> dateEl = $ '.postInfo > .dateTime', post.el - utc = dateEl.dataset.utc * 1000 # convert data-utc to milliseconds - # Show original absolute time as tooltip so users can still know exact times # Since "Time Formatting" runs `node` before us, the title tooltip will # pick up the user-formatted time instead of 4chan time when enabled. dateEl.title = dateEl.textContent + # convert data-utc to milliseconds + utc = dateEl.dataset.utc * 1000 + diff = Date.now() - utc dateEl.textContent = RelativeDates.relative diff - RelativeDates.setUpdate dateEl, diff + RelativeDates.setUpdate dateEl, utc, diff # Main calls @node whenever a DOM node is added (update, inline post, # whatever), so use also this reflow opportunity to flush any other dates @@ -2889,31 +2890,44 @@ RelativeDates = # stuttering won't be noticed), falling back to INTERVAL while the page # is visible. # - # each individual dateTime element will be added to the stale list when it - # needs to change. + # each individual dateTime element will add its update() function to the stale list + # when it to be called. stale: [] flush: $.debounce($.SECOND, -> # no point in changing the dates until the user sees them return if d.hidden - for dateEl in RelativeDates.stale - if d.contains dateEl # not removed from DOM - diff = Date.now() - dateEl.dataset.utc * 1000 - dateEl.textContent = RelativeDates.relative diff - RelativeDates.setUpdate dateEl, diff + + now = Date.now() + update now for update in RelativeDates.stale RelativeDates.stale = [] + + # reset automatic flush clearTimeout RelativeDates.timeout RelativeDates.timeout = setTimeout RelativeDates.flush, RelativeDates.INTERVAL) - # Add element to stale list when the relative date string would change - # diff is in milliseconds between dateEl and now - setUpdate: (dateEl, diff) -> - delay = if diff > $.HOUR - diff % $.HOUR - else if diff > $.MINUTE - diff % $.MINUTE - else - diff % $.SECOND - setTimeout (-> RelativeDates.stale.push dateEl), delay + # create function `update()`, closed over dateEl and diff, that, when called + # from `flush()`, updates the element, and re-calls `setOwnTimeout()` to + # re-add `update()` to the stale list later. + setUpdate: (dateEl, utc, diff) -> + setOwnTimeout = (diff) -> + delay = if diff > $.HOUR + diff % $.HOUR + else if diff > $.MINUTE + diff % $.MINUTE + else + diff % $.SECOND + setTimeout markStale, delay + + update = (now) -> + if d.contains dateEl # not removed from DOM + diff = now - utc + dateEl.textContent = RelativeDates.relative diff + setOwnTimeout diff + + markStale = -> RelativeDates.stale.push update + + # kick off initial timeout with current diff + setOwnTimeout diff FileInfo = init: ->