]> git.ipfire.org Git - thirdparty/bugzilla.git/commitdiff
Bug 1472522 - Show image/text attachments inline
authorKohei Yoshino <kohei.yoshino@gmail.com>
Wed, 6 Mar 2019 18:35:33 +0000 (13:35 -0500)
committerGitHub <noreply@github.com>
Wed, 6 Mar 2019 18:35:33 +0000 (13:35 -0500)
Bugzilla/Install.pm
extensions/BugModal/lib/MonkeyPatches.pm
extensions/BugModal/template/en/default/bug_modal/activity_stream.html.tmpl
extensions/BugModal/template/en/default/bug_modal/header.html.tmpl
extensions/BugModal/web/bug_modal.css
extensions/BugModal/web/comments.js
template/en/default/bug/format_comment.txt.tmpl
template/en/default/global/setting-descs.none.tmpl

index 705a8396ca87000f28982663b9afc628d5dec5c4..c13c32929e70a28f031e414a710848a43f4e1f6f 100644 (file)
@@ -204,6 +204,12 @@ sub SETTINGS {
       default  => 'on',
       category => 'User Interface'
     },
+    {
+        name     => 'inline_attachments',
+        options  => ['on', 'off'],
+        default  => 'on',
+        category => 'User Interface'
+    },
   ];
 }
 
index bb1ba14c43cec4632b08bc1870befaf82515cfdc..39e2613568cb646c42cad710e63ca485ccc03c68 100644 (file)
@@ -54,4 +54,14 @@ sub is_image {
   return substr($self->contenttype, 0, 6) eq 'image/';
 }
 
+sub is_audio {
+  my ($self) = @_;
+  return substr($self->contenttype, 0, 6) eq 'audio/';
+}
+
+sub is_video {
+  my ($self) = @_;
+  return substr($self->contenttype, 0, 6) eq 'video/';
+}
+
 1;
index 710fa40d1fefe517ae981b4930a17401f96334bc..2661e256a1922eff9be2db698af425303a1da1ad 100644 (file)
 [% END %]
 
 [% BLOCK comment_body %]
+  [% IF comment.type == constants.CMT_ATTACHMENT_CREATED %]
+    [% att = comment.attachment; link = 'attachment.cgi?id=' _ att.id %]
+    <div id="att-[% att.id FILTER none %]"
+         class="attachment[% " patch" IF att.ispatch %][% " obsolete" IF att.isobsolete %]"
+         data-id="[% att.id FILTER none %]" itemscope itemtype="http://schema.org/MediaObject"
+         [% IF comment.collapsed +%] style="display:none"[% END ~%]>
+      <meta itemprop="contentSize" content="[% att.datasize FILTER none %]">
+      <meta itemprop="encodingFormat" content="[% att.mimetype FILTER html %]">
+      <div class="label">
+        Posted [% IF att.is_image %]image
+          [% ELSIF att.is_audio %]audio[% ELSIF att.is_video %]video
+          [% ELSIF att.ispatch %]patch[% ELSE %]file[% END %]
+        [% IF att.ispatch %]
+          <meta itemprop="contentUrl" content="[% link FILTER html %]">
+          <a class="link" href="[% link FILTER html %]&amp;action=diff">
+        [% ELSE %]
+          <a class="link[% " lightbox" IF att.is_image %]" href="[% link FILTER html %]" itemprop="contentUrl">
+        [% END %]
+        <span id="att-[% att.id FILTER none %]-description" itemprop="name">[% att.description FILTER html %]</span></a>
+        [% " (obsolete)" IF att.isobsolete %]
+        — <a href="[% link FILTER html %]&amp;action=edit" itemprop="url">Details</a>
+        [% IF att.ispatch && Param('splinter_base') %]
+          — <a href="[% Bugzilla.splinter_review_url(bug.bug_id, att.id) FILTER none %]">Splinter Review</a>
+        [% END %]
+      </div>
+    </div>
+  [% END %]
   [% IF comment.is_markdown AND Param('use_markdown') %]
     [% comment_tag = 'div' %]
   [% ELSE %]
         class="lightbox lightbox-icon [%= "markdown" IF comment_tag == 'div' %]"><img src="extensions/BugModal/web/image.png" width="16" height="16"></a>
     [% END %]
   [% END %]
-  [%~ comment.body_full FILTER renderMarkdown(bug, comment) ~%]</[% comment_tag FILTER none %]>
+  [%~ comment.body_full({ exclude_attachment => 1 }) FILTER renderMarkdown(bug, comment) ~%]</[% comment_tag FILTER none %]>
 [% END %]
 
 [%
index 92331f6cf62a75764ecb7d82176c6366467bed04..675082a50b71f3d053a277261df5b0e3340c34e0 100644 (file)
       quote_replies: '[% user.settings.quote_replies.value FILTER js %]',
       zoom_textareas: [% user.settings.zoom_textareas.value == "on" ? "true" : "false" %],
       remember_collapsed: [% user.settings.ui_remember_collapsed.value == "on" ? "true" : "false" %],
+      inline_attachments: [% user.settings.inline_attachments.value == "on" ? "true" : "false" %],
       autosize_comments: [% user.settings.autosize_comments.value == "on" ? "true" : "false" %]
     }
   };
index 1844c7f506012e171172a3743beb8f951dcad7f1..47bd806b5dea0cf95e74a353d87b23ba74523833 100644 (file)
@@ -745,6 +745,94 @@ h3.change-name {
     text-decoration: line-through;
 }
 
+/* inline attachments */
+
+.change-set .attachment {
+    border-top: 1px solid #ddd;
+    background-color: #FFF;
+}
+
+.change-set .attachment {
+    background-color: #FFF;
+    padding: 8px;
+}
+
+.change-set .attachment .label {
+    font-size: 14px;
+    font-style: italic;
+    color: grey;
+}
+
+.change-set .attachment .label [itemprop="name"] {
+    font-weight: 600;
+}
+
+.change-set .attachment .outer {
+    display: inline-block;
+    margin: 8px 0 0;
+    overflow: hidden;
+    border: 1px solid lightgrey;
+    border-radius: 4px;
+    vertical-align: top;
+    text-decoration: none;
+    color: #333;
+}
+
+.change-set .attachment .lightbox {
+    cursor: zoom-in;
+}
+
+.change-set .attachment .lightbox * {
+    pointer-events: none;
+}
+
+.change-set .attachment img,
+.change-set .attachment audio,
+.change-set .attachment video {
+    margin: 0;
+    vertical-align: top;
+    max-width: 426px;
+}
+
+.change-set .attachment [content="image/svg+xml"] ~ .outer img {
+    width: 426px;
+}
+
+.change-set .attachment pre {
+    position: relative;
+    overflow: hidden;
+    box-sizing: border-box;
+    margin: 0;
+    padding: 8px;
+    width: 426px;
+    height: 240px;
+    font-family: "Fira Mono","Droid Sans Mono",Menlo,Monaco,"Courier New",monospace;
+    font-size: 12px;
+    -moz-user-select: none;
+    -webkit-user-select: none;
+    user-select: none;
+    pointer-events: none;
+}
+
+.change-set .attachment pre::after {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    background-image: linear-gradient(to bottom, transparent 75%, #FFF);
+    content: '';
+}
+
+
+.change-set .attachment pre .token {
+    background-color: transparent !important; /* Override Prism.js */
+}
+
+.change-set .attachment ~ .comment-text {
+    padding-top: 0;
+}
+
 /* add comment */
 
 #add-comment {
index 4cc0560a46898dd190f58313dcfc581d433add04..3051fea2e38829dee5cc37f57918a807e62e3d1c 100644 (file)
@@ -75,9 +75,7 @@ $(function() {
             $('#ct-' + id).hide();
             if (BUGZILLA.user.id !== 0)
                 $('#ctag-' + id).hide();
-            $('#c' + id).find('.activity').hide();
-            $('#c' + id).find('.comment-tags').hide();
-            $('#c' + id).find('.comment-tags').hide();
+            $('#c' + id).find('.activity, .attachment, .comment-tags').hide();
             $('#c' + id).find('.gravatar').css('width', '16px').css('height', '16px');
             $('#cr-' + id).hide();
             update_spinner(realSpinner, false);
@@ -90,9 +88,7 @@ $(function() {
             $('#ct-' + id).show();
             if (BUGZILLA.user.id !== 0)
                 $('#ctag-' + id).show();
-            $('#c' + id).find('.activity').show();
-            $('#c' + id).find('.comment-tags').show();
-            $('#c' + id).find('.comment-tags').show();
+            $('#c' + id).find('.activity, .attachment, .comment-tags').show();
             $('#c' + id).find('.gravatar').css('width', '32px').css('height', '32px');
             $('#cr-' + id).show();
             update_spinner(realSpinner, true);
@@ -100,6 +96,7 @@ $(function() {
         else {
             $('#ct-' + id).slideToggle('fast', function() {
                 $('#c' + id).find('.activity').toggle();
+                $('#c' + id).find('.attachment').slideToggle();
                 if ($('#ct-' + id + ':visible').length) {
                     $('#c' + id).find('.comment-tags').show();
                     update_spinner(realSpinner, true);
@@ -493,3 +490,125 @@ $(function() {
 
     updateTagsMenu();
 });
+
+/**
+ * Reference or define the Bugzilla app namespace.
+ * @namespace
+ */
+var Bugzilla = Bugzilla || {};
+
+/**
+ * Reference or define the Review namespace.
+ * @namespace
+ */
+Bugzilla.BugModal = Bugzilla.BugModal || {};
+
+/**
+ * Implement the modal bug view's comment-related functionality.
+ */
+Bugzilla.BugModal.Comments = class Comments {
+  /**
+   * Initiate a new Comments instance.
+   */
+  constructor() {
+    this.prepare_inline_attachments();
+  }
+
+  /**
+   * Prepare to show image and text attachments inline if possible. For a better performance, this functionality uses
+   * the Intersection Observer API to show attachments when the associated comment goes into the viewport, when the page
+   * is scrolled down or the collapsed comment is expanded. This also utilizes the Network Information API to save
+   * bandwidth over cellular networks.
+   * @see https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
+   * @see https://developer.mozilla.org/en-US/docs/Web/API/Network_Information_API
+   */
+  prepare_inline_attachments() {
+    // Check the user setting, API support and connectivity
+    if (!BUGZILLA.user.settings.inline_attachments || typeof IntersectionObserver !== 'function' ||
+        (navigator.connection && navigator.connection.type === 'cellular')) {
+      return;
+    }
+
+    const observer = new IntersectionObserver(entries => entries.forEach(entry => {
+      const $att = entry.target;
+
+      if (entry.intersectionRatio > 0) {
+        observer.unobserve($att);
+        this.show_attachment($att);
+      }
+    }), { root: document.querySelector('#bugzilla-body') });
+
+    // Show only non-obsolete attachments
+    document.querySelectorAll('.change-set .attachment:not(.obsolete)').forEach($att => observer.observe($att));
+  }
+
+  /**
+   * Load and show an image, audio, video or text attachment.
+   * @param {HTMLElement} $att An attachment wrapper element.
+   */
+  async show_attachment($att) {
+    const id = Number($att.dataset.id);
+    const link = $att.querySelector('.link').href;
+    const name = $att.querySelector('[itemprop="name"]').textContent;
+    const type = $att.querySelector('[itemprop="encodingFormat"]').content;
+    const size = Number($att.querySelector('[itemprop="contentSize"]').content);
+    const max_size = 2000000;
+
+    // Show image smaller than 2 MB
+    if (type.match(/^image\/(?!vnd).+$/) && size < max_size) {
+      $att.insertAdjacentHTML('beforeend', `
+        <a href="${link}" class="outer lightbox"><img src="${link}" alt="${name}" itemprop="image"></a>`);
+
+      // Add lightbox support
+      $att.querySelector('.outer.lightbox').addEventListener('click', event => {
+        if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
+          return;
+        }
+
+        event.preventDefault();
+        lb_show(event.target);
+      });
+    }
+
+    // Show audio and video
+    if (type.match(/^(?:audio|video)\/(?!vnd).+$/)) {
+      const media = type.split('/')[0];
+
+      if (document.createElement(media).canPlayType(type)) {
+        $att.insertAdjacentHTML('beforeend', `
+          <span class="outer"><${media} src="${link}" controls itemprop="${media}"></span>`);
+      }
+    }
+
+    // Detect text (code from attachment.js)
+    const is_patch = !!name.match(/\.(?:diff|patch)$/) || !!type.match(/^text\/x-(?:diff|patch)$/);
+    const is_markdown = !!name.match(/\.(?:md|mkdn?|mdown|markdown)$/);
+    const is_source = !!name.match(/\.(?:cpp|es|h|js|json|rs|rst|sh|toml|ts|tsx|xml|yaml|yml)$/);
+    const is_text = type.startsWith('text/') || is_patch || is_markdown || is_source;
+
+    // Show text smaller than 2 MB
+    if (is_text && size < max_size) {
+      // Load text body
+      try {
+        const response = await fetch(`/attachment.cgi?id=${id}`, { credentials: 'same-origin' });
+
+        if (!response.ok) {
+          throw new Error();
+        }
+
+        const text = await response.text();
+        const lang = is_patch ? 'diff' : type.match(/\w+$/)[0];
+
+        $att.insertAdjacentHTML('beforeend', `
+          <a href="${link}" title="${name}" class="outer">
+          <pre class="language-${lang}" role="img" itemprop="text">${text}</pre></a>`);
+
+        if (Prism) {
+          Prism.highlightElement($att.querySelector('pre'));
+        }
+      } catch (ex) {}
+    }
+  }
+};
+
+document.addEventListener('DOMContentLoaded', () => new Bugzilla.BugModal.Comments(), { once: true });
index 9c1f1385f55c46de3cb23418290941d91d156fb7..1b0ebf5793900e281b6347ed7bc730019aecacb1 100644 (file)
@@ -40,12 +40,14 @@ X[% comment_body %]
 [% ELSIF comment.type == constants.CMT_HAS_DUPE %]
 *** [% terms.Bug %] [%+ comment.extra_data %] has been marked as a duplicate of this [% terms.bug %]. ***
 [% ELSIF comment.type == constants.CMT_ATTACHMENT_CREATED %]
+[% UNLESS exclude_attachment %]
 Created attachment [% comment.extra_data %]
 [% IF is_bugmail %]
   --> [% urlbase _ "attachment.cgi?id=" _ comment.extra_data _ "&action=edit" %]
 [% END %]
 [%+ comment.attachment.description %]
 
+[% END %]
 [%+ comment.body %]
 [% ELSIF comment.type == constants.CMT_ATTACHMENT_UPDATED %]
 Comment on attachment [% comment.extra_data %]
index b6800d22d28b417c66ba5778a7f7bfefd832a718..7ed674b3bd749a669e403beb839ecfa72a4cf21f 100644 (file)
@@ -43,6 +43,7 @@
    "quote_replies"                    => "Quote the associated comment when you click on its reply link",
    "quoted_reply"                     => "Quote the full comment",
    "simple_reply"                     => "Reference the comment number only",
+   "inline_attachments"               => "Show attachments inline",
    "autosize_comments"                => "Expand the comment box dynamically",
    "comment_box_position"             => "Position of the Additional Comments box",
    "before_comments"                  => "Before other comments",