markdown: Use html5 <time> tag for timestamps.

Previously, we had implemented:
    <span class="timestamp" data-timestamp="unix time">Original text</span>
The new syntax is:
    <time timestamp="ISO 8601 string">Original text</time>
    <span class="timestamp-error">Invalid time format: Original text</span>

Since python and JS interpretations of the ISO format are very
slightly different, we force both of them to drop milliseconds
and use 'Z' instead of '+00:00' to represent that the string is
in UTC. The resultant strings look like: 2011-04-11T10:20:30Z.

Fixes #15431.
This commit is contained in:
Rohitt Vashishtha
2020-06-18 05:02:24 +05:30
committed by Tim Abbott
parent 9423d150ac
commit 6ea3816fa6
6 changed files with 60 additions and 42 deletions

View File

@@ -68,7 +68,7 @@ const get_content_element = () => {
$content.set_find_results('.user-group-mention', $array([]));
$content.set_find_results('a.stream', $array([]));
$content.set_find_results('a.stream-topic', $array([]));
$content.set_find_results('span.timestamp', $array([]));
$content.set_find_results('time', $array([]));
$content.set_find_results('.emoji', $array([]));
$content.set_find_results('div.spoiler-header', $array([]));
return $content;
@@ -160,11 +160,10 @@ run_test('timestamp', () => {
// Setup
const $content = get_content_element();
const $timestamp = $.create('timestamp(valid)');
$timestamp.attr('data-timestamp', 1);
$timestamp.attr('datetime', '1970-01-01T00:00:01Z');
const $timestamp_invalid = $.create('timestamp(invalid)');
$timestamp.addClass('timestamp');
$timestamp_invalid.addClass('timestamp');
$content.set_find_results('span.timestamp', $array([$timestamp, $timestamp_invalid]));
$timestamp_invalid.attr('datetime', 'invalid');
$content.set_find_results('time', $array([$timestamp, $timestamp_invalid]));
// Initial asserts
assert.equal($timestamp.text(), 'never-been-set');
@@ -173,12 +172,9 @@ run_test('timestamp', () => {
rm.update_elements($content);
// Final asserts
assert($timestamp.hasClass('timestamp'));
assert(!$timestamp_invalid.hasClass('timestamp'));
assert.equal($timestamp.text(), 'Thu, Jan 1 1970, 12:00 AM');
assert.equal($timestamp.attr('title'), "This time is in your timezone. Original text was 'never-been-set'.");
assert.equal($timestamp_invalid.text(), 'never-been-set');
assert.equal($timestamp_invalid.attr('title'), 'Could not parse timestamp.');
assert.equal($timestamp_invalid.text(), 'translated: Could not parse timestamp.');
});
run_test('emoji', () => {

View File

@@ -300,15 +300,17 @@ function handleTimestamp(time) {
// JavaScript dates are in milliseconds, Unix timestamps are in seconds
timeobject = moment(time * 1000);
}
const istimevalid = !(timeobject === null || !timeobject.isValid());
// Generate HTML
let timestring = '<span class="timestamp"';
if (istimevalid) {
timestring += ' data-timestamp="' + timeobject.unix() + '"';
const escaped_time = _.escape(time);
if (timeobject === null || !timeobject.isValid()) {
// Unsupported time format: rerender accordingly.
return `<span class="timestamp-error">Invalid time format: ${escaped_time}</span>`;
}
timestring += '>' + _.escape(time) + '</span>';
return timestring;
// Use html5 <time> tag for valid timestamps.
// render time without milliseconds.
const escaped_isotime = _.escape(timeobject.toISOString().split('.')[0] + 'Z');
return `<time datetime="${escaped_isotime}">${escaped_time}</time>`;
}
function handleStream(stream_name) {

View File

@@ -137,19 +137,27 @@ exports.update_elements = (content) => {
}
});
content.find('span.timestamp').each(function () {
content.find('time').each(function () {
// Populate each timestamp span with mentioned time
// in user's local timezone.
const timestamp = moment.unix($(this).attr('data-timestamp'));
if (timestamp.isValid() && $(this).attr('data-timestamp') !== null) {
const time_str = $(this).attr('datetime');
if (time_str === undefined) {
return;
}
// Moment throws a large deprecation warning when it has to fallback
// to the Date() constructor. We needn't worry here and can let bugdown
// handle any dates that moment misses.
moment.suppressDeprecationWarnings = true;
const timestamp = moment(time_str);
if (timestamp.isValid()) {
const text = $(this).text();
const rendered_time = timerender.render_markdown_timestamp(timestamp,
null, text);
$(this).text(rendered_time.text);
$(this).attr('title', rendered_time.title);
} else {
$(this).removeClass('timestamp');
$(this).attr('title', 'Could not parse timestamp.');
$(this).text(i18n.t('Could not parse timestamp.'));
}
});

View File

@@ -142,7 +142,7 @@
}
/* Timestamps */
.timestamp {
time {
background: hsl(0, 0%, 93%);
border-radius: 3px;
padding: 0 0.2em;

View File

@@ -33,6 +33,7 @@ import ahocorasick
import dateutil.parser
import dateutil.tz
import markdown
import pytz
import requests
from django.conf import settings
from django.db.models import Q
@@ -1229,23 +1230,34 @@ def possible_avatar_emails(content: str) -> Set[str]:
class Timestamp(markdown.inlinepatterns.Pattern):
def handleMatch(self, match: Match[str]) -> Optional[Element]:
span = Element('span')
span.set('class', 'timestamp')
time_input_string = match.group('time')
timestamp = None
try:
timestamp = dateutil.parser.parse(match.group('time'), tzinfos=get_common_timezones())
timestamp = dateutil.parser.parse(time_input_string, tzinfos=get_common_timezones())
except ValueError:
try:
timestamp = datetime.fromtimestamp(float(match.group('time')))
timestamp = datetime.fromtimestamp(float(time_input_string))
except ValueError:
pass
if timestamp:
if timestamp.tzinfo:
timestamp = timestamp - timestamp.utcoffset()
span.set('data-timestamp', timestamp.strftime("%s"))
# Set text to initial input, so even if parsing fails, the data remains intact.
span.text = markdown.util.AtomicString(match.group('time'))
return span
if not timestamp:
error_element = Element('span')
error_element.set('class', 'timestamp-error')
error_element.text = markdown.util.AtomicString(
f"Invalid time format: {time_input_string}")
return error_element
# Use HTML5 <time> element for valid timestamps.
time_element = Element('time')
if timestamp.tzinfo:
timestamp = timestamp.astimezone(pytz.utc)
else:
timestamp = pytz.utc.localize(timestamp)
time_element.set('datetime', timestamp.isoformat().replace('+00:00', 'Z'))
# Set text to initial input, so simple clients translating
# HTML to text will at least display something.
time_element.text = markdown.util.AtomicString(time_input_string)
return time_element
# All of our emojis(non ZWJ sequences) belong to one of these unicode blocks:
# \U0001f100-\U0001f1ff - Enclosed Alphanumeric Supplement

View File

@@ -743,34 +743,34 @@
{
"name": "timestamp_bugdown_only",
"input": "!time(Jun 5th 2017, 10:30PM)",
"expected_output": "<p><span class=\"timestamp\" data-timestamp=\"1496701800\">Jun 5th 2017, 10:30PM</span></p>",
"marked_expected_output": "<p><span class=\"timestamp\">Jun 5th 2017, 10:30PM</span></p>"
"expected_output": "<p><time datetime=\"2017-06-05T22:30:00Z\">Jun 5th 2017, 10:30PM</time></p>",
"marked_expected_output": "<p><span class=\"timestamp-error\">Invalid time format: Jun 5th 2017, 10:30PM</span></p>"
},
{
"name": "timestamp_bugdown_and_marked",
"input": "!time(31 Dec 2017)",
"expected_output": "<p><span class=\"timestamp\" data-timestamp=\"1514678400\">31 Dec 2017</span></p>"
"expected_output": "<p><time datetime=\"2017-12-31T00:00:00Z\">31 Dec 2017</time></p>"
},
{
"name": "timestamp_invalid_input",
"input": "!time(<alert(1)>)",
"expected_output": "<p><span class=\"timestamp\">&lt;alert(1</span>&gt;)</p>"
"expected_output": "<p><span class=\"timestamp-error\">Invalid time format: &lt;alert(1</span>&gt;)</p>"
},
{
"name": "timestamp_timezone",
"input": "!time(31 Dec 2017 5:30 am IST)",
"expected_output": "<p><span class=\"timestamp\" data-timestamp=\"1514678400\">31 Dec 2017 5:30 am IST</span></p>",
"marked_expected_output": "<p><span class=\"timestamp\">31 Dec 2017 5:30 am IST</span></p>"
"expected_output": "<p><time datetime=\"2017-12-31T00:00:00Z\">31 Dec 2017 5:30 am IST</time></p>",
"marked_expected_output": "<p><span class=\"timestamp-error\">Invalid time format: 31 Dec 2017 5:30 am IST</span></p>"
},
{
"name": "timestamp_incorrect",
"input": "!time(hello world)",
"expected_output": "<p><span class=\"timestamp\">hello world</span></p>"
"input": "!time(**hello world**)",
"expected_output": "<p><span class=\"timestamp-error\">Invalid time format: **hello world**</span></p>"
},
{
"name": "timestamp_unix",
"input": "!time(1496701800)",
"expected_output": "<p><span class=\"timestamp\" data-timestamp=\"1496701800\">1496701800</span></p>"
"expected_output": "<p><time datetime=\"2017-06-05T22:30:00Z\">1496701800</time></p>"
},
{
"name": "tex_inline",