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:
parent
dd7d72a77d
commit
7b29a567b5
4 changed files with 128 additions and 93 deletions
|
@ -21,7 +21,7 @@
|
||||||
this.listenTo(this.model, 'destroy', this.remove); // auto update
|
this.listenTo(this.model, 'destroy', this.remove); // auto update
|
||||||
this.listenTo(this.model, 'opened', this.markSelected); // auto update
|
this.listenTo(this.model, 'opened', this.markSelected); // auto update
|
||||||
extension.windows.onClosed(this.stopListening.bind(this));
|
extension.windows.onClosed(this.stopListening.bind(this));
|
||||||
this.timeStampView = new Whisper.BriefTimestampView();
|
this.timeStampView = new Whisper.TimestampView({brief: true});
|
||||||
},
|
},
|
||||||
|
|
||||||
markSelected: function() {
|
markSelected: function() {
|
||||||
|
|
|
@ -6,7 +6,10 @@
|
||||||
window.Whisper = window.Whisper || {};
|
window.Whisper = window.Whisper || {};
|
||||||
|
|
||||||
Whisper.TimestampView = Whisper.View.extend({
|
Whisper.TimestampView = Whisper.View.extend({
|
||||||
initialize: function() {
|
initialize: function(options) {
|
||||||
|
if (options) {
|
||||||
|
this.brief = options.brief;
|
||||||
|
}
|
||||||
extension.windows.onClosed(this.clearTimeout.bind(this));
|
extension.windows.onClosed(this.clearTimeout.bind(this));
|
||||||
},
|
},
|
||||||
update: function() {
|
update: function() {
|
||||||
|
@ -19,102 +22,97 @@
|
||||||
if (millis >= millis_now) {
|
if (millis >= millis_now) {
|
||||||
millis = millis_now;
|
millis = millis_now;
|
||||||
}
|
}
|
||||||
// defined in subclass!
|
|
||||||
var result = this.getRelativeTimeSpanString(millis);
|
var result = this.getRelativeTimeSpanString(millis);
|
||||||
this.$el.text(result);
|
this.$el.text(result);
|
||||||
|
|
||||||
|
var timestamp = moment(millis);
|
||||||
|
this.$el.attr('title', timestamp.format('llll'));
|
||||||
|
|
||||||
var millis_since = millis_now - millis;
|
var millis_since = millis_now - millis;
|
||||||
var delay = this.computeDelay(millis_since);
|
if (this.delay) {
|
||||||
if (delay) {
|
if (this.delay < 0) { this.delay = 1000; }
|
||||||
if (delay < 0) { delay = 1000; }
|
this.timeout = setTimeout(this.update.bind(this), this.delay);
|
||||||
this.timeout = setTimeout(this.update.bind(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: function() {
|
||||||
clearTimeout(this.timeout);
|
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_) {
|
getRelativeTimeSpanString: function(timestamp_) {
|
||||||
// Convert to moment timestamp if it isn't already
|
// Convert to moment timestamp if it isn't already
|
||||||
var timestamp = moment(timestamp_),
|
var timestamp = moment(timestamp_),
|
||||||
timediff = moment.duration(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) {
|
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) {
|
} 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) {
|
} 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) {
|
} else if (timediff.hours() > 1) {
|
||||||
return timediff.hours() + ' hours';
|
this.delay = moment(timestamp).add(timediff.hours() + 1,'h').diff(moment());
|
||||||
} else if (timediff.hours() === 1 || timediff.minutes() >= 45) {
|
return this.relativeTime(timediff.hours(), 'hh');
|
||||||
// to match conversation view, display >= 45 minutes as 1 hour
|
} else if (timediff.hours() === 1) {
|
||||||
return '1 hour';
|
this.delay = moment(timestamp).add(timediff.hours() + 1,'h').diff(moment());
|
||||||
|
return this.relativeTime(timediff.hours(), 'h');
|
||||||
} else if (timediff.minutes() > 1) {
|
} else if (timediff.minutes() > 1) {
|
||||||
return timediff.minutes() + ' min';
|
this.delay = moment(timestamp).add(timediff.minutes() + 1,'m').diff(moment());
|
||||||
} else if (timediff.minutes() === 1 || timediff.seconds() >= 45) {
|
return this.relativeTime(timediff.minutes(), 'mm');
|
||||||
// same as hours/minutes, 0:00:45 -> 1 min
|
} else if (timediff.minutes() === 1) {
|
||||||
return '1 min';
|
this.delay = moment(timestamp).add(timediff.minutes() + 1,'m').diff(moment());
|
||||||
|
return this.relativeTime(timediff.minutes(), 'm');
|
||||||
} else {
|
} 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({
|
Whisper.ExtendedTimestampView = Whisper.TimestampView.extend({
|
||||||
getRelativeTimeSpanString: function(timestamp_) {
|
_relativeTime : {
|
||||||
var timestamp = moment(timestamp_),
|
s: "now",
|
||||||
now = moment(),
|
m: "1 minute ago",
|
||||||
lastWeek = moment().subtract(7, 'days'),
|
mm: "%d minutes ago",
|
||||||
lastYear = moment().subtract(1, 'years');
|
h: "1 hour ago",
|
||||||
if (timestamp > now.startOf('day')) {
|
hh: "%d hours ago",
|
||||||
// t units ago
|
d: "1 day ago",
|
||||||
return timestamp.fromNow();
|
dd: "%d days ago",
|
||||||
} else if (timestamp > lastWeek) {
|
M: "1 month ago",
|
||||||
// Fri 1:30 PM or Fri 13:30
|
MM: "%d months ago",
|
||||||
return timestamp.format('ddd ') + timestamp.format('LT');
|
y: "1 year ago",
|
||||||
} else if (timestamp > lastYear) {
|
yy: "%d years ago"
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
_format: {
|
||||||
|
y: 'MMM D, YYYY LT',
|
||||||
|
m: 'MMM D LT',
|
||||||
|
d: 'ddd LT'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -29,21 +29,21 @@ describe('MessageView', function() {
|
||||||
var view = new Whisper.MessageView({model: message});
|
var view = new Whisper.MessageView({model: message});
|
||||||
message.set({'sent_at': Date.now() - 5000});
|
message.set({'sent_at': Date.now() - 5000});
|
||||||
view.render();
|
view.render();
|
||||||
assert.match(view.$el.html(), /seconds ago/);
|
assert.match(view.$el.html(), /now/);
|
||||||
|
|
||||||
message.set({'sent_at': Date.now() - 60000});
|
message.set({'sent_at': Date.now() - 60000});
|
||||||
view.render();
|
view.render();
|
||||||
assert.match(view.$el.html(), /minute ago/);
|
assert.match(view.$el.html(), /min/);
|
||||||
|
|
||||||
message.set({'sent_at': Date.now() - 3600000});
|
message.set({'sent_at': Date.now() - 3600000});
|
||||||
view.render();
|
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() {
|
it('should not imply messages are from the future', function() {
|
||||||
var view = new Whisper.MessageView({model: message});
|
var view = new Whisper.MessageView({model: message});
|
||||||
message.set({'sent_at': Date.now() + 60000});
|
message.set({'sent_at': Date.now() + 60000});
|
||||||
view.render();
|
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() {
|
it('should go away when the model is destroyed', function() {
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
describe('TimestampView', function() {
|
describe('TimestampView', function() {
|
||||||
it('formats long-ago timestamps correctly', function() {
|
it('formats long-ago timestamps correctly', function() {
|
||||||
var timestamp = Date.now();
|
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();
|
ext_view = new Whisper.ExtendedTimestampView().render();
|
||||||
|
|
||||||
// Helper functions to check absolute and relative timestamps
|
// Helper functions to check absolute and relative timestamps
|
||||||
|
@ -40,15 +40,14 @@ describe('TimestampView', function() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// check integer timestamp, JS Date object and moment object
|
// check integer timestamp, JS Date object and moment object
|
||||||
checkAbs(timestamp, 'now', 'a few seconds ago');
|
checkAbs(timestamp, 'now', 'now');
|
||||||
checkAbs(new Date(), 'now', 'a few seconds ago');
|
checkAbs(new Date(), 'now', 'now');
|
||||||
checkAbs(moment(), 'now', 'a few seconds ago');
|
checkAbs(moment(), 'now', 'now');
|
||||||
|
|
||||||
// check recent timestamps
|
// check recent timestamps
|
||||||
checkDiff(30, 'now', 'a few seconds ago'); // 30 seconds
|
checkDiff(30, 'now', 'now'); // 30 seconds
|
||||||
checkDiff(50, '1 min', 'a minute ago'); // >= 45 seconds => 1 minute
|
|
||||||
checkDiff(40*60, '40 min', '40 minutes ago');
|
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');
|
checkDiff(125*60, '2 hours', '2 hours ago');
|
||||||
|
|
||||||
// set to third of month to avoid problems on the 29th/30th/31st
|
// 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() {
|
describe('updates within a minute reasonable intervals', function() {
|
||||||
var view = new Whisper.TimestampView();
|
var view;
|
||||||
assert.isBelow(view.computeDelay(1000), 60 * 1000); // < minute
|
beforeEach(function() {
|
||||||
assert.strictEqual(view.computeDelay(1000 * 60 * 5), 60 * 1000); // minute
|
view = new Whisper.TimestampView();
|
||||||
assert.strictEqual(view.computeDelay(1000 * 60 * 60 * 5), 60 * 60 * 1000); // hour
|
});
|
||||||
|
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
|
it('updates timestamps from this hour within a minute', function() {
|
||||||
assert.notOk(view.computeDelay(1000 * 60 * 60 * 24 * 8));
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue