diff --git a/.eslintrc.json b/.eslintrc.json index 469f158a13..9f6a6aeea0 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -303,6 +303,7 @@ "settings_ui": false, "settings_user_groups": false, "settings_users": false, + "spoilers": false, "starred_messages": false, "stream_color": false, "stream_create": false, diff --git a/frontend_tests/node_tests/rendered_markdown.js b/frontend_tests/node_tests/rendered_markdown.js index 6b17c3d900..21caa616fb 100644 --- a/frontend_tests/node_tests/rendered_markdown.js +++ b/frontend_tests/node_tests/rendered_markdown.js @@ -70,6 +70,7 @@ const get_content_element = () => { $content.set_find_results('a.stream-topic', $array([])); $content.set_find_results('span.timestamp', $array([])); $content.set_find_results('.emoji', $array([])); + $content.set_find_results('div.spoiler-header', $array([])); return $content; }; @@ -197,4 +198,34 @@ run_test('emoji', () => { rm.update_elements($content); assert(called); + + // Set page paramaters back so that test run order is independent + page_params.emojiset = 'apple'; +}); + +run_test('spoiler-header', () => { + // Setup + const $content = get_content_element(); + const $header = $.create('div.spoiler-header'); + $content.set_find_results('div.spoiler-header', $array([$header])); + + // Test that button gets appened to a spoiler header + const label = 'My Spoiler Header'; + const toggle_button_html = ''; + $header.html(label); + rm.update_elements($content); + assert.equal(toggle_button_html + label, $header.html()); +}); + +run_test('spoiler-header-empty-fill', () => { + // Setup + const $content = get_content_element(); + 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) + const toggle_button_html = ''; + $header.html(''); + rm.update_elements($content); + assert.equal(toggle_button_html + '

translated: Spoiler

', $header.html()); }); diff --git a/frontend_tests/node_tests/ui_init.js b/frontend_tests/node_tests/ui_init.js index e502337944..2f45f5b58e 100644 --- a/frontend_tests/node_tests/ui_init.js +++ b/frontend_tests/node_tests/ui_init.js @@ -84,6 +84,7 @@ zrequire('color_data'); zrequire('stream_data'); zrequire('muting'); zrequire('condense'); +zrequire('spoilers'); zrequire('lightbox'); zrequire('overlays'); zrequire('invite'); diff --git a/frontend_tests/zjsunit/zjquery.js b/frontend_tests/zjsunit/zjquery.js index 4889c30be7..e48f2cabca 100644 --- a/frontend_tests/zjsunit/zjquery.js +++ b/frontend_tests/zjsunit/zjquery.js @@ -144,6 +144,10 @@ exports.make_new_elem = function (selector, opts) { classes.set(class_name, true); return self; }, + append: function (arg) { + html = html + arg; + return self; + }, attr: function (name, val) { if (val === undefined) { return attrs.get(name); @@ -284,6 +288,10 @@ exports.make_new_elem = function (selector, opts) { parents_selector + ' in ' + selector); return result; }, + prepend: function (arg) { + html = arg + html; + return self; + }, prop: function (name, val) { if (val === undefined) { return properties.get(name); diff --git a/static/images/help/spoiler-collapsed.png b/static/images/help/spoiler-collapsed.png new file mode 100644 index 0000000000..0de87b1b6d Binary files /dev/null and b/static/images/help/spoiler-collapsed.png differ diff --git a/static/images/help/spoiler-expanded.png b/static/images/help/spoiler-expanded.png new file mode 100644 index 0000000000..91c446a487 Binary files /dev/null and b/static/images/help/spoiler-expanded.png differ diff --git a/static/js/bundles/app.js b/static/js/bundles/app.js index a1c5795b6e..96c0d8d51f 100644 --- a/static/js/bundles/app.js +++ b/static/js/bundles/app.js @@ -199,6 +199,7 @@ import "../settings_ui.js"; import "../search_pill.js"; import "../search_pill_widget.js"; import "../stream_ui_updates.js"; +import "../spoilers.js"; // Import Styles diff --git a/static/js/click_handlers.js b/static/js/click_handlers.js index 641dfd7f06..e902ea54be 100644 --- a/static/js/click_handlers.js +++ b/static/js/click_handlers.js @@ -11,7 +11,7 @@ exports.initialize = function () { function is_clickable_message_element(target) { return target.is("a") || target.is("img.message_inline_image") || target.is("img.twitter-avatar") || target.is("div.message_length_controller") || target.is("textarea") || target.is("input") || - target.is("i.edit_content_button") || + target.is("i.edit_content_button") || target.is(".spoiler-arrow") || target.is(".highlight") && target.parent().is("a"); } diff --git a/static/js/fenced_code.js b/static/js/fenced_code.js index dedb439179..e70b214f8b 100644 --- a/static/js/fenced_code.js +++ b/static/js/fenced_code.js @@ -5,14 +5,20 @@ // auto-completing code blocks missing a trailing close. // See backend fenced_code.py:71 for associated regexp -const fencestr = "^(~{3,}|`{3,})" + // Opening Fence +const fencestr = "^(~{3,}|`{3,})" + // Opening Fence "[ ]*" + // Spaces "(" + "\\{?\\.?" + "([a-zA-Z0-9_+-./#]*)" + // Language "\\}?" + + ")" + "[ ]*" + // Spaces - ")$"; + "(" + + "\\{?\\.?" + + "([^~`]*)" + // Header (see fenced_code.py) + "\\}?" + + ")" + + "$"; const fence_re = new RegExp(fencestr); // Default stashing function does nothing @@ -52,6 +58,20 @@ function wrap_tex(tex) { } } +function wrap_spoiler(header, text, stash_func) { + const output = []; + const header_div_open_html = '
'; + const end_header_start_content_html = '
'; + + output.push(stash_func(header_div_open_html)); + output.push(header); + output.push(stash_func(end_header_start_content_html)); + output.push(text); + output.push(stash_func(footer_html)); + return output.join("\n\n"); +} + exports.set_stash_func = function (stash_handler) { stash_func = stash_handler; }; @@ -62,7 +82,7 @@ exports.process_fenced_code = function (content) { const handler_stack = []; let consume_line; - function handler_for_fence(output_lines, fence, lang) { + function handler_for_fence(output_lines, fence, lang, header) { // lang is ignored except for 'quote', as we // don't do syntax highlighting yet return (function () { @@ -108,6 +128,26 @@ exports.process_fenced_code = function (content) { }; } + if (lang === 'spoiler') { + return { + handle_line: function (line) { + if (line === fence) { + this.done(); + } else { + lines.push(line); + } + }, + + done: function () { + const text = wrap_spoiler(header, lines.join('\n'), stash_func); + output_lines.push(''); + output_lines.push(text); + output_lines.push(''); + handler_stack.pop(); + }, + }; + } + return { handle_line: function (line) { if (line === fence) { @@ -146,7 +186,8 @@ exports.process_fenced_code = function (content) { if (match) { const fence = match[1]; const lang = match[3]; - const handler = handler_for_fence(output_lines, fence, lang); + const header = match[5]; + const handler = handler_for_fence(output_lines, fence, lang, header); handler_stack.push(handler); } else { output_lines.push(line); diff --git a/static/js/rendered_markdown.js b/static/js/rendered_markdown.js index 6085e3c7f4..cfcbcb0043 100644 --- a/static/js/rendered_markdown.js +++ b/static/js/rendered_markdown.js @@ -153,6 +153,19 @@ exports.update_elements = (content) => { } }); + content.find('div.spoiler-header').each(function () { + // If a spoiler block has no header content, it should have a default header + // We do this client side to allow for i18n by the client + if ($.trim($(this).html()).length === 0) { + $(this).append(`

${i18n.t('Spoiler')}

`); + } + + // Add the expand/collapse button to spoiler blocks + const toggle_button_html = ''; + $(this).prepend(toggle_button_html); + }); + + // Display emoji (including realm emoji) as text if // page_params.emojiset is 'text'. if (page_params.emojiset === 'text') { diff --git a/static/js/spoilers.js b/static/js/spoilers.js new file mode 100644 index 0000000000..8a51a8c4ce --- /dev/null +++ b/static/js/spoilers.js @@ -0,0 +1,66 @@ +function collapse_spoiler(spoiler) { + const spoiler_height = spoiler.prop('scrollHeight'); + + // Set height to rendered height on next frame, then to zero on following + // frame to allow CSS transition animation to work + requestAnimationFrame(function () { + spoiler.height(spoiler_height + 'px'); + spoiler.removeClass("spoiler-content-open"); + + requestAnimationFrame(function () { + spoiler.height("0px"); + }); + }); +} + +function expand_spoiler(spoiler) { + // Normally, the height of the spoiler block is not defined absolutely on + // the `spoiler-content-open` class, but just set to `auto` (i.e. the height + // of the content). CSS animations do not work with properties set to + // `auto`, so we get the actual height of the content here and temporarily + // put it explicitly on the element styling to allow the transition to work. + const spoiler_height = spoiler.prop('scrollHeight'); + spoiler.height(spoiler_height + "px"); + // The `spoiler-content-open` class has CSS animations defined on it which + // will trigger on the frame after this class change. + spoiler.addClass("spoiler-content-open"); + + spoiler.on('transitionend', function () { + spoiler.off('transitionend'); + // When the CSS transition is over, reset the height to auto + // This keeps things working if, e.g., the viewport is resized + spoiler.height(""); + }); +} + +exports.initialize = function () { + $("body").on("click", ".spoiler-button", function (e) { + e.preventDefault(); + e.stopPropagation(); + + const arrow = $(this).children('.spoiler-arrow'); + const spoiler_content = $(this).parent().siblings(".spoiler-content"); + + if (spoiler_content.hasClass("spoiler-content-open")) { + // Content was open, we are collapsing + arrow.removeClass("spoiler-button-open"); + + // Modify ARIA roles for screen readers + $(this).attr("aria-expanded", "false"); + spoiler_content.attr("aria-hidden", "true"); + + collapse_spoiler(spoiler_content); + } else { + // Content was closed, we are expanding + arrow.addClass("spoiler-button-open"); + + // Modify ARIA roles for screen readers + $(this).attr("aria-expanded", "true"); + spoiler_content.attr("aria-hidden", "false"); + + expand_spoiler(spoiler_content); + } + }); +}; + +window.spoilers = exports; diff --git a/static/js/ui_init.js b/static/js/ui_init.js index c40a723ff8..95e531b22d 100644 --- a/static/js/ui_init.js +++ b/static/js/ui_init.js @@ -438,6 +438,7 @@ exports.initialize_everything = function () { subs.initialize(); stream_list.initialize(); condense.initialize(); + spoilers.initialize(); lightbox.initialize(); click_handlers.initialize(); copy_and_paste.initialize(); diff --git a/static/styles/rendered_markdown.scss b/static/styles/rendered_markdown.scss index 577711954e..a1ef8eb2dc 100644 --- a/static/styles/rendered_markdown.scss +++ b/static/styles/rendered_markdown.scss @@ -163,6 +163,89 @@ color: hsl(0, 0%, 50%); } + /* Spoiler styling */ + .spoiler-block { + border: hsl(0, 0%, 50%) 1px solid; + padding: 2px 8px 2px 10px; + border-radius: 10px; + position: relative; + top: 1px; + display: block; + margin: 5px 0 15px 0; + + .spoiler-header { + padding: 5px; + font-weight: bold; + } + + .spoiler-content { + overflow: hidden; + border-top: hsl(0, 0%, 50%) 0px solid; + transition: height 0.4s ease-in-out, border-top 0.4s step-end, padding 0.4s step-end; + padding: 0px; + height: 0px; + + &.spoiler-content-open { + border-top: hsl(0, 0%, 50%) 1px solid; + transition: height 0.4s ease-in-out, border-top 0.4s step-start, padding 0.4s step-start; + padding: 5px; + height: auto; + } + } + + .spoiler-button { + float: right; + width: 25px; + height: 25px; + &:hover .spoiler-arrow { + &::before, + &::after { + background-color: hsl(0, 0%, 50%); + } + } + } + + + .spoiler-arrow { + float: right; + width: 13px; + height: 13px; + position: relative; + bottom: -5px; + left: -10px; + cursor: pointer; + transition: 0.4s ease; + margin-top: 2px; + text-align: left; + transform: rotate(45deg); + &::before, + &::after { + position: absolute; + content: ''; + display: inline-block; + width: 12px; + height: 3px; + background-color: hsl(0, 0%, 83%); + transition: 0.4s ease; + } + &::after { + position: absolute; + transform: rotate(90deg); + top: -5px; + left: 5px; + } + &.spoiler-button-open { + transform: rotate(45deg) translate(-5px, -5px); + &::before { + transform: translate(10px, 0); + } + &::after { + transform: rotate(90deg) translate(10px, 0); + } + } + } + } + /* CSS for message content widgets */ table.tictactoe { width: 80px; diff --git a/templates/zerver/app/markdown_help.html b/templates/zerver/app/markdown_help.html index b161c192be..c2d1ba8998 100644 --- a/templates/zerver/app/markdown_help.html +++ b/templates/zerver/app/markdown_help.html @@ -128,6 +128,22 @@ Quoted block ```

Quoted block

+ + ```spoiler Always visible heading +This text won't be visible until the user clicks. +``` + +
+
+

Always visible heading

+
+ +
+

This text won't be visible until the user clicks.

+
+
+ + Some inline math $$ e^{i \pi } + 1 = 0 $$ diff --git a/templates/zerver/help/format-your-message-using-markdown.md b/templates/zerver/help/format-your-message-using-markdown.md index 150ba82928..8ed25d6008 100644 --- a/templates/zerver/help/format-your-message-using-markdown.md +++ b/templates/zerver/help/format-your-message-using-markdown.md @@ -13,6 +13,7 @@ to allow you to easily format your messages. * [Code blocks](#code) * [LaTeX](#latex) * [Quotes](#quotes) +* [Spoilers](#spoilers) * [Emoji and emoticons](#emoji-and-emoticons) * [Mentions](#mentions) * [Status messages](#status-messages) @@ -150,6 +151,28 @@ quote in two paragraphs ![](/static/images/help/markdown-quotes.png) +## Spoilers + +You can use spoilers to hide content that you do not want to be visible until +the user interacts with it. + + +~~~ +Normal content in message + +```spoiler Spoiler Header +Spoiler content. These lines won't be visible until the user expands the spoiler. +``` +~~~ + +The spoiler will initially display in a collapsed form: + +![](/static/images/help/spoiler-collapsed.png) + +Clicking the arrow will expand the spoiler content: + +![](/static/images/help/spoiler-expanded.png) + ## Emoji and emoticons To translate emoticons into emoji, you'll need to diff --git a/tools/setup/lang.json b/tools/setup/lang.json index c60d3fadd7..705936d25d 100644 --- a/tools/setup/lang.json +++ b/tools/setup/lang.json @@ -49,6 +49,7 @@ "scala": 21, "scheme": 14, "sql": 32, + "spoiler": 50, "swift": 41, "tex": 40, "text": 1, diff --git a/tools/test-js-with-node b/tools/test-js-with-node index 9eb6a5f5b6..9c3d18f2c7 100755 --- a/tools/test-js-with-node +++ b/tools/test-js-with-node @@ -128,6 +128,7 @@ EXEMPT_FILES = { 'static/js/settings_ui.js', 'static/js/settings_users.js', 'static/js/setup.js', + 'static/js/spoilers.js', 'static/js/starred_messages.js', 'static/js/stream_color.js', 'static/js/stream_create.js', diff --git a/zerver/lib/bugdown/fenced_code.py b/zerver/lib/bugdown/fenced_code.py index 4823d728d4..3a80082603 100644 --- a/zerver/lib/bugdown/fenced_code.py +++ b/zerver/lib/bugdown/fenced_code.py @@ -102,6 +102,13 @@ FENCE_RE = re.compile(""" \\}? ) # language, like ".py" or "{javascript}" [ ]* # spaces + ( + \\{?\\.? + (?P
+ [^~`]* + ) + \\}? + ) # header for features that use fenced block header syntax (like spoilers) $ """, re.VERBOSE) @@ -155,13 +162,16 @@ class BaseHandler: raise NotImplementedError() def generic_handler(processor: Any, output: MutableSequence[str], - fence: str, lang: str, + fence: str, lang: str, header: str, run_content_validators: bool=False, default_language: Optional[str]=None) -> BaseHandler: + lang = lang.lower() if lang in ('quote', 'quoted'): return QuoteHandler(processor, output, fence, default_language) elif lang == 'math': return TexHandler(processor, output, fence) + elif lang == 'spoiler': + return SpoilerHandler(processor, output, fence, header) else: return CodeHandler(processor, output, fence, lang, run_content_validators) @@ -172,9 +182,11 @@ def check_for_new_fence(processor: Any, output: MutableSequence[str], line: str, if m: fence = m.group('fence') lang = m.group('lang') + header = m.group('header') if not lang and default_language: lang = default_language - handler = generic_handler(processor, output, fence, lang, run_content_validators, default_language) + handler = generic_handler(processor, output, fence, lang, header, + run_content_validators, default_language) processor.push(handler) else: output.append(line) @@ -251,6 +263,37 @@ class QuoteHandler(BaseHandler): self.output.append('') self.processor.pop() + +class SpoilerHandler(BaseHandler): + def __init__(self, processor: Any, output: MutableSequence[str], + fence: str, spoiler_header: str) -> None: + self.processor = processor + self.output = output + self.fence = fence + self.spoiler_header = spoiler_header + self.lines: List[str] = [] + + def handle_line(self, line: str) -> None: + if line.rstrip() == self.fence: + self.done() + else: + check_for_new_fence(self.processor, self.lines, line) + + def done(self) -> None: + if len(self.lines) == 0: + # No content, do nothing + return + else: + header = self.spoiler_header + text = '\n'.join(self.lines) + + text = self.processor.format_spoiler(header, text) + processed_lines = text.split('\n') + self.output.append('') + self.output.extend(processed_lines) + self.output.append('') + self.processor.pop() + class TexHandler(BaseHandler): def __init__(self, processor: Any, output: MutableSequence[str], fence: str) -> None: self.processor = processor @@ -359,6 +402,20 @@ class FencedBlockPreprocessor(markdown.preprocessors.Preprocessor): quoted_paragraphs.append("\n".join("> " + line for line in lines if line != '')) return "\n\n".join(quoted_paragraphs) + def format_spoiler(self, header: str, text: str) -> str: + output = [] + header_div_open_html = '
' + end_header_start_content_html = '
' + + output.append(self.placeholder(header_div_open_html)) + output.append(header) + output.append(self.placeholder(end_header_start_content_html)) + output.append(text) + output.append(self.placeholder(footer_html)) + return "\n\n".join(output) + def format_tex(self, text: str) -> str: paragraphs = text.split("\n\n") tex_paragraphs = [] diff --git a/zerver/tests/fixtures/markdown_test_cases.json b/zerver/tests/fixtures/markdown_test_cases.json index ed4ad79f3f..9d726c7f8b 100644 --- a/zerver/tests/fixtures/markdown_test_cases.json +++ b/zerver/tests/fixtures/markdown_test_cases.json @@ -853,6 +853,38 @@ "expected_output": "
    \n
  1. \n

    A

    \n
  2. \n
  3. \n

    B

    \n
  4. \n
  5. \n

    C

    \n
  6. \n
  7. \n

    D
    \nordinary paragraph

    \n
  8. \n
  9. \n

    AA

    \n
  10. \n
  11. \n

    BB

    \n
  12. \n
", "marked_expected_output": "
    \n
  1. A

    \n
  2. \n
  3. B

    \n
  4. \n
  5. C

    \n
  6. \n
  7. D
    \nordinary paragraph

    \n
  8. \n
  9. AA

    \n
  10. \n
  11. BB

    \n
  12. \n
", "text_content": "1. A\n2. B\n3. C\n4. D\nordinary paragraph\n5. AA\n6. BB" + }, + { + "name": "spoilers_fenced_spoiler", + "input": "```spoiler header\ncontent\n```\noutside spoiler\n", + "expected_output": "
\n\n

header

\n
\n\n

content

\n
\n\n

outside spoiler

" + }, + { + "name": "spoilers_empty_header", + "input": "```spoiler\ncontent\n```\noutside spoiler\n", + "expected_output": "
\n\n
\n\n

content

\n
\n\n

outside spoiler

" + }, + { + "name": "spoilers_script_tags", + "input": "```spoiler \n\n```", + "expected_output": "
\n\n

<script>alert(1)</script>

\n
\n\n

<script>alert(1)</script>

\n
", + "marked_expected_output": "
\n\n

<script>alert(1)</script>\n\n

\n
\n\n

<script>alert(1)</script>\n\n

\n
" + }, + { + "name": "spoilers_block_quote", + "input": "~~~quote\n```spoiler header\ncontent\n```\noutside spoiler\n~~~\noutside quote", + "expected_output": "
\n
\n\n

header

\n
\n\n

content

\n
\n\n

outside spoiler

\n
\n

outside quote

" + }, + { + "name": "spoilers_with_header_markdown", + "input": "```spoiler [Header](https://example.com) :smile:\ncontent\n```", + "expected_output": "
\n\n

Header :smile:

\n
\n\n

content

\n
" + }, + { + "name": "spoiler_with_inline_image", + "input": "```spoiler header\nContent http://example.com/image.png\n```", + "expected_output": "
\n\n

header

\n
", + "marked_expected_output": "
\n\n

header

\n
" } ], "linkify_tests": [