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
This commit is contained in:
lilia 2016-04-18 19:48:54 -07:00
parent dd7d72a77d
commit 7b29a567b5
4 changed files with 128 additions and 93 deletions

View file

@ -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() {

View file

@ -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'
}
});
})();

View file

@ -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() {

View file

@ -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);
});
});