diff --git a/4chan_x.user.js b/4chan_x.user.js index 17851beaa..190d83a1a 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,68 @@ } }; + 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); + dateEl.title = dateEl.textContent; + utc = dateEl.dataset.utc * 1000; + diff = Date.now() - utc; + dateEl.textContent = RelativeDates.relative(diff); + RelativeDates.setUpdate(dateEl, utc, 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 now, update, _i, _len, _ref; + if (d.hidden) { + return; + } + now = Date.now(); + _ref = RelativeDates.stale; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + update = _ref[_i]; + update(now); + } + RelativeDates.stale = []; + clearTimeout(RelativeDates.timeout); + return RelativeDates.timeout = setTimeout(RelativeDates.flush, RelativeDates.INTERVAL); + }), + 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); + } + }; + FileInfo = { init: function() { if (g.BOARD === 'f') { @@ -5307,12 +5381,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(); @@ -5374,6 +5464,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 a9658ab9a..90b0de36e 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,96 @@ 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 + + # 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, 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 + # 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 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 + + 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) + + # 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: -> return if g.BOARD is 'f' @@ -4274,11 +4376,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 +4461,9 @@ Main = if Conf['Time Formatting'] Time.init() + if Conf['Relative Post Dates'] + RelativeDates.init() + if Conf['File Info Formatting'] FileInfo.init()