Repository: zulip/zulip

Issue:
Twitter preview embeds show outside of spoiler blocks
When a tweet is linked to inside of a spoiler block, the embedded preview for the tweet shows up outside of the spoiler block (at the end of the post as is typical for non-spoilered links to tweets). The preview should probably show up inside of the spoiler block (perhaps at the end of the inside of the block, if there are concerns about tweet previews breaking the flow of a post).

(Previews for uploaded images appear to respect spoiler blocks.)


Discussion:
I agree.  This may be somewhat messy to change because of how tweets are rendered in a post-processing step; I'm not sure.  Ideally we'd just append then as the last element of the parent spoiler block (if any).

@Dylnuge @opheliasdaisies FYI.
Hello @zulip/server-markdown members, this issue was labeled with the "area: markdown" label, so you may want to check it out!

<!-- areaLabelAddition -->
If anyone else takes this on, @timabbott's comment on the PR might be useful: https://github.com/zulip/zulip/pull/15281#discussion_r437864539
I think fixing this correctly involves changing the logic around `handle_image_inlining` to set the root element for where we add image previews to be the end of the spoiler tag, not the top element.  I haven't read the code closely enough to say how complex that will be.
@zulipbot claim
Hello @aero31aero, you claimed this issue to work on it, but this issue and any referenced pull requests haven't been updated for 10 days. Are you still working on this issue?

If so, please update this issue by leaving a comment on this issue to let me know that you're still working on it. Otherwise, I'll automatically remove you from this issue in 4 days.

If you've decided to work on something else, simply comment `@zulipbot abandon` so that someone else can claim it and continue from where you left off.

Thank you for your valuable contributions to Zulip!

<!-- inactiveWarning -->

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
@@ -94,6 +94,12 @@ class FullNameInfo(TypedDict):
     email: str
     full_name: str
 
+class LinkInfo(TypedDict):
+    parent: Element
+    title: Optional[str]
+    index: Optional[int]
+    remove: Optional[Element]
+
 DbData = Dict[str, Any]
 
 # Format version of the markdown rendering; stored along with rendered
@@ -988,25 +994,31 @@ def get_url_data(self, e: Element) -> Optional[Tuple[str, Optional[str]]]:
             return (url, e.text)
         return None
 
-    def handle_image_inlining(
+    def get_inlining_information(
         self,
         root: Element,
         found_url: ResultWithFamily[Tuple[str, Optional[str]]],
-    ) -> None:
+    ) -> LinkInfo:
+
         grandparent = found_url.family.grandparent
         parent = found_url.family.parent
         ahref_element = found_url.family.child
         (url, text) = found_url.result
-        actual_url = self.get_actual_image_url(url)
 
         # url != text usually implies a named link, which we opt not to remove
         url_eq_text = text is None or url == text
         title = None if url_eq_text else text
+        info: LinkInfo = {
+            'parent': root,
+            'title': title,
+            'index': None,
+            'remove': None,
+        }
 
         if parent.tag == 'li':
-            self.add_a(parent, self.get_actual_image_url(url), url, title=title)
+            info['parent'] = parent
             if not parent.text and not ahref_element.tail and url_eq_text:
-                parent.remove(ahref_element)
+                info['remove'] = ahref_element
 
         elif parent.tag == 'p':
             assert grandparent is not None
@@ -1016,25 +1028,50 @@ def handle_image_inlining(
                     parent_index = index
                     break
 
-            if parent_index is not None:
-                ins_index = self.find_proper_insertion_index(grandparent, parent, parent_index)
-                self.add_a(grandparent, actual_url, url, title=title, insertion_index=ins_index)
+            # Append to end of list of grandparent's children as normal
+            info['parent'] = grandparent
 
-            else:
-                # We're not inserting after parent, since parent not found.
-                # Append to end of list of grandparent's children as normal
-                self.add_a(grandparent, actual_url, url, title=title)
-
-            # If link is alone in a paragraph, delete paragraph containing it
             if (len(parent) == 1 and
                     (not parent.text or parent.text == "\n") and
                     not ahref_element.tail and
                     url_eq_text):
-                grandparent.remove(parent)
+                info['remove'] = parent
+
+            if parent_index is not None:
+                info['index'] = self.find_proper_insertion_index(grandparent, parent, parent_index)
 
+        return info
+
+    def handle_image_inlining(
+        self,
+        root: Element,
+        found_url: ResultWithFamily[Tuple[str, Optional[str]]],
+    ) -> None:
+        info = self.get_inlining_information(root, found_url)
+        (url, text) = found_url.result
+        actual_url = self.get_actual_image_url(url)
+        self.add_a(info['parent'], actual_url, url, title=info['title'], insertion_index=info['index'])
+        if info['remove'] is not None:
+            info['parent'].remove(info['remove'])
+
+    def handle_tweet_inlining(
+        self,
+        root: Element,
+        found_url: ResultWithFamily[Tuple[str, Optional[str]]],
+        twitter_data: Element,
+    ) -> None:
+        info = self.get_inlining_information(root, found_url)
+
+        if info['index'] is not None:
+            div = Element("div")
+            root.insert(info['index'], div)
         else:
-            # If none of the above criteria match, fall back to old behavior
-            self.add_a(root, actual_url, url, title=title)
+            div = SubElement(root, "div")
+
+        div.set("class", "inline-preview-twitter")
+        div.insert(0, twitter_data)
+        if info['remove'] is not None:
+            info['parent'].remove(info['remove'])
 
     def find_proper_insertion_index(self, grandparent: Element, parent: Element,
                                     parent_index_in_grandparent: int) -> int:
@@ -1051,7 +1088,7 @@ def find_proper_insertion_index(self, grandparent: Element, parent: Element,
                 return insertion_index
 
             uncle = grandparent[insertion_index]
-            inline_image_classes = ['message_inline_image', 'message_inline_ref']
+            inline_image_classes = ['message_inline_image', 'message_inline_ref', 'inline-preview-twitter']
             if (
                 uncle.tag != 'div' or
                 'class' not in uncle.keys() or
@@ -1155,9 +1192,7 @@ def run(self, root: Element) -> None:
                     # This link is not actually a tweet known to twitter
                     continue
                 rendered_tweet_count += 1
-                div = SubElement(root, "div")
-                div.set("class", "inline-preview-twitter")
-                div.insert(0, twitter_data)
+                self.handle_tweet_inlining(root, found_url, twitter_data)
                 continue
             youtube = self.youtube_image(url)
             if youtube is not None:


Test patch:
diff --git a/zerver/tests/test_markdown.py b/zerver/tests/test_markdown.py
--- a/zerver/tests/test_markdown.py
+++ b/zerver/tests/test_markdown.py
@@ -790,21 +790,21 @@ def make_inline_twitter_preview(url: str, tweet_html: str, image_html: str='') -
         converted = markdown_convert_wrapper(msg)
         self.assertEqual(converted, '<p>{}</p>'.format(make_link('http://www.twitter.com/wdaher/status/999999999999999999')))
 
-        msg = 'http://www.twitter.com/wdaher/status/287977969287315456'
+        msg = 'Tweet: http://www.twitter.com/wdaher/status/287977969287315456'
         converted = markdown_convert_wrapper(msg)
-        self.assertEqual(converted, '<p>{}</p>\n{}'.format(
+        self.assertEqual(converted, '<p>Tweet: {}</p>\n{}'.format(
             make_link('http://www.twitter.com/wdaher/status/287977969287315456'),
             make_inline_twitter_preview('http://www.twitter.com/wdaher/status/287977969287315456', normal_tweet_html)))
 
-        msg = 'https://www.twitter.com/wdaher/status/287977969287315456'
+        msg = 'Tweet: https://www.twitter.com/wdaher/status/287977969287315456'
         converted = markdown_convert_wrapper(msg)
-        self.assertEqual(converted, '<p>{}</p>\n{}'.format(
+        self.assertEqual(converted, '<p>Tweet: {}</p>\n{}'.format(
             make_link('https://www.twitter.com/wdaher/status/287977969287315456'),
             make_inline_twitter_preview('https://www.twitter.com/wdaher/status/287977969287315456', normal_tweet_html)))
 
-        msg = 'http://twitter.com/wdaher/status/287977969287315456'
+        msg = 'Tweet: http://twitter.com/wdaher/status/287977969287315456'
         converted = markdown_convert_wrapper(msg)
-        self.assertEqual(converted, '<p>{}</p>\n{}'.format(
+        self.assertEqual(converted, '<p>Tweet: {}</p>\n{}'.format(
             make_link('http://twitter.com/wdaher/status/287977969287315456'),
             make_inline_twitter_preview('http://twitter.com/wdaher/status/287977969287315456', normal_tweet_html)))
 
@@ -837,19 +837,29 @@ def make_inline_twitter_preview(url: str, tweet_html: str, image_html: str='') -
             make_inline_twitter_preview('http://twitter.com/wdaher/status/287977969287315457', normal_tweet_html),
             make_inline_twitter_preview('https://twitter.com/wdaher/status/287977969287315456', normal_tweet_html)))
 
+        # Test smart in-place inlining behavior:
+        msg = ('Paragraph 1: http://twitter.com/wdaher/status/287977969287315456\n\n'
+               'Paragraph 2. Below paragraph will be removed.\n\n'
+               'http://twitter.com/wdaher/status/287977969287315457')
+        converted = markdown_convert_wrapper(msg)
+        self.assertEqual(converted, '<p>Paragraph 1: {}</p>\n{}<p>Paragraph 2. Below paragraph will be removed.</p>\n{}'.format(
+            make_link('http://twitter.com/wdaher/status/287977969287315456'),
+            make_inline_twitter_preview('http://twitter.com/wdaher/status/287977969287315456', normal_tweet_html),
+            make_inline_twitter_preview('http://twitter.com/wdaher/status/287977969287315457', normal_tweet_html)))
+
         # Tweet has a mention in a URL, only the URL is linked
-        msg = 'http://twitter.com/wdaher/status/287977969287315458'
+        msg = 'Tweet: http://twitter.com/wdaher/status/287977969287315458'
 
         converted = markdown_convert_wrapper(msg)
-        self.assertEqual(converted, '<p>{}</p>\n{}'.format(
+        self.assertEqual(converted, '<p>Tweet: {}</p>\n{}'.format(
             make_link('http://twitter.com/wdaher/status/287977969287315458'),
             make_inline_twitter_preview('http://twitter.com/wdaher/status/287977969287315458', mention_in_link_tweet_html)))
 
         # Tweet with an image
-        msg = 'http://twitter.com/wdaher/status/287977969287315459'
+        msg = 'Tweet: http://twitter.com/wdaher/status/287977969287315459'
 
         converted = markdown_convert_wrapper(msg)
-        self.assertEqual(converted, '<p>{}</p>\n{}'.format(
+        self.assertEqual(converted, '<p>Tweet: {}</p>\n{}'.format(
             make_link('http://twitter.com/wdaher/status/287977969287315459'),
             make_inline_twitter_preview('http://twitter.com/wdaher/status/287977969287315459',
                                         media_tweet_html,
@@ -859,12 +869,21 @@ def make_inline_twitter_preview(url: str, tweet_html: str, image_html: str='') -
                                          '</a>'
                                          '</div>'))))
 
-        msg = 'http://twitter.com/wdaher/status/287977969287315460'
+        msg = 'Tweet: http://twitter.com/wdaher/status/287977969287315460'
         converted = markdown_convert_wrapper(msg)
-        self.assertEqual(converted, '<p>{}</p>\n{}'.format(
+        self.assertEqual(converted, '<p>Tweet: {}</p>\n{}'.format(
             make_link('http://twitter.com/wdaher/status/287977969287315460'),
             make_inline_twitter_preview('http://twitter.com/wdaher/status/287977969287315460', emoji_in_tweet_html)))
 
+        # Test twitter previews in spoiler tags.
+        msg = '```spoiler secret tweet\nTweet: http://twitter.com/wdaher/status/287977969287315456\n```'
+        converted = markdown_convert_wrapper(msg)
+
+        rendered_spoiler = "<div class=\"spoiler-block\"><div class=\"spoiler-header\">\n\n<p>secret tweet</p>\n</div><div class=\"spoiler-content\" aria-hidden=\"true\">\n\n<p>Tweet: {}</p>\n{}</div></div>"
+        self.assertEqual(converted, rendered_spoiler.format(
+            make_link('http://twitter.com/wdaher/status/287977969287315456'),
+            make_inline_twitter_preview('http://twitter.com/wdaher/status/287977969287315456', normal_tweet_html)))
+
     def test_fetch_tweet_data_settings_validation(self) -> None:
         with self.settings(TEST_SUITE=False, TWITTER_CONSUMER_KEY=None):
             self.assertIs(None, fetch_tweet_data('287977969287315459'))