From ddc24177dd080229ab3b7d80a1231e59d270a064 Mon Sep 17 00:00:00 2001
From: Gusted <postmaster@gusted.xyz>
Date: Fri, 29 Mar 2024 20:41:13 +0100
Subject: [PATCH] [BUG] Render emojis in labels in issue info popup

- Currently emojis that are part of the label's name aren't rendered
when shown in the popup that you get when you hover over issue
references.
- This patch fixes that by rendering the emoji.
- Adds CSS to not make the emoji big in the label.
- Resolves #1531
---
 package-lock.json                          | 171 ++++++++++++++++++++-
 package.json                               |   1 +
 web_src/css/repo.css                       |   4 +
 web_src/js/components/ContextPopup.test.js |  39 +++++
 web_src/js/components/ContextPopup.vue     |  18 +--
 web_src/js/vitest.setup.js                 |   1 +
 6 files changed, 221 insertions(+), 13 deletions(-)
 create mode 100644 web_src/js/components/ContextPopup.test.js

diff --git a/package-lock.json b/package-lock.json
index 5358428509..3b2f868ec8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -70,6 +70,7 @@
         "@stylistic/eslint-plugin-js": "1.7.0",
         "@stylistic/stylelint-plugin": "2.1.0",
         "@vitejs/plugin-vue": "5.0.4",
+        "@vue/test-utils": "2.4.5",
         "eslint": "8.57.0",
         "eslint-plugin-array-func": "4.0.0",
         "eslint-plugin-github": "4.10.2",
@@ -1329,6 +1330,12 @@
         "node": ">= 8"
       }
     },
+    "node_modules/@one-ini/wasm": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
+      "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==",
+      "dev": true
+    },
     "node_modules/@pkgjs/parseargs": {
       "version": "0.11.0",
       "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -2680,6 +2687,16 @@
       "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.21.tgz",
       "integrity": "sha512-PuJe7vDIi6VYSinuEbUIQgMIRZGgM8e4R+G+/dQTk0X1NEdvgvvgv7m+rfmDH1gZzyA1OjjoWskvHlfRNfQf3g=="
     },
+    "node_modules/@vue/test-utils": {
+      "version": "2.4.5",
+      "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.5.tgz",
+      "integrity": "sha512-oo2u7vktOyKUked36R93NB7mg2B+N7Plr8lxp2JBGwr18ch6EggFjixSCdIVVLkT6Qr0z359Xvnafc9dcKyDUg==",
+      "dev": true,
+      "dependencies": {
+        "js-beautify": "^1.14.9",
+        "vue-component-type-helpers": "^2.0.0"
+      }
+    },
     "node_modules/@webassemblyjs/ast": {
       "version": "1.12.1",
       "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz",
@@ -2862,6 +2879,15 @@
       "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
       "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="
     },
+    "node_modules/abbrev": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
+      "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==",
+      "dev": true,
+      "engines": {
+        "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+      }
+    },
     "node_modules/abort-controller": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@@ -3799,6 +3825,22 @@
       "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
       "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
     },
+    "node_modules/config-chain": {
+      "version": "1.1.13",
+      "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
+      "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
+      "dev": true,
+      "dependencies": {
+        "ini": "^1.3.4",
+        "proto-list": "~1.2.1"
+      }
+    },
+    "node_modules/config-chain/node_modules/ini": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+      "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+      "dev": true
+    },
     "node_modules/core-js-compat": {
       "version": "3.36.1",
       "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.36.1.tgz",
@@ -4775,6 +4817,48 @@
         "marked": "^4.1.0"
       }
     },
+    "node_modules/editorconfig": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
+      "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==",
+      "dev": true,
+      "dependencies": {
+        "@one-ini/wasm": "0.1.1",
+        "commander": "^10.0.0",
+        "minimatch": "9.0.1",
+        "semver": "^7.5.3"
+      },
+      "bin": {
+        "editorconfig": "bin/editorconfig"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/editorconfig/node_modules/commander": {
+      "version": "10.0.1",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
+      "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
+      "dev": true,
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/editorconfig/node_modules/minimatch": {
+      "version": "9.0.1",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz",
+      "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
     "node_modules/electron-to-chromium": {
       "version": "1.4.716",
       "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.716.tgz",
@@ -7398,6 +7482,58 @@
       "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
       "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg=="
     },
+    "node_modules/js-beautify": {
+      "version": "1.15.1",
+      "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.1.tgz",
+      "integrity": "sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==",
+      "dev": true,
+      "dependencies": {
+        "config-chain": "^1.1.13",
+        "editorconfig": "^1.0.4",
+        "glob": "^10.3.3",
+        "js-cookie": "^3.0.5",
+        "nopt": "^7.2.0"
+      },
+      "bin": {
+        "css-beautify": "js/bin/css-beautify.js",
+        "html-beautify": "js/bin/html-beautify.js",
+        "js-beautify": "js/bin/js-beautify.js"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/js-beautify/node_modules/glob": {
+      "version": "10.3.12",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.12.tgz",
+      "integrity": "sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==",
+      "dev": true,
+      "dependencies": {
+        "foreground-child": "^3.1.0",
+        "jackspeak": "^2.3.6",
+        "minimatch": "^9.0.1",
+        "minipass": "^7.0.4",
+        "path-scurry": "^1.10.2"
+      },
+      "bin": {
+        "glob": "dist/esm/bin.mjs"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/js-cookie": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
+      "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
+      "dev": true,
+      "engines": {
+        "node": ">=14"
+      }
+    },
     "node_modules/js-levenshtein-esm": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/js-levenshtein-esm/-/js-levenshtein-esm-1.2.0.tgz",
@@ -8801,6 +8937,21 @@
       "resolved": "https://registry.npmjs.org/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz",
       "integrity": "sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw=="
     },
+    "node_modules/nopt": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.0.tgz",
+      "integrity": "sha512-CVDtwCdhYIvnAzFoJ6NJ6dX3oga9/HyciQDnG1vQDjSLMeKLJ4A93ZqYKDrgYSr1FBY5/hMYC+2VCi24pgpkGA==",
+      "dev": true,
+      "dependencies": {
+        "abbrev": "^2.0.0"
+      },
+      "bin": {
+        "nopt": "bin/nopt.js"
+      },
+      "engines": {
+        "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+      }
+    },
     "node_modules/normalize-package-data": {
       "version": "2.5.0",
       "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
@@ -9140,11 +9291,11 @@
       "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
     },
     "node_modules/path-scurry": {
-      "version": "1.10.1",
-      "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz",
-      "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==",
+      "version": "1.10.2",
+      "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.2.tgz",
+      "integrity": "sha512-7xTavNy5RQXnsjANvVvMkEjvloOinkAjv/Z6Ildz9v2RinZ4SBKTWFOVRbaF8p0vpHnyjV/UwNDdKuUv6M5qcA==",
       "dependencies": {
-        "lru-cache": "^9.1.1 || ^10.0.0",
+        "lru-cache": "^10.2.0",
         "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
       },
       "engines": {
@@ -9727,6 +9878,12 @@
       "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==",
       "dev": true
     },
+    "node_modules/proto-list": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
+      "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
+      "dev": true
+    },
     "node_modules/proto-props": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/proto-props/-/proto-props-2.0.0.tgz",
@@ -12089,6 +12246,12 @@
         "vue": "^3.0.0-0 || ^2.7.0"
       }
     },
+    "node_modules/vue-component-type-helpers": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.0.7.tgz",
+      "integrity": "sha512-7e12Evdll7JcTIocojgnCgwocX4WzIYStGClBQ+QuWPinZo/vQolv2EMq4a3lg16TKfwWafLimG77bxb56UauA==",
+      "dev": true
+    },
     "node_modules/vue-eslint-parser": {
       "version": "9.4.2",
       "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.2.tgz",
diff --git a/package.json b/package.json
index b5bfda9dc6..7b40976968 100644
--- a/package.json
+++ b/package.json
@@ -69,6 +69,7 @@
     "@stylistic/eslint-plugin-js": "1.7.0",
     "@stylistic/stylelint-plugin": "2.1.0",
     "@vitejs/plugin-vue": "5.0.4",
+    "@vue/test-utils": "2.4.5",
     "eslint": "8.57.0",
     "eslint-plugin-array-func": "4.0.0",
     "eslint-plugin-github": "4.10.2",
diff --git a/web_src/css/repo.css b/web_src/css/repo.css
index 0dfd132a73..d28dc4b96d 100644
--- a/web_src/css/repo.css
+++ b/web_src/css/repo.css
@@ -3010,3 +3010,7 @@ tbody.commit-list {
   margin-top: -1px;
   border-top: 1px solid var(--color-secondary);
 }
+#issue-info-popup .emoji {
+  font-size: inherit;
+  line-height: inherit;
+}
diff --git a/web_src/js/components/ContextPopup.test.js b/web_src/js/components/ContextPopup.test.js
new file mode 100644
index 0000000000..1db6c38301
--- /dev/null
+++ b/web_src/js/components/ContextPopup.test.js
@@ -0,0 +1,39 @@
+import {mount, flushPromises} from '@vue/test-utils';
+import ContextPopup from './ContextPopup.vue';
+
+test('renders a issue info popup', async () => {
+  const owner = 'user2';
+  const repo = 'repo1';
+  const index = 1;
+  vi.spyOn(global, 'fetch').mockResolvedValue({
+    json: vi.fn().mockResolvedValue({
+      ok: true,
+      created_at: '2023-09-30T19:00:00Z',
+      repository: {full_name: owner},
+      pull_request: null,
+      state: 'open',
+      title: 'Normal issue',
+      body: 'Lorem ipsum...',
+      number: index,
+      labels: [{color: 'ee0701', name: "Bug :+1: <script class='evil'>alert('Oh no!');</script>"}],
+    }),
+    ok: true,
+  });
+
+  const wrapper = mount(ContextPopup);
+  wrapper.vm.$el.dispatchEvent(new CustomEvent('ce-load-context-popup', {detail: {owner, repo, index}}));
+  await flushPromises();
+
+  // Header
+  expect(wrapper.get('p:nth-of-type(1)').text()).toEqual('user2 on Sep 30, 2023');
+  // Title
+  expect(wrapper.get('p:nth-of-type(2)').text()).toEqual('Normal issue #1');
+  // Body
+  expect(wrapper.get('p:nth-of-type(3)').text()).toEqual('Lorem ipsum...');
+  // Check that the state is correct.
+  expect(wrapper.get('svg').classes()).toContain('octicon-issue-opened');
+  // Ensure that script is not an element.
+  expect(() => wrapper.get('.evil')).toThrowError();
+  // Check content of label
+  expect(wrapper.get('.ui.label').text()).toContain("Bug 👍 <script class='evil'>alert('Oh no!');</script>");
+});
diff --git a/web_src/js/components/ContextPopup.vue b/web_src/js/components/ContextPopup.vue
index d87eb1a180..ac6a8f3bb6 100644
--- a/web_src/js/components/ContextPopup.vue
+++ b/web_src/js/components/ContextPopup.vue
@@ -3,6 +3,8 @@ import {SvgIcon} from '../svg.js';
 import {useLightTextOnBackground} from '../utils/color.js';
 import tinycolor from 'tinycolor2';
 import {GET} from '../modules/fetch.js';
+import {emojiHTML} from '../features/emoji.js';
+import {htmlEscape} from 'escape-goat';
 
 const {appSubUrl, i18n} = window.config;
 
@@ -67,6 +69,10 @@ export default {
         } else {
           textColor = '#111111';
         }
+        label.name = htmlEscape(label.name);
+        label.name = label.name.replaceAll(/:[-+\w]+:/g, (emoji) => {
+          return emojiHTML(emoji.substring(1, emoji.length - 1));
+        });
         return {name: label.name, color: `#${label.color}`, textColor};
       });
     },
@@ -104,19 +110,13 @@ export default {
 <template>
   <div ref="root">
     <div v-if="loading" class="tw-h-12 tw-w-12 is-loading"/>
-    <div v-if="!loading && issue !== null">
+    <div v-if="!loading && issue !== null" id="issue-info-popup">
       <p><small>{{ issue.repository.full_name }} on {{ createdAt }}</small></p>
       <p><svg-icon :name="icon" :class="['text', color]"/> <strong>{{ issue.title }}</strong> #{{ issue.number }}</p>
       <p>{{ body }}</p>
       <div>
-        <div
-          v-for="label in labels"
-          :key="label.name"
-          class="ui label"
-          :style="{ color: label.textColor, backgroundColor: label.color }"
-        >
-          {{ label.name }}
-        </div>
+        <!-- eslint-disable-next-line vue/no-v-html -->
+        <div v-for="label in labels" :key="label.name" class="ui label" :style="{ color: label.textColor, backgroundColor: label.color }" v-html="label.name"/>
       </div>
     </div>
     <div v-if="!loading && issue === null">
diff --git a/web_src/js/vitest.setup.js b/web_src/js/vitest.setup.js
index 6fb0f5dc8f..5366958fb5 100644
--- a/web_src/js/vitest.setup.js
+++ b/web_src/js/vitest.setup.js
@@ -4,6 +4,7 @@ window.config = {
   csrfToken: 'test-csrf-token-123456',
   pageData: {},
   i18n: {},
+  customEmojis: {},
   appSubUrl: '',
   mentionValues: [
     {key: 'user1 User 1', value: 'user1', name: 'user1', fullname: 'User 1', avatar: 'https://avatar1.com'},