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

View File

@@ -300,15 +300,17 @@ function handleTimestamp(time) {
// JavaScript dates are in milliseconds, Unix timestamps are in seconds // JavaScript dates are in milliseconds, Unix timestamps are in seconds
timeobject = moment(time * 1000); timeobject = moment(time * 1000);
} }
const istimevalid = !(timeobject === null || !timeobject.isValid());
// Generate HTML const escaped_time = _.escape(time);
let timestring = '<span class="timestamp"'; if (timeobject === null || !timeobject.isValid()) {
if (istimevalid) { // Unsupported time format: rerender accordingly.
timestring += ' data-timestamp="' + timeobject.unix() + '"'; 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) { 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 // Populate each timestamp span with mentioned time
// in user's local timezone. // in user's local timezone.
const timestamp = moment.unix($(this).attr('data-timestamp')); const time_str = $(this).attr('datetime');
if (timestamp.isValid() && $(this).attr('data-timestamp') !== null) { 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 text = $(this).text();
const rendered_time = timerender.render_markdown_timestamp(timestamp, const rendered_time = timerender.render_markdown_timestamp(timestamp,
null, text); null, text);
$(this).text(rendered_time.text); $(this).text(rendered_time.text);
$(this).attr('title', rendered_time.title); $(this).attr('title', rendered_time.title);
} else { } else {
$(this).removeClass('timestamp'); $(this).text(i18n.t('Could not parse timestamp.'));
$(this).attr('title', 'Could not parse timestamp.');
} }
}); });

View File

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

View File

@@ -33,6 +33,7 @@ import ahocorasick
import dateutil.parser import dateutil.parser
import dateutil.tz import dateutil.tz
import markdown import markdown
import pytz
import requests import requests
from django.conf import settings from django.conf import settings
from django.db.models import Q from django.db.models import Q
@@ -1229,23 +1230,34 @@ def possible_avatar_emails(content: str) -> Set[str]:
class Timestamp(markdown.inlinepatterns.Pattern): class Timestamp(markdown.inlinepatterns.Pattern):
def handleMatch(self, match: Match[str]) -> Optional[Element]: def handleMatch(self, match: Match[str]) -> Optional[Element]:
span = Element('span') time_input_string = match.group('time')
span.set('class', 'timestamp')
timestamp = None timestamp = None
try: 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: except ValueError:
try: try:
timestamp = datetime.fromtimestamp(float(match.group('time'))) timestamp = datetime.fromtimestamp(float(time_input_string))
except ValueError: except ValueError:
pass pass
if timestamp:
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: if timestamp.tzinfo:
timestamp = timestamp - timestamp.utcoffset() timestamp = timestamp.astimezone(pytz.utc)
span.set('data-timestamp', timestamp.strftime("%s")) else:
# Set text to initial input, so even if parsing fails, the data remains intact. timestamp = pytz.utc.localize(timestamp)
span.text = markdown.util.AtomicString(match.group('time')) time_element.set('datetime', timestamp.isoformat().replace('+00:00', 'Z'))
return span # 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: # All of our emojis(non ZWJ sequences) belong to one of these unicode blocks:
# \U0001f100-\U0001f1ff - Enclosed Alphanumeric Supplement # \U0001f100-\U0001f1ff - Enclosed Alphanumeric Supplement

View File

@@ -743,34 +743,34 @@
{ {
"name": "timestamp_bugdown_only", "name": "timestamp_bugdown_only",
"input": "!time(Jun 5th 2017, 10:30PM)", "input": "!time(Jun 5th 2017, 10:30PM)",
"expected_output": "<p><span class=\"timestamp\" data-timestamp=\"1496701800\">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\">Jun 5th 2017, 10:30PM</span></p>" "marked_expected_output": "<p><span class=\"timestamp-error\">Invalid time format: Jun 5th 2017, 10:30PM</span></p>"
}, },
{ {
"name": "timestamp_bugdown_and_marked", "name": "timestamp_bugdown_and_marked",
"input": "!time(31 Dec 2017)", "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", "name": "timestamp_invalid_input",
"input": "!time(<alert(1)>)", "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", "name": "timestamp_timezone",
"input": "!time(31 Dec 2017 5:30 am IST)", "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>", "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\">31 Dec 2017 5:30 am IST</span></p>" "marked_expected_output": "<p><span class=\"timestamp-error\">Invalid time format: 31 Dec 2017 5:30 am IST</span></p>"
}, },
{ {
"name": "timestamp_incorrect", "name": "timestamp_incorrect",
"input": "!time(hello world)", "input": "!time(**hello world**)",
"expected_output": "<p><span class=\"timestamp\">hello world</span></p>" "expected_output": "<p><span class=\"timestamp-error\">Invalid time format: **hello world**</span></p>"
}, },
{ {
"name": "timestamp_unix", "name": "timestamp_unix",
"input": "!time(1496701800)", "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", "name": "tex_inline",