Jira Plugin Development: Hide embedded images from attachments

Jira issues often include duplicate images from emails, cluttering the interface. A custom plugin can hide these duplicates in the attachments section while keeping them in the description. The plugin uses JavaScript and a MutationObserver to dynamically update the issue page, improving usability.

When Jira Issues are automatically created from emails, they often contain a lot of images, such as small icons from various social networks or company logos as shown in the following figure.

As a result, an important PDF document tends to get overlooked, since Jira imports and displays everything equally as an attachment. The images therefore appear twice. Embedded in the description as well as an attachment.

Although Jira offers many possibilities for customization, there is unfortunately no way to influence this behavior. The only thing left to do is to write your own plugin. The goal is to hide images that are already visible in the description from the attachments. With this blog you can do that comfortably in one morning.

You can find the final code on github.

How to create a new Jira plugin?

The first thing we do is create a new plugin. Don't worry. We've already done the lion's share of the work and provide a Github project for it that contains a matching Maven archetype.

You can install the archetype with three commands in the terminal if Maven is already installed:

git clone git@github.com:linked-planet/atlassian-plugin-kotlin.git
cd atlassian-plugin-kotlin
install-latest-tag.sh

Next, go to the folder where you want to create the new plugin and run the following Maven command as explained in the plugin instructions:

mvn archetype:generate -B \
    "-DarchetypeGroupId=com.linked-planet.maven.archetype" \
    "-DarchetypeArtifactId=atlassian-plugin-kotlin" \
    "-DarchetypeVersion=3.3.0" \
    "-DatlassianApp=jira" \
    "-DatlassianAppVersion=8.20.13" \
    "-DgroupId=com.linked-planet.plugin.jira" \
    "-DartifactId=blog-plugin" \
    "-Dpackage=com.linkedplanet.plugin.jira.blog" \
    "-DnameHumanReadable=Hide Issue Attachment Blog" \
    "-Ddescription=Hides image attachments that are visible inside the description of an issue." \
    "-DorganizationNameHumanReadable=linked-planet GmbH" \
    "-DorganizationUrl=https://linked-planet.com" \
    "-DinceptionYear=2023" \
    "-DgenerateGithubActions=true" \
    "-DgenerateDockerEnvironment=true" \
    "-DgenerateStubs=true" \
    "-DgenerateFrontend=false" \
    "-DfrontendAppName=exampleApp" \
    "-DfrontendAppNameUpperCamelCase=ExampleApp" \
    "-DfrontendAppNameKebabCase=example-app" \
    "-DhttpPort=2990" \
    "-Dgoals=license:update-file-header"

After that my new plugin can be found in the folder "blog-plugin". You can open the folder as a Maven project in Intellij. Currently you have to set Java 11 in the project settings. Now you can start Jira with the help of the Intellij run configuration.

In the browser one can log in with admin/admin under the address jira:2990.

Under Jira Administration -> Manage Apps our app should already appear with the name specified by nameHumandReadable during project creation, in our example "Hide IssueAttachment Blog".

Deliver Javascript with the help of the plugin.

Next, we make Jira load javascript code whenever an issue is displayed.

We add a file called hide-issue-attachments.js under src/main/resources/js.
The file contains only one line of code to check if our code is loaded.

console.info("[BLOG] hide-issue-attachments.js geladen")

In order for Jira to know that it should deliver the file, you need to add the following web resource to atlassian-plugin.xml:

    <web-resource key="hide-issue-attachments">
        <dependency>com.atlassian.auiplugin:ajs</dependency>
        <resource type="download" name="hide-issue-attachments.js" location="js/hide-issue-attachments.js"/>
        <context>jira.view.issue</context>
    </web-resource> 

Now you can run the run configuration "package" and check in the browser if the module has been loaded.

Jira will now deliver the hide-issue-attachments.js to the browser. The context "jira.view.issue" tells Jira to load the script exactly whenever an issue is displayed. This is perfect for our use case, as we only want to manipulate the issue view.

Manipulate the DOM with Javascript.

Now that our Javascript is loaded, the DOM can be manipulated to our heart's content. There are a few things to keep in mind here, though, so that Jira is happy with the javascript code it encounters.

  • Jira encapsulates and compresses all javascript code before it is delivered as bulk.js. Because of this, classes and some other features, such as "optional chaining", cannot be used.
  • Once Jira has loaded the page, the AJS.toInit() method is called.
  • Even if AJS.toInit() has been already called, parts of the page may be missing or change later.
  • Atlassian recommends that plugins clearly communicate when they make changes to the DOM.

Since we need to call AJS.toInit() with a lambda anyway, we define all code within that lambda to hide it from the global namespace. Of course, if you have a lot of code, you have to get more creative.

So the structure in our JS looks like this:

AJS.toInit(() => {
    // ... lots of helper functions
    const observeContentAndHideAttachments = () => {
      // ...
    }
    observeContentAndHideAttachments()
}

Our entry point is the observeContentAndHideAttachments function. Since the whole DOM can change at any time, especially when users switch between different operations, we need to permanently observe the whole DOM.
To do this, we can use the content element, on which we place a MutationObserver that monitors everything below this element.

const observeContentAndHideAttachments = () => {
  const targetNode = document.getElementById("content")
  if (!targetNode) {
  console.info("[BLOG] hide-issue-attachments failed to load, because the document does not contain 'content' node.")
  } else {
    console.info("[BLOG] hide-issue-attachments modification loaded.")
    const observer = new MutationObserver(observeContentNode)
    observer.observe(targetNode, {
      childList: true,
      subtree: true,
    })
  }
}

So our observer is the observeContentNode function. This basically looks to see if all the required sections have already been loaded. We need the description and the attachments. We also need to remember if the code has already been executed. The Observer can be called relatively often, so we want to perform expensive operations only once if possible.
We remember if the code has already been executed by inserting an invisible attachment with a special ID. If the attachment is already there, we can stop immediately.
If the conditions are met, we load all attachments and all images in the description and call the function that handles the actual hiding.

const alreadyExecutedId = "hide-issue-attachment-was-already-executed"

const addAlreadyExecutedLiElement = attachmentModule => {
  const iWasHere = document.createElement("li")
  iWasHere.id = alreadyExecutedId
  iWasHere.style.display = "none"
  attachmentModule.querySelector("#attachment_thumbnails").appendChild(iWasHere)
};

const observeContentNode = (/*mutationsList, observer*/) => {
  if (document.getElementById(alreadyExecutedId)) return
    const attachmentModule = document.querySelector("#attachmentmodule")
  if (!attachmentModule) return
  const descriptionModule = document.querySelector("#descriptionmodule")
  if (!descriptionModule) return

  const attachmentsList = attachmentModule.querySelectorAll("#attachment_thumbnails li")
  addAlreadyExecutedLiElement(attachmentModule) // increases attachmentsList.length
  if (attachmentsList.length === 0) return // no attachments to hide
  const embeddedImages = document.querySelectorAll("#descriptionmodule img")
  if (embeddedImages.length === 0) return
  hideAttachmentsForEmbeddedImages(attachmentsList, embeddedImages)
}

The hideAttachmentsForEmbeddedImages function hides all attachments whose ID corresponds to an ID of an image in the description.

const hideAttachmentsForEmbeddedImages = (attachments /* : NodeList*/, embeddedImages /* : NodeList*/) => {
  const embeddedImgIds = Array.from(embeddedImages)
    .map(extractAttachmentIdFromImg)

  for (const listItem of attachments) {
    const attachmentImg = listItem.querySelector("img")
    const attachmentImgId = extractAttachmentIdFromImg(attachmentImg)
    if (!attachmentImgId) continue // attachment has no image

    if (embeddedImgIds.includes(attachmentImgId)) {
      console.info(`[BLOG] hid attachment from DOM with image id=${attachmentImgId} element:%o`, listItem)
      listItem.style.display = "none"
    }
  }
}

Now we just need to figure out the attachment id for attachments and images. This is done by using the src URL of the images, which contains either the string "thumbnail" for embedded images in the description, or "attachment" for images as attachments, each followed by the id we are looking for. The ID is extracted with the help of a regular expression.

const extractAttachmentIdFromImg = img => {
  const src = img && img.src
  if (!src) return undefined
  let match = src.match(/[a-zA-Z]+:\/\/.*\/(thumbnail|attachment)\/(?<id>\d+)\/.*/)
  if (match && match.length > 2) return match.groups.id
}

Now you can run the run configuration "package" again and check in the browser if the images no longer appear as attachments.

Conclusion

Creating Jira plugins with the right tools is quite fast. Also the integration of Javascript is not a big hurdle and with a few tips the DOM of each Jira page can be manipulated as desired.

You can find the final code on github.