From 7b29a567b5fd75223fdeeadb0788ebd2963a5ab2 Mon Sep 17 00:00:00 2001 From: lilia Date: Mon, 18 Apr 2016 19:48:54 -0700 Subject: [PATCH] More consistent timestamps * Apply the same rounding to in message bubbles and conversation list. Also make them consistent with Android's relative times. Fixes #682 * Show full timestamps when hovering on relative time * Compute timestamp update delays more precisely: Set timestamps to self-update as soon as they are able to change rather than a fixed time since the last update. * Refactor for customizable/localizable relative times * Update timestamp tests * Log timestamp update intervals to help debug #460 --- js/views/conversation_list_item_view.js | 2 +- js/views/timestamp_view.js | 144 ++++++++++++------------ test/views/message_view_test.js | 8 +- test/views/timestamp_view_test.js | 67 ++++++++--- 4 files changed, 128 insertions(+), 93 deletions(-) diff --git a/js/views/conversation_list_item_view.js b/js/views/conversation_list_item_view.js index be1fb4d1..e127eb34 100644 --- a/js/views/conversation_list_item_view.js +++ b/js/views/conversation_list_item_view.js @@ -21,7 +21,7 @@ this.listenTo(this.model, 'destroy', this.remove); // auto update this.listenTo(this.model, 'opened', this.markSelected); // auto update extension.windows.onClosed(this.stopListening.bind(this)); - this.timeStampView = new Whisper.BriefTimestampView(); + this.timeStampView = new Whisper.TimestampView({brief: true}); }, markSelected: function() { diff --git a/js/views/timestamp_view.js b/js/views/timestamp_view.js index 953347b4..f7a69469 100644 --- a/js/views/timestamp_view.js +++ b/js/views/timestamp_view.js @@ -6,7 +6,10 @@ window.Whisper = window.Whisper || {}; Whisper.TimestampView = Whisper.View.extend({ - initialize: function() { + initialize: function(options) { + if (options) { + this.brief = options.brief; + } extension.windows.onClosed(this.clearTimeout.bind(this)); }, update: function() { @@ -19,102 +22,97 @@ if (millis >= millis_now) { millis = millis_now; } - // defined in subclass! var result = this.getRelativeTimeSpanString(millis); this.$el.text(result); + var timestamp = moment(millis); + this.$el.attr('title', timestamp.format('llll')); + var millis_since = millis_now - millis; - var delay = this.computeDelay(millis_since); - if (delay) { - if (delay < 0) { delay = 1000; } - this.timeout = setTimeout(this.update.bind(this), delay); + if (this.delay) { + if (this.delay < 0) { this.delay = 1000; } + this.timeout = setTimeout(this.update.bind(this), this.delay); + console.log('ts', timestamp.valueOf(), result, + timestamp.format('YYYY-MM-DD HH:mm:ss.SSS'), + 'next update at', + moment().add(this.delay, 'ms').format('YYYY-MM-DD HH:mm:ss.SSS') + ); } }, clearTimeout: function() { clearTimeout(this.timeout); }, - computeDelay: function(millis_since) { - var delay; - if (millis_since <= moment.relativeTimeThreshold('s') * 1000) { - // a few seconds ago - delay = 45 * 1000 - millis_since; - } else if (millis_since <= moment.relativeTimeThreshold('m') * 1000 * 60) { - // N minutes ago - delay = 60 * 1000; - } else if (millis_since <= moment.relativeTimeThreshold('h') * 1000 * 60 * 60) { - // N hours ago - delay = 60 * 60 * 1000; - } else { // more than a week ago - // Day of week + time - delay = 7 * 24 * 60 * 60 * 1000 - millis_since; - - if (delay < -(60 * 1000)) { - // more than one week and one minute ago - // don't do any further updates as the displayed timestamp - // won't change any more - return; - } - } - return delay; - } - }); - - Whisper.BriefTimestampView = Whisper.TimestampView.extend({ getRelativeTimeSpanString: function(timestamp_) { // Convert to moment timestamp if it isn't already var timestamp = moment(timestamp_), timediff = moment.duration(moment() - timestamp); - // Do some wrapping to match conversation view, display >= 30 minutes (seconds) - // as a full hour (minute) if the number of hours (minutes) isn't zero - if (timediff.hours >= 1 && timediff.minutes() >= 30) { - timediff.add(1, 'hours'); - } else if (timediff.minutes() >= 1 && timediff.seconds() >= 30) { - timediff.add(1, 'minutes'); - } - if (timediff.years() > 0) { - return timestamp.format('MMM D, YYYY'); + this.delay = null; + return timestamp.format(this._format['y']); } else if (timediff.months() > 0 || timediff.days() > 6) { - return timestamp.format('MMM D'); + this.delay = null; + return timestamp.format(this._format['m']); } else if (timediff.days() > 0) { - return timestamp.format('ddd'); + this.delay = moment(timestamp).add(timediff.days() + 1,'d').diff(moment()); + return timestamp.format(this._format['d']); } else if (timediff.hours() > 1) { - return timediff.hours() + ' hours'; - } else if (timediff.hours() === 1 || timediff.minutes() >= 45) { - // to match conversation view, display >= 45 minutes as 1 hour - return '1 hour'; + this.delay = moment(timestamp).add(timediff.hours() + 1,'h').diff(moment()); + return this.relativeTime(timediff.hours(), 'hh'); + } else if (timediff.hours() === 1) { + this.delay = moment(timestamp).add(timediff.hours() + 1,'h').diff(moment()); + return this.relativeTime(timediff.hours(), 'h'); } else if (timediff.minutes() > 1) { - return timediff.minutes() + ' min'; - } else if (timediff.minutes() === 1 || timediff.seconds() >= 45) { - // same as hours/minutes, 0:00:45 -> 1 min - return '1 min'; + this.delay = moment(timestamp).add(timediff.minutes() + 1,'m').diff(moment()); + return this.relativeTime(timediff.minutes(), 'mm'); + } else if (timediff.minutes() === 1) { + this.delay = moment(timestamp).add(timediff.minutes() + 1,'m').diff(moment()); + return this.relativeTime(timediff.minutes(), 'm'); } else { - return 'now'; + this.delay = moment(timestamp).add(1,'m').diff(moment()); + return this.relativeTime(timediff.seconds(), 's'); } }, + relativeTime : function (number, string, isFuture) { + return this._relativeTime[string].replace(/%d/i, number); + }, + _relativeTime : { + s: "now", + m: "1 min", + mm: "%d min", + h: "1 hour", + hh: "%d hours", + d: "1 day", + dd: "%d days", + M: "1 month", + MM: "%d months", + y: "1 year", + yy: "%d years" + }, + _format: { + y: 'MMM D, YYYY', + m: 'MMM D', + d: 'ddd' + } }); - - Whisper.ExtendedTimestampView = Whisper.TimestampView.extend({ - getRelativeTimeSpanString: function(timestamp_) { - var timestamp = moment(timestamp_), - now = moment(), - lastWeek = moment().subtract(7, 'days'), - lastYear = moment().subtract(1, 'years'); - if (timestamp > now.startOf('day')) { - // t units ago - return timestamp.fromNow(); - } else if (timestamp > lastWeek) { - // Fri 1:30 PM or Fri 13:30 - return timestamp.format('ddd ') + timestamp.format('LT'); - } else if (timestamp > lastYear) { - // Oct 31 1:30 PM or Oct 31 - return timestamp.format('MMM D ') + timestamp.format('LT'); - } else { - // Oct 31, 2012 1:30 PM - return timestamp.format('MMM D, YYYY ') + timestamp.format('LT'); - } + _relativeTime : { + s: "now", + m: "1 minute ago", + mm: "%d minutes ago", + h: "1 hour ago", + hh: "%d hours ago", + d: "1 day ago", + dd: "%d days ago", + M: "1 month ago", + MM: "%d months ago", + y: "1 year ago", + yy: "%d years ago" }, + _format: { + y: 'MMM D, YYYY LT', + m: 'MMM D LT', + d: 'ddd LT' + } }); })(); diff --git a/test/views/message_view_test.js b/test/views/message_view_test.js index 5b38050d..e88230e7 100644 --- a/test/views/message_view_test.js +++ b/test/views/message_view_test.js @@ -29,21 +29,21 @@ describe('MessageView', function() { var view = new Whisper.MessageView({model: message}); message.set({'sent_at': Date.now() - 5000}); view.render(); - assert.match(view.$el.html(), /seconds ago/); + assert.match(view.$el.html(), /now/); message.set({'sent_at': Date.now() - 60000}); view.render(); - assert.match(view.$el.html(), /minute ago/); + assert.match(view.$el.html(), /min/); message.set({'sent_at': Date.now() - 3600000}); view.render(); - assert.match(view.$el.html(), /hour ago/); + assert.match(view.$el.html(), /hour/); }); it('should not imply messages are from the future', function() { var view = new Whisper.MessageView({model: message}); message.set({'sent_at': Date.now() + 60000}); view.render(); - assert.match(view.$el.html(), /seconds ago/); + assert.match(view.$el.html(), /now/); }); it('should go away when the model is destroyed', function() { diff --git a/test/views/timestamp_view_test.js b/test/views/timestamp_view_test.js index d3b93d4e..3615666e 100644 --- a/test/views/timestamp_view_test.js +++ b/test/views/timestamp_view_test.js @@ -6,7 +6,7 @@ describe('TimestampView', function() { it('formats long-ago timestamps correctly', function() { var timestamp = Date.now(); - var brief_view = new Whisper.BriefTimestampView().render(), + var brief_view = new Whisper.TimestampView({brief: true}).render(), ext_view = new Whisper.ExtendedTimestampView().render(); // Helper functions to check absolute and relative timestamps @@ -40,15 +40,14 @@ describe('TimestampView', function() { }; // check integer timestamp, JS Date object and moment object - checkAbs(timestamp, 'now', 'a few seconds ago'); - checkAbs(new Date(), 'now', 'a few seconds ago'); - checkAbs(moment(), 'now', 'a few seconds ago'); + checkAbs(timestamp, 'now', 'now'); + checkAbs(new Date(), 'now', 'now'); + checkAbs(moment(), 'now', 'now'); // check recent timestamps - checkDiff(30, 'now', 'a few seconds ago'); // 30 seconds - checkDiff(50, '1 min', 'a minute ago'); // >= 45 seconds => 1 minute + checkDiff(30, 'now', 'now'); // 30 seconds checkDiff(40*60, '40 min', '40 minutes ago'); - checkDiff(60*60, '1 hour', 'an hour ago'); + checkDiff(60*60, '1 hour', '1 hour ago'); checkDiff(125*60, '2 hours', '2 hours ago'); // set to third of month to avoid problems on the 29th/30th/31st @@ -72,16 +71,54 @@ describe('TimestampView', function() { }); - it('updates at reasonable intervals', function() { - var view = new Whisper.TimestampView(); - assert.isBelow(view.computeDelay(1000), 60 * 1000); // < minute - assert.strictEqual(view.computeDelay(1000 * 60 * 5), 60 * 1000); // minute - assert.strictEqual(view.computeDelay(1000 * 60 * 60 * 5), 60 * 60 * 1000); // hour + describe('updates within a minute reasonable intervals', function() { + var view; + beforeEach(function() { + view = new Whisper.TimestampView(); + }); + afterEach(function() { + clearTimeout(view.timeout); + }); - assert.isBelow(view.computeDelay(6 * 24 * 60 * 60 * 1000), 7 * 24 * 60 * 60 * 1000); // < week + it('updates timestamps this minute within a minute', function() { + var now = Date.now(); + view.$el.attr('data-timestamp', now - 1000); + view.update(); + assert.isAbove(view.delay, 0); // non zero + assert.isBelow(view.delay, 60 * 1000); // < minute + }); - // return falsey value for long ago dates that don't update - assert.notOk(view.computeDelay(1000 * 60 * 60 * 24 * 8)); + it('updates timestamps from this hour within a minute', function() { + var now = Date.now(); + view.$el.attr('data-timestamp', now - 1000 - 1000*60*5); // 5 minutes and 1 sec ago + view.update(); + assert.isAbove(view.delay, 0); // non zero + assert.isBelow(view.delay, 60 * 1000); // minute + }); + + it('updates timestamps from today within an hour', function() { + var now = Date.now(); + view.$el.attr('data-timestamp', now - 1000 - 1000*60*60*5); // 5 hours and 1 sec ago + view.update(); + assert.isAbove(view.delay, 60 * 1000); // minute + assert.isBelow(view.delay, 60 * 60 * 1000); // hour + }); + + it('updates timestamps from this week within a day', function() { + var now = Date.now(); + view.$el.attr('data-timestamp', now - 1000 - 6*24*60*60*1000); // 6 days and 1 sec ago + view.update(); + assert.isAbove(view.delay, 60 * 60 * 1000); // hour + assert.isBelow(view.delay, 24 * 60 * 60 * 1000); // day + }); + + it('does not updates very old timestamps', function() { + var now = Date.now(); + // return falsey value for long ago dates that don't update + view.$el.attr('data-timestamp', now - 8*24*60*60*1000); + view.update(); + assert.notOk(view.delay); + }); });