Repository: zulip/zulip

Issue:
markdown: change syntax from !time(...) to <time:...>
Suggested by @andersk [on CZO](https://chat.zulip.org/#narrow/stream/101-design/topic/time.20mentions/near/906857). 

It's better to built on established syntax constructs instead of inventing our own. With Markdown you can usually format links with `<http://example.com>`, since we already support rich links for Twitter it makes sense to expand on the *rich link* metaphor.

This change should probably be made after #15431 to avoid conflicts.



Discussion:
These changes can be done simultaneously as well, and should involve minimal changes to the code anyway. We just need to decide on a syntax.

Hello @zulip/server-markdown members, this issue was labeled with the "area: markdown" label, so you may want to check it out!

<!-- areaLabelAddition -->
I'm a little skeptical of this change concept, mainly from thinking about the spoiler syntax; I wouldn't want to ask folks to do `<

Another direction we could go is `@**time:isotime**`; that would be consistent with all of our existing mentions.  
I don’t see any world in which this is related to spoiler syntax. Spoilers are blocks and contain markdown for human consumption. Times are inline and contain a specific machine-parsed format.
Yeah, that's fair.  

I think the other big question is whether we want the typeahead PR: https://github.com/zulip/zulip/pull/15052 as well as a button, or just one or the other.
We definitely want typeahead because it's very convenient, especially if we also trigger it when entering something that looks like a date e.g. `2020-` (we would need to take care to directly jump to the entered year/month/day in the datetime picker).

A button would also be a good idea to make the feature more discoverable, since users unaware of it usually won't communicate dates in a form for which we could reasonably trigger the typeahead (e.g. `Saturday 3 PM`, `tomorrow 10h`).


Resolving patch:
diff --git a/zerver/lib/markdown/__init__.py b/zerver/lib/markdown/__init__.py
--- a/zerver/lib/markdown/__init__.py
+++ b/zerver/lib/markdown/__init__.py
@@ -1936,7 +1936,7 @@ def build_inlinepatterns(self) -> markdown.util.Registry:
         reg.register(StreamTopicPattern(get_compiled_stream_topic_link_regex(), self), 'topic', 87)
         reg.register(StreamPattern(get_compiled_stream_link_regex(), self), 'stream', 85)
         reg.register(Avatar(AVATAR_REGEX, self), 'avatar', 80)
-        reg.register(Timestamp(r'!time\((?P<time>[^)]*)\)'), 'timestamp', 75)
+        reg.register(Timestamp(r'<time:(?P<time>[^>]*?)>'), 'timestamp', 75)
         # Note that !gravatar syntax should be deprecated long term.
         reg.register(Avatar(GRAVATAR_REGEX, self), 'gravatar', 70)
         reg.register(UserGroupMentionPattern(mention.user_group_mentions, self), 'usergroupmention', 65)


Test patch:
diff --git a/frontend_tests/node_tests/composebox_typeahead.js b/frontend_tests/node_tests/composebox_typeahead.js
--- a/frontend_tests/node_tests/composebox_typeahead.js
+++ b/frontend_tests/node_tests/composebox_typeahead.js
@@ -1302,14 +1302,14 @@ run_test('begins_typeahead', () => {
     assert_typeahead_equals("#**Sweden>totally new topic", sweden_topics_to_show);
 
     // time_jump
-    assert_typeahead_equals("!tim", false);
-    assert_typeahead_equals("!timerandom", false);
-    assert_typeahead_equals("!time", ['translated: Mention a timezone-aware time']);
-    assert_typeahead_equals("!time(", ['translated: Mention a timezone-aware time']);
-    assert_typeahead_equals("!time(something", ['translated: Mention a timezone-aware time']);
-    assert_typeahead_equals("!time(something", ") ", ['translated: Mention a timezone-aware time']);
-    assert_typeahead_equals("!time(something)", false);
-    assert_typeahead_equals("!time(something) ", false); // Already completed the mention
+    assert_typeahead_equals("<tim", false);
+    assert_typeahead_equals("<timerandom", false);
+    assert_typeahead_equals("<time", ['translated: Mention a timezone-aware time']);
+    assert_typeahead_equals("<time:", ['translated: Mention a timezone-aware time']);
+    assert_typeahead_equals("<time:something", ['translated: Mention a timezone-aware time']);
+    assert_typeahead_equals("<time:something", "> ", ['translated: Mention a timezone-aware time']);
+    assert_typeahead_equals("<time:something>", ['translated: Mention a timezone-aware time']);
+    assert_typeahead_equals("<time:something> ", false); // Already completed the mention
 
     // Following tests place the cursor before the second string
     assert_typeahead_equals("#test", "ing", false);
@@ -1317,7 +1317,7 @@ run_test('begins_typeahead', () => {
     assert_typeahead_equals(":test", "ing", false);
     assert_typeahead_equals("```test", "ing", false);
     assert_typeahead_equals("~~~test", "ing", false);
-    const terminal_symbols = ',.;?!()[] "\'\n\t';
+    const terminal_symbols = ',.;?!()[]> "\'\n\t';
     terminal_symbols.split().forEach((symbol) => {
         assert_stream_list("#test", symbol);
         assert_typeahead_equals("@test", symbol, all_mentions);
diff --git a/frontend_tests/node_tests/rendered_markdown.js b/frontend_tests/node_tests/rendered_markdown.js
--- a/frontend_tests/node_tests/rendered_markdown.js
+++ b/frontend_tests/node_tests/rendered_markdown.js
@@ -69,6 +69,7 @@ const get_content_element = () => {
     $content.set_find_results('a.stream', $array([]));
     $content.set_find_results('a.stream-topic', $array([]));
     $content.set_find_results('time', $array([]));
+    $content.set_find_results('span.timestamp-error', $array([]));
     $content.set_find_results('.emoji', $array([]));
     $content.set_find_results('div.spoiler-header', $array([]));
     return $content;
@@ -164,6 +165,7 @@ run_test('timestamp', () => {
     const $timestamp_invalid = $.create('timestamp(invalid)');
     $timestamp_invalid.attr('datetime', 'invalid');
     $content.set_find_results('time', $array([$timestamp, $timestamp_invalid]));
+    blueslip.expect('error', 'Moment could not parse datetime supplied by backend: invalid');
 
     // Initial asserts
     assert.equal($timestamp.text(), 'never-been-set');
@@ -174,7 +176,23 @@ run_test('timestamp', () => {
     // Final asserts
     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(), 'translated: Could not parse timestamp.');
+    assert.equal($timestamp_invalid.text(), 'never-been-set');
+});
+
+run_test('timestamp-error', () => {
+    // Setup
+    const $content = get_content_element();
+    const $timestamp_error = $.create('timestamp-error');
+    $timestamp_error.text('Invalid time format: the-time-format');
+    $content.set_find_results('span.timestamp-error', $array([$timestamp_error]));
+
+    // Initial assert
+    assert.equal($timestamp_error.text(), 'Invalid time format: the-time-format');
+
+    rm.update_elements($content);
+
+    // Final assert
+    assert.equal($timestamp_error.text(), 'translated: Invalid time format: the-time-format');
 });
 
 run_test('emoji', () => {
@@ -205,7 +223,7 @@ run_test('spoiler-header', () => {
     const $header = $.create('div.spoiler-header');
     $content.set_find_results('div.spoiler-header', $array([$header]));
 
-    // Test that button gets appened to a spoiler header
+    // Test that the show/hide button gets added to a spoiler header.
     const label = 'My Spoiler Header';
     const toggle_button_html = '<span class="spoiler-button" aria-expanded="false"><span class="spoiler-arrow"></span></span>';
     $header.html(label);
@@ -219,7 +237,7 @@ run_test('spoiler-header-empty-fill', () => {
     const $header = $.create('div.spoiler-header');
     $content.set_find_results('div.spoiler-header', $array([$header]));
 
-    // Test that an empty header gets the default text applied (through i18n filter)
+    // Test that an empty header gets the default text applied (through i18n filter).
     const toggle_button_html = '<span class="spoiler-button" aria-expanded="false"><span class="spoiler-arrow"></span></span>';
     $header.html('');
     rm.update_elements($content);
diff --git a/frontend_tests/node_tests/timerender.js b/frontend_tests/node_tests/timerender.js
--- a/frontend_tests/node_tests/timerender.js
+++ b/frontend_tests/node_tests/timerender.js
@@ -2,6 +2,7 @@ set_global('$', global.make_zjquery());
 set_global('page_params', {
     twenty_four_hour_time: true,
 });
+set_global('moment', require('moment-timezone'));
 set_global('XDate', zrequire('XDate', 'xdate'));
 zrequire('timerender');
 
@@ -130,6 +131,16 @@ run_test('get_full_time', () => {
     assert.equal(expected, actual);
 });
 
+run_test('get_timestamp_for_flatpickr', () => {
+    const unix_timestamp = 1495091573000; // 5/18/2017 7:12:53 AM (UTC+0)
+    const iso_timestamp = '2017-05-18T07:12:53Z'; // ISO 8601 date format
+    const func = timerender.get_timestamp_for_flatpickr;
+    // Invalid timestamps should show current time.
+    assert.equal(func("random str").valueOf(), moment().valueOf());
+    // Valid ISO timestamps should return Date objects.
+    assert.equal(func(iso_timestamp).valueOf(), moment(unix_timestamp).valueOf());
+});
+
 run_test('absolute_time_12_hour', () => {
     set_global('page_params', {
         twenty_four_hour_time: false,
diff --git a/zerver/tests/fixtures/markdown_test_cases.json b/zerver/tests/fixtures/markdown_test_cases.json
--- a/zerver/tests/fixtures/markdown_test_cases.json
+++ b/zerver/tests/fixtures/markdown_test_cases.json
@@ -748,34 +748,36 @@
     },
     {
       "name": "timestamp_backend_markdown_only",
-      "input": "!time(Jun 5th 2017, 10:30PM)",
+      "input": "<time:Jun 5th 2017, 10:30PM>",
       "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>"
+      "marked_expected_output": "<p><span>Jun 5th 2017, 10:30PM</span></p>"
     },
     {
       "name": "timestamp_backend_markdown_and_marked",
-      "input": "!time(31 Dec 2017)",
+      "input": "<time:31 Dec 2017>",
       "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-error\">Invalid time format: &lt;alert(1</span>&gt;)</p>"
+      "input": "<time:<alert(1)>>",
+      "expected_output": "<p><span class=\"timestamp-error\">Invalid time format: &lt;alert(1)</span>&gt;</p>",
+      "marked_expected_output": "<p><span>&lt;alert(1)</span>&gt;</p>"
     },
     {
       "name": "timestamp_timezone",
-      "input": "!time(31 Dec 2017 5:30 am IST)",
+      "input": "<time:31 Dec 2017 5:30 am IST>",
       "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>"
+      "marked_expected_output": "<p><span>31 Dec 2017 5:30 am IST</span></p>"
     },
     {
       "name": "timestamp_incorrect",
-      "input": "!time(**hello world**)",
-      "expected_output": "<p><span class=\"timestamp-error\">Invalid time format: **hello world**</span></p>"
+      "input": "<time:**hello world**>",
+      "expected_output": "<p><span class=\"timestamp-error\">Invalid time format: **hello world**</span></p>",
+      "marked_expected_output": "<p><span>**hello world**</span></p>"
     },
     {
       "name": "timestamp_unix",
-      "input": "!time(1496701800)",
+      "input": "<time:1496701800>",
       "expected_output": "<p><time datetime=\"2017-06-05T22:30:00Z\">1496701800</time></p>"
     },
     {