Quellcode durchsuchen

refactor: use Bootstrap only via UiHelper and test against it

Bootstrap 5 is the only supported template since the Bootstrap 3 removal
in 1b132724, but the JavaScript still checked for it in several places,
with fallbacks that only the unit tests ran, as they had no Bootstrap.
These checks are replaced by UiHelper.showModal, hideModal and
showDropdown, now the only functions using the UI framework directly,
and the tests run against the real Bootstrap bundle.

- UiHelper: new modal and dropdown functions reuse existing Bootstrap
instances, so reopening the e-mail confirmation dialog or highlighting
an attachment no longer creates duplicate instances that log errors
- Helper: removed isBootstrap5
- Prompt: shows the password dialog with a static backdrop on request,
instead of creating it in init, and drops the fallbacks
- TopNav: the retry button always toggles its own list item
- Alert: messages replace everything next to the icon in its parent
element, so a status message rendered by the server can't stay
visible, while buttons next to it are kept
- common.js: loads bootstrap-5.3.8.js into every jsdom window

Tests:
- UiHelper "shows and hides a modal repeatedly", "ignores hiding a modal
that was never shown", "keeps the options of the first showing of a
modal" and "lets a modal without options be closed with Escape"
- Prompt "shows the password dialog until a password was submitted" and
"asks for confirmation before loading a document"
- Email "Shows the modal and hides it again once a timezone was chosen"
catches duplicate modal instances
- TopNav "opens the attachment dropdown and highlights the file
selection", collapseBar now checks that Bootstrap collapses the
navigation
- Alert "replaces a status message rendered by the server, keeping its
buttons (bootstrap)"
- removed the isBootstrap5 tests, the Bootstrap 3 retry button test and
the bootstrap stubs; fixtures use Bootstrap 5 modal and dropdown markup
El RIDO vor 1 Woche
Ursprung
Commit
ebb33eb0bd
10 geänderte Dateien mit 345 neuen und 151 gelöschten Zeilen
  1. 2 0
      CHANGELOG.md
  2. 10 0
      js/common.js
  3. 63 69
      js/privatebin.js
  4. 27 4
      js/test/Alert.js
  5. 0 12
      js/test/Helper.js
  6. 60 0
      js/test/Prompt.js
  7. 65 59
      js/test/TopNav.js
  8. 70 0
      js/test/UiHelper.js
  9. 47 6
      js/test/emailTemplateTest.js
  10. 1 1
      lib/Configuration.php

+ 2 - 0
CHANGELOG.md

@@ -2,8 +2,10 @@
 
 
 ## 2.1.2 (not yet released)
 ## 2.1.2 (not yet released)
 * CHANGED: Removed the Bootstrap 3 leftovers: its stylesheets, glyphicon fonts, unused icons and the mapping of `bootstrap-*` template names to the removed `bootstrap` template, which now fail with a "Template not found" error instead of rendering a page with missing JavaScript dependencies. Set `template = "bootstrap5"` in `cfg/conf.php`, or upgrade custom templates to Bootstrap 5.
 * CHANGED: Removed the Bootstrap 3 leftovers: its stylesheets, glyphicon fonts, unused icons and the mapping of `bootstrap-*` template names to the removed `bootstrap` template, which now fail with a "Template not found" error instead of rendering a page with missing JavaScript dependencies. Set `template = "bootstrap5"` in `cfg/conf.php`, or upgrade custom templates to Bootstrap 5.
+* CHANGED: The JavaScript requires Bootstrap 5, which it now only uses via `UiHelper`, instead of checking for it in various places
 * FIXED: Navigation menu on small screens didn't collapse after sending or cloning a document
 * FIXED: Navigation menu on small screens didn't collapse after sending or cloning a document
 * FIXED: Loading, status and comment reply messages didn't update their icons, i.e. when sending or decrypting a document or when posting a comment failed
 * FIXED: Loading, status and comment reply messages didn't update their icons, i.e. when sending or decrypting a document or when posting a comment failed
+* FIXED: Opening the e-mail confirmation dialog again or highlighting the opened attachment menu created duplicate Bootstrap components, logging errors in the browser console
 
 
 ## 2.1.1 (not yet released)
 ## 2.1.1 (not yet released)
 * ADDED: Translation for Korean (#2071)
 * ADDED: Translation for Korean (#2071)

+ 10 - 0
js/common.js

@@ -7,6 +7,14 @@ global.jsdom = require('jsdom-global');
 // initial DOM environment created by jsdom-global
 // initial DOM environment created by jsdom-global
 let currentCleanup = global.jsdom();
 let currentCleanup = global.jsdom();
 
 
+// Loads bootstrap into the current jsdom environment. It registers its
+// data API event handlers on the document when loaded, so it needs to be
+// reloaded for every new window, like privatebin.js.
+function loadBootstrap() {
+    delete require.cache[require.resolve('./bootstrap-5.3.8')];
+    global.bootstrap = window.bootstrap = require('./bootstrap-5.3.8');
+}
+
 // Recreates the jsdom environment and reloads privatebin.js into it.
 // Recreates the jsdom environment and reloads privatebin.js into it.
 // The reload is necessary because modules close over DOM element references
 // The reload is necessary because modules close over DOM element references
 // in their init() methods, and pending async callbacks (e.g. setTimeout in
 // in their init() methods, and pending async callbacks (e.g. setTimeout in
@@ -16,6 +24,7 @@ global.cleanup = function (...args) {
         currentCleanup();
         currentCleanup();
     }
     }
     currentCleanup = global.jsdom(...args);
     currentCleanup = global.jsdom(...args);
+    loadBootstrap();
     delete require.cache[require.resolve('./privatebin')];
     delete require.cache[require.resolve('./privatebin')];
     delete require.cache[require.resolve('./legacy')];
     delete require.cache[require.resolve('./legacy')];
     require('./privatebin');
     require('./privatebin');
@@ -34,6 +43,7 @@ global.showdown = require('./showdown-2.1.0');
 global.DOMPurify = require('./purify-3.4.17');
 global.DOMPurify = require('./purify-3.4.17');
 global.baseX = require('./base-x-5.0.1').baseX;
 global.baseX = require('./base-x-5.0.1').baseX;
 global.Legacy = require('./legacy').Legacy;
 global.Legacy = require('./legacy').Legacy;
+loadBootstrap();
 require('./privatebin');
 require('./privatebin');
 global.PrivateBin = window.PrivateBin;
 global.PrivateBin = window.PrivateBin;
 
 

+ 63 - 69
js/privatebin.js

@@ -638,16 +638,6 @@ window.PrivateBin = (function () {
             baseUri = null;
             baseUri = null;
         };
         };
 
 
-        /**
-         * check if bootstrap5 object detected
-         *
-         * @name Helper.isBootstrap5
-         * @returns {Boolean}
-         */
-        me.isBootstrap5 = function () {
-            return typeof bootstrap !== 'undefined';
-        };
-
         /**
         /**
          * set the icon of an element, by pointing its icon sprite reference
          * set the icon of an element, by pointing its icon sprite reference
          * to the given bootstrap icon
          * to the given bootstrap icon
@@ -1633,6 +1623,46 @@ window.PrivateBin = (function () {
             window.location.href = Helper.baseUri();
             window.location.href = Helper.baseUri();
         };
         };
 
 
+        /**
+         * show a modal dialog
+         *
+         * This and the following functions are the only places that use the
+         * UI framework (bootstrap) directly.
+         *
+         * @name   UiHelper.showModal
+         * @function
+         * @param  {HTMLElement} element - the modal
+         * @param  {object} [options] - only applied when first showing it
+         */
+        me.showModal = function (element, options) {
+            bootstrap.Modal.getOrCreateInstance(element, options).show();
+        };
+
+        /**
+         * hide a modal dialog, if it was shown
+         *
+         * @name   UiHelper.hideModal
+         * @function
+         * @param  {HTMLElement} element - the modal
+         */
+        me.hideModal = function (element) {
+            const modal = bootstrap.Modal.getInstance(element);
+            if (modal) {
+                modal.hide();
+            }
+        };
+
+        /**
+         * open a dropdown menu
+         *
+         * @name   UiHelper.showDropdown
+         * @function
+         * @param  {HTMLElement} toggle - the element toggling the dropdown
+         */
+        me.showDropdown = function (toggle) {
+            bootstrap.Dropdown.getOrCreateInstance(toggle).show();
+        };
+
 
 
         /**
         /**
          * initialize
          * initialize
@@ -1719,14 +1749,17 @@ window.PrivateBin = (function () {
             let translationTarget = element;
             let translationTarget = element;
 
 
             // handle icon, if template uses one
             // handle icon, if template uses one
-            const iconElement = element.querySelector(':first-child');
+            const iconElement = element.querySelector('svg');
             if (iconElement) {
             if (iconElement) {
                 // if there is an icon, we need to provide an inner element
                 // if there is an icon, we need to provide an inner element
-                // to translate the message into, instead of the parent
+                // to translate the message into, instead of the parent,
+                // replacing any previous message next to the icon, but
+                // keeping other elements of the alert, i.e. buttons
+                const container = iconElement.parentElement;
                 translationTarget = document.createElement('span');
                 translationTarget = document.createElement('span');
-                element.innerHTML = ' ';
-                element.prepend(iconElement);
-                element.appendChild(translationTarget);
+                container.innerHTML = ' ';
+                container.prepend(iconElement);
+                container.appendChild(translationTarget);
 
 
                 if (typeof icon === 'string' && icon.length > 0) {
                 if (typeof icon === 'string' && icon.length > 0) {
                     Helper.setIcon(iconElement, icon);
                     Helper.setIcon(iconElement, icon);
@@ -2150,7 +2183,6 @@ window.PrivateBin = (function () {
 
 
         let passwordDecrypt,
         let passwordDecrypt,
             passwordModal,
             passwordModal,
-            bootstrap5PasswordModal = null,
             password = '';
             password = '';
 
 
         /**
         /**
@@ -2167,15 +2199,7 @@ window.PrivateBin = (function () {
             // get input
             // get input
             password = passwordDecrypt.value;
             password = passwordDecrypt.value;
 
 
-            // hide modal
-            if (bootstrap5PasswordModal) {
-                bootstrap5PasswordModal.hide();
-            } else {
-                if (passwordModal) {
-                    passwordModal.classList.remove('show');
-                    passwordModal.style.display = 'none';
-                }
-            }
+            UiHelper.hideModal(passwordModal);
 
 
             PasteDecrypter.run();
             PasteDecrypter.run();
         }
         }
@@ -2201,12 +2225,7 @@ window.PrivateBin = (function () {
                 loadconfirmOpenNow.focus();
                 loadconfirmOpenNow.focus();
             });
             });
 
 
-            if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip && typeof bootstrap.Modal === 'function') {
-                (new bootstrap.Modal(loadconfirmmodal)).show();
-            } else {
-                // minimal fallback: make element visible
-                loadconfirmmodal.classList.add('show');
-            }
+            UiHelper.showModal(loadconfirmmodal);
         };
         };
 
 
         /**
         /**
@@ -2216,11 +2235,12 @@ window.PrivateBin = (function () {
          * @function
          * @function
          */
          */
         me.requestPassword = function () {
         me.requestPassword = function () {
-            // show new bootstrap method (if available)
             if (passwordModal !== null) {
             if (passwordModal !== null) {
-                if (bootstrap5PasswordModal) {
-                    bootstrap5PasswordModal.show();
-                }
+                // the password is required, so the modal can't be closed
+                UiHelper.showModal(passwordModal, {
+                    backdrop: 'static',
+                    keyboard: false
+                });
                 return;
                 return;
             }
             }
 
 
@@ -2274,19 +2294,7 @@ window.PrivateBin = (function () {
                     passwordForm.addEventListener('submit', submitPasswordModal);
                     passwordForm.addEventListener('submit', submitPasswordModal);
                 }
                 }
 
 
-                const disableClosingConfig = {
-                    backdrop: 'static',
-                    keyboard: false,
-                    show: false
-                };
-                if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip && typeof bootstrap.Modal === 'function') {
-                    bootstrap5PasswordModal = new bootstrap.Modal(passwordModal, disableClosingConfig);
-                } else {
-                    // fallback: keep modal hidden until explicitly shown
-                    passwordModal.style.display = 'none';
-                }
-
-                // ensure focus when modal shown (works for bootstrap5)
+                // ensure focus when modal shown
                 passwordModal.addEventListener('shown.bs.modal', () => {
                 passwordModal.addEventListener('shown.bs.modal', () => {
                     passwordDecrypt.focus();
                     passwordDecrypt.focus();
                 });
                 });
@@ -4158,8 +4166,6 @@ window.PrivateBin = (function () {
                 const emailconfirmTimezoneCurrent = emailconfirmmodal.querySelector('#emailconfirm-timezone-current');
                 const emailconfirmTimezoneCurrent = emailconfirmmodal.querySelector('#emailconfirm-timezone-current');
                 const emailconfirmTimezoneUtc = emailconfirmmodal.querySelector('#emailconfirm-timezone-utc');
                 const emailconfirmTimezoneUtc = emailconfirmmodal.querySelector('#emailconfirm-timezone-utc');
                 let localeConfiguration = { dateStyle: 'long', timeStyle: 'long' };
                 let localeConfiguration = { dateStyle: 'long', timeStyle: 'long' };
-                const bootstrap5EmailConfirmModal = typeof bootstrap !== 'undefined' && bootstrap.Tooltip && bootstrap.Tooltip.VERSION ?
-                    new bootstrap.Modal(emailconfirmmodal) : null;
 
 
                 function sendEmailAndHideModal() {
                 function sendEmailAndHideModal() {
                     const emailBody = templateEmailBody(
                     const emailBody = templateEmailBody(
@@ -4168,9 +4174,7 @@ window.PrivateBin = (function () {
                             [], localeConfiguration
                             [], localeConfiguration
                         ), isBurnafterreading
                         ), isBurnafterreading
                     );
                     );
-                    if (bootstrap5EmailConfirmModal) {
-                        bootstrap5EmailConfirmModal.hide();
-                    }
+                    UiHelper.hideModal(emailconfirmmodal);
                     triggerEmailSend(emailSubject, emailBody);
                     triggerEmailSend(emailSubject, emailBody);
                 }
                 }
 
 
@@ -4185,9 +4189,7 @@ window.PrivateBin = (function () {
                     localeConfiguration.timeZone = 'UTC';
                     localeConfiguration.timeZone = 'UTC';
                     sendEmailAndHideModal();
                     sendEmailAndHideModal();
                 });
                 });
-                if (bootstrap5EmailConfirmModal) {
-                    bootstrap5EmailConfirmModal.show();
-                }
+                UiHelper.showModal(emailconfirmmodal);
             } else {
             } else {
                 triggerEmailSend(emailSubject, templateEmailBody(null, isBurnafterreading));
                 triggerEmailSend(emailSubject, templateEmailBody(null, isBurnafterreading));
             }
             }
@@ -4325,11 +4327,9 @@ window.PrivateBin = (function () {
          */
          */
         me.showRetryButton = function () {
         me.showRetryButton = function () {
             retryButton.classList.remove('hidden');
             retryButton.classList.remove('hidden');
-            // The Bootstrap 5 template gives Retry its own list item. That item
-            // still takes a flex gap while only the button is hidden.
-            if (Helper.isBootstrap5()) {
-                retryButton.parentElement.classList.remove('hidden');
-            }
+            // The template gives Retry its own list item. That item still
+            // takes a flex gap while only the button is hidden.
+            retryButton.parentElement.classList.remove('hidden');
         };
         };
 
 
         /**
         /**
@@ -4340,9 +4340,7 @@ window.PrivateBin = (function () {
          */
          */
         me.hideRetryButton = function () {
         me.hideRetryButton = function () {
             retryButton.classList.add('hidden');
             retryButton.classList.add('hidden');
-            if (Helper.isBootstrap5()) {
-                retryButton.parentElement.classList.add('hidden');
-            }
+            retryButton.parentElement.classList.add('hidden');
         };
         };
 
 
         /**
         /**
@@ -4631,11 +4629,7 @@ window.PrivateBin = (function () {
             // visually indicate file uploaded
             // visually indicate file uploaded
             const attachDropdownToggle = attach && attach.querySelector('.dropdown-toggle');
             const attachDropdownToggle = attach && attach.querySelector('.dropdown-toggle');
             if (attachDropdownToggle && attachDropdownToggle.getAttribute('aria-expanded') === 'false') {
             if (attachDropdownToggle && attachDropdownToggle.getAttribute('aria-expanded') === 'false') {
-                if (Helper.isBootstrap5()) {
-                    new bootstrap.Dropdown(attachDropdownToggle).toggle();
-                } else {
-                    attachDropdownToggle.click();
-                }
+                UiHelper.showDropdown(attachDropdownToggle);
             }
             }
             fileWrap.classList.add('highlight');
             fileWrap.classList.add('highlight');
             setTimeout(function () {
             setTimeout(function () {

+ 27 - 4
js/test/Alert.js

@@ -41,8 +41,8 @@ describe('Alert', function () {
                     message = message.join('');
                     message = message.join('');
                     const expected = '<div id="status" role="alert" ' +
                     const expected = '<div id="status" role="alert" ' +
                         'class="d-flex align-items-center gap-2 alert alert-info">' +
                         'class="d-flex align-items-center gap-2 alert alert-info">' +
-                        '<div>' + icon('info-circle') + ' </div> <span>' +
-                        message + '</span></div>';
+                        '<div>' + icon('info-circle') + ' <span>' +
+                        message + '</span></div></div>';
                     document.body.innerHTML =
                     document.body.innerHTML =
                         '<div id="status" role="alert" class="d-flex ' +
                         '<div id="status" role="alert" class="d-flex ' +
                         'align-items-center gap-2 alert alert-info hidden">' +
                         'align-items-center gap-2 alert alert-info hidden">' +
@@ -64,8 +64,8 @@ describe('Alert', function () {
                     message = message.join('');
                     message = message.join('');
                     const expected = '<div id="status" role="alert" ' +
                     const expected = '<div id="status" role="alert" ' +
                         'class="d-flex align-items-center gap-2 alert alert-info">' +
                         'class="d-flex align-items-center gap-2 alert alert-info">' +
-                        '<div>' + icon(customIcon || 'info-circle') + ' </div> <span>' +
-                        message + '</span></div>';
+                        '<div>' + icon(customIcon || 'info-circle') + ' <span>' +
+                        message + '</span></div></div>';
                     document.body.innerHTML =
                     document.body.innerHTML =
                         '<div id="status" role="alert" class="d-flex ' +
                         '<div id="status" role="alert" class="d-flex ' +
                         'align-items-center gap-2 alert alert-info hidden">' +
                         'align-items-center gap-2 alert alert-info hidden">' +
@@ -77,6 +77,29 @@ describe('Alert', function () {
                 }
                 }
             ));
             ));
         });
         });
+
+        it('replaces a status message rendered by the server, keeping its buttons (bootstrap)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString(), {minLength: 1}),
+                fc.array(common.fcAlnumString(), {minLength: 1}),
+                function (serverMessage, message) {
+                    serverMessage = 'server ' + serverMessage.join('');
+                    message = message.join('');
+                    const button = '<button type="button" id="new-from-alert">Start over</button>';
+                    document.body.innerHTML =
+                        '<div id="status" role="alert" class="d-flex ' +
+                        'align-items-center gap-2 alert alert-success"><div>' +
+                        icon('info-circle') + ' ' + serverMessage + ' </div>' +
+                        button + '</div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showStatus(message);
+                    const status = document.getElementById('status');
+                    return status.innerHTML === '<div>' + icon('info-circle') +
+                        ' <span>' + message + '</span></div>' + button &&
+                        status.textContent.indexOf(serverMessage) === -1;
+                }
+            ));
+        });
     });
     });
 
 
     describe('showWarning', function () {
     describe('showWarning', function () {

+ 0 - 12
js/test/Helper.js

@@ -387,18 +387,6 @@ describe('Helper', function () {
     });
     });
 
 
 
 
-    describe('isBootstrap5', function () {
-        it('Bootstrap 5 has been detected', function () {
-            global.bootstrap = {};
-            return PrivateBin.Helper.isBootstrap5() === true;
-        });
-
-        it('Bootstrap 5 has not been detected', function () {
-            delete global.bootstrap;
-            return PrivateBin.Helper.isBootstrap5() === false;
-        });
-    });
-
     describe('setIcon', function () {
     describe('setIcon', function () {
         afterEach(function () {
         afterEach(function () {
             cleanup();
             cleanup();

+ 60 - 0
js/test/Prompt.js

@@ -83,4 +83,64 @@ describe('Prompt', function () {
             ));
             ));
         });
         });
     });
     });
+
+    describe('modal dialogs', function () {
+        afterEach(function () {
+            cleanup();
+        });
+
+        it('shows the password dialog until a password was submitted', function () {
+            const clean = globalThis.cleanup('', {url: 'ftp://example.com/?0000000000000000'});
+            document.body.innerHTML =
+                '<div id="passwordmodal" tabindex="-1" class="modal" role="dialog" aria-hidden="true">' +
+                '<div class="modal-dialog" role="document"><div class="modal-content"><div class="modal-body">' +
+                '<form id="passwordform" role="form"><input id="passworddecrypt" type="password" />' +
+                '<button type="submit">Decrypt</button></form></div></div></div></div>';
+            PrivateBin.Alert.init();
+            PrivateBin.TopNav.init();
+            PrivateBin.Prompt.init();
+            const modal = document.getElementById('passwordmodal');
+
+            PrivateBin.Prompt.requestPassword();
+            assert.ok(modal.classList.contains('show'), 'password dialog is shown');
+            modal.dispatchEvent(new window.KeyboardEvent('keydown', {key: 'Escape', bubbles: true}));
+            assert.ok(modal.classList.contains('show'), 'password dialog can not be closed with Escape');
+
+            const originalFetch = globalThis.fetch;
+            globalThis.fetch = function () {
+                return Promise.resolve({
+                    ok: true,
+                    status: 200,
+                    statusText: 'OK',
+                    json: function () {
+                        return Promise.resolve({});
+                    }
+                });
+            };
+            try {
+                document.getElementById('passworddecrypt').value = 'secret';
+                document.getElementById('passwordform').dispatchEvent(
+                    new window.Event('submit', {bubbles: true, cancelable: true})
+                );
+                assert.ok(!modal.classList.contains('show'), 'password dialog is hidden after submitting');
+                assert.strictEqual(PrivateBin.Prompt.getPassword(), 'secret');
+            } finally {
+                globalThis.fetch = originalFetch;
+                clean();
+            }
+        });
+
+        it('asks for confirmation before loading a document', function () {
+            document.body.innerHTML =
+                '<div id="loadconfirmmodal" tabindex="-1" class="modal" role="dialog" aria-hidden="true">' +
+                '<div class="modal-dialog" role="document"><div class="modal-content">' +
+                '<div class="modal-header"><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>' +
+                '<div class="modal-body"><button id="loadconfirm-open-now" type="button" data-bs-dismiss="modal">' +
+                'Yes, see it</button></div></div></div></div>';
+            const modal = document.getElementById('loadconfirmmodal');
+            PrivateBin.Prompt.requestLoadConfirmation();
+            assert.ok(modal.classList.contains('show'), 'confirmation dialog is shown');
+            assert.strictEqual(document.activeElement, document.getElementById('loadconfirm-open-now'));
+        });
+    });
 });
 });

+ 65 - 59
js/test/TopNav.js

@@ -409,10 +409,10 @@ describe('TopNav', function () {
                 let results = [];
                 let results = [];
                 document.documentElement.innerHTML =
                 document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li id="attach" class="hidden ' +
                     '<nav><div id="navbar"><ul><li id="attach" class="hidden ' +
-                    'dropdown"><a href="#" class="dropdown-toggle" data-' +
-                    'toggle="dropdown" role="button" aria-haspopup="true" ' +
-                    'aria-expanded="false">Attach a file <span class="caret">' +
-                    '</span></a><ul class="dropdown-menu"><li id="filewrap">' +
+                    'dropdown"><a href="#" class="nav-link dropdown-toggle" ' +
+                    'data-bs-toggle="dropdown" role="button" ' +
+                    'aria-expanded="false">Attach a file</a>' +
+                    '<ul class="dropdown-menu px-2"><li id="filewrap">' +
                     '<div><input type="file" id="file" name="file" /></div>' +
                     '<div><input type="file" id="file" name="file" /></div>' +
                     '</li><li id="customattachment" class="hidden"></li><li>' +
                     '</li><li id="customattachment" class="hidden"></li><li>' +
                     '<a id="fileremovebutton"  href="#">Remove attachment</a>' +
                     '<a id="fileremovebutton"  href="#">Remove attachment</a>' +
@@ -435,6 +435,52 @@ describe('TopNav', function () {
         );
         );
     });
     });
 
 
+    describe('highlightFileupload', function () {
+        afterEach(function () {
+            cleanup();
+        });
+
+        it(
+            'opens the attachment dropdown and highlights the file selection',
+            function () {
+                cleanup();
+                document.documentElement.innerHTML =
+                    '<nav><div id="navbar"><ul><li id="attach" class="nav-item ' +
+                    'dropdown"><a href="#" class="nav-link dropdown-toggle" ' +
+                    'data-bs-toggle="dropdown" role="button" ' +
+                    'aria-expanded="false">Attach a file</a>' +
+                    '<ul class="dropdown-menu px-2"><li id="filewrap">' +
+                    '<div><input type="file" id="file" name="file" /></div>' +
+                    '</li></ul></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
+                const errors = [];
+                const consoleError = console.error;
+                console.error = function (...args) {
+                    errors.push(args.join(' '));
+                };
+                try {
+                    PrivateBin.TopNav.highlightFileupload();
+                    assert.strictEqual(query('.dropdown-toggle').getAttribute('aria-expanded'), 'true');
+                    assert.ok(query('.dropdown-menu').classList.contains('show'), 'dropdown is open');
+                    assert.ok(query('#filewrap').classList.contains('highlight'), 'file selection is highlighted');
+
+                    // a second upload, while the dropdown is still open
+                    PrivateBin.TopNav.highlightFileupload();
+                    assert.ok(query('.dropdown-menu').classList.contains('show'), 'dropdown stays open');
+
+                    // a third upload, after the user closed the dropdown
+                    query('.dropdown-toggle').click();
+                    assert.strictEqual(query('.dropdown-toggle').getAttribute('aria-expanded'), 'false');
+                    PrivateBin.TopNav.highlightFileupload();
+                    assert.ok(query('.dropdown-menu').classList.contains('show'), 'dropdown is open again');
+                } finally {
+                    console.error = consoleError;
+                }
+                assert.deepStrictEqual(errors, []);
+            }
+        );
+    });
+
     describe('showCustomAttachment', function () {
     describe('showCustomAttachment', function () {
         before(function () {
         before(function () {
             cleanup();
             cleanup();
@@ -446,10 +492,10 @@ describe('TopNav', function () {
                 let results = [];
                 let results = [];
                 document.documentElement.innerHTML =
                 document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li id="attach" class="hidden ' +
                     '<nav><div id="navbar"><ul><li id="attach" class="hidden ' +
-                    'dropdown"><a href="#" class="dropdown-toggle" data-' +
-                    'toggle="dropdown" role="button" aria-haspopup="true" ' +
-                    'aria-expanded="false">Attach a file <span class="caret">' +
-                    '</span></a><ul class="dropdown-menu"><li id="filewrap">' +
+                    'dropdown"><a href="#" class="nav-link dropdown-toggle" ' +
+                    'data-bs-toggle="dropdown" role="button" ' +
+                    'aria-expanded="false">Attach a file</a>' +
+                    '<ul class="dropdown-menu px-2"><li id="filewrap">' +
                     '<div><input type="file" id="file" name="file" /></div>' +
                     '<div><input type="file" id="file" name="file" /></div>' +
                     '</li><li id="customattachment" class="hidden"></li><li>' +
                     '</li><li id="customattachment" class="hidden"></li><li>' +
                     '<a id="fileremovebutton"  href="#">Remove attachment</a>' +
                     '<a id="fileremovebutton"  href="#">Remove attachment</a>' +
@@ -507,6 +553,7 @@ describe('TopNav', function () {
                 });
                 });
                 PrivateBin.TopNav.collapseBar();
                 PrivateBin.TopNav.collapseBar();
                 assert.strictEqual(clicks, 1, 'toggler is clicked once');
                 assert.strictEqual(clicks, 1, 'toggler is clicked once');
+                assert.ok(!query('#navbar').classList.contains('show'), 'navigation is collapsing');
             }
             }
         );
         );
 
 
@@ -521,6 +568,7 @@ describe('TopNav', function () {
                 });
                 });
                 PrivateBin.TopNav.collapseBar();
                 PrivateBin.TopNav.collapseBar();
                 assert.strictEqual(clicks, 0, 'toggler is not clicked');
                 assert.strictEqual(clicks, 0, 'toggler is not clicked');
+                assert.ok(!query('#navbar').classList.contains('collapsing'), 'navigation is not toggled');
             }
             }
         );
         );
 
 
@@ -826,10 +874,10 @@ describe('TopNav', function () {
                 let results = [];
                 let results = [];
                 document.documentElement.innerHTML =
                 document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li id="attach" class="hidden ' +
                     '<nav><div id="navbar"><ul><li id="attach" class="hidden ' +
-                    'dropdown"><a href="#" class="dropdown-toggle" data-' +
-                    'toggle="dropdown" role="button" aria-haspopup="true" ' +
-                    'aria-expanded="false">Attach a file <span class="caret">' +
-                    '</span></a><ul class="dropdown-menu"><li id="filewrap">' +
+                    'dropdown"><a href="#" class="nav-link dropdown-toggle" ' +
+                    'data-bs-toggle="dropdown" role="button" ' +
+                    'aria-expanded="false">Attach a file</a>' +
+                    '<ul class="dropdown-menu px-2"><li id="filewrap">' +
                     '<div><input type="file" id="file" name="file" /></div>' +
                     '<div><input type="file" id="file" name="file" /></div>' +
                     '</li><li id="customattachment" class="hidden"></li><li>' +
                     '</li><li id="customattachment" class="hidden"></li><li>' +
                     '<a id="fileremovebutton"  href="#">Remove attachment</a>' +
                     '<a id="fileremovebutton"  href="#">Remove attachment</a>' +
@@ -981,10 +1029,10 @@ describe('TopNav', function () {
                 let results = [];
                 let results = [];
                 document.documentElement.innerHTML =
                 document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li id="attach" class="hidden ' +
                     '<nav><div id="navbar"><ul><li id="attach" class="hidden ' +
-                    'dropdown"><a href="#" class="dropdown-toggle" data-' +
-                    'toggle="dropdown" role="button" aria-haspopup="true" ' +
-                    'aria-expanded="false">Attach a file <span class="caret">' +
-                    '</span></a><ul class="dropdown-menu"><li id="filewrap">' +
+                    'dropdown"><a href="#" class="nav-link dropdown-toggle" ' +
+                    'data-bs-toggle="dropdown" role="button" ' +
+                    'aria-expanded="false">Attach a file</a>' +
+                    '<ul class="dropdown-menu px-2"><li id="filewrap">' +
                     '<div><input type="file" id="file" name="file" /></div>' +
                     '<div><input type="file" id="file" name="file" /></div>' +
                     '</li><li id="customattachment" class="hidden"></li><li>' +
                     '</li><li id="customattachment" class="hidden"></li><li>' +
                     '<a id="fileremovebutton"  href="#">Remove attachment</a>' +
                     '<a id="fileremovebutton"  href="#">Remove attachment</a>' +
@@ -1048,15 +1096,13 @@ describe('TopNav', function () {
 
 
     describe('showRetryButton & hideRetryButton', function () {
     describe('showRetryButton & hideRetryButton', function () {
         afterEach(function () {
         afterEach(function () {
-            delete global.bootstrap;
             cleanup();
             cleanup();
         });
         });
 
 
         it(
         it(
-            'shows and hides the Bootstrap 5 retry button with its list item and runs the callback once',
+            'shows and hides the retry button with its list item and runs the callback once',
             function () {
             function () {
                 cleanup();
                 cleanup();
-                global.bootstrap = {};
                 document.documentElement.innerHTML =
                 document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul class="navbar-nav">' +
                     '<nav><div id="navbar"><ul class="navbar-nav">' +
                     '<li class="nav-item hidden"><button id="retrybutton" ' +
                     '<li class="nav-item hidden"><button id="retrybutton" ' +
@@ -1092,46 +1138,6 @@ describe('TopNav', function () {
                 assert.strictEqual(calls, 1);
                 assert.strictEqual(calls, 1);
             }
             }
         );
         );
-
-        it(
-            'toggles only the retry button on legacy Bootstrap 3 and page templates',
-            function () {
-                cleanup();
-                delete global.bootstrap;
-                document.documentElement.innerHTML =
-                    '<nav><div id="navbar"><ul class="nav navbar-nav"><li>' +
-                    '<button id="retrybutton" type="button" class="hidden">' +
-                    'Retry</button><button id="newbutton" type="button">' +
-                    'New</button></li></ul></div></nav>';
-                PrivateBin.TopNav.init();
-                let calls = 0;
-                PrivateBin.TopNav.setRetryCallback(function () {
-                    calls++;
-                });
-                const retryItem = query('#retrybutton').parentElement;
-
-                assert.ok(query('#retrybutton').classList.contains('hidden'));
-                assert.ok(!retryItem.classList.contains('hidden'));
-
-                PrivateBin.TopNav.showRetryButton();
-                assert.ok(!query('#retrybutton').classList.contains('hidden'));
-                assert.ok(!retryItem.classList.contains('hidden'));
-                assert.ok(!query('#newbutton').classList.contains('hidden'));
-
-                query('#retrybutton').click();
-                assert.strictEqual(calls, 1);
-
-                PrivateBin.TopNav.hideRetryButton();
-                assert.ok(query('#retrybutton').classList.contains('hidden'));
-                assert.ok(!retryItem.classList.contains('hidden'));
-                assert.ok(!query('#newbutton').classList.contains('hidden'));
-
-                PrivateBin.TopNav.showRetryButton();
-                PrivateBin.TopNav.hideRetryButton();
-                assert.ok(query('#retrybutton').classList.contains('hidden'));
-                assert.strictEqual(calls, 1);
-            }
-        );
     });
     });
 
 
     describe('rawText', function () {
     describe('rawText', function () {

+ 70 - 0
js/test/UiHelper.js

@@ -89,4 +89,74 @@ describe('UiHelper', function () {
         });
         });
         */
         */
     });
     });
+
+    describe('showModal & hideModal', function () {
+        beforeEach(function () {
+            cleanup();
+            document.body.innerHTML =
+                '<div id="testmodal" tabindex="-1" class="modal" role="dialog" aria-hidden="true">' +
+                '<div class="modal-dialog" role="document"><div class="modal-content">' +
+                '<div class="modal-body"><button type="button" data-bs-dismiss="modal">Close</button>' +
+                '</div></div></div></div>';
+        });
+
+        /**
+         * runs the given action, while collecting messages logged as errors
+         *
+         * @param {function} action
+         * @returns {string[]} logged errors
+         */
+        function collectErrors(action) {
+            const errors = [];
+            const consoleError = console.error;
+            console.error = function (...args) {
+                errors.push(args.join(' '));
+            };
+            try {
+                action();
+            } finally {
+                console.error = consoleError;
+            }
+            return errors;
+        }
+
+        it('shows and hides a modal repeatedly', function () {
+            const modal = document.getElementById('testmodal');
+            const errors = collectErrors(function () {
+                for (let i = 0; i < 3; ++i) {
+                    PrivateBin.UiHelper.showModal(modal);
+                    assert.ok(modal.classList.contains('show'), 'modal is shown');
+                    assert.strictEqual(modal.getAttribute('aria-modal'), 'true');
+                    PrivateBin.UiHelper.hideModal(modal);
+                    assert.ok(!modal.classList.contains('show'), 'modal is hidden');
+                    assert.strictEqual(modal.style.display, 'none');
+                }
+            });
+            assert.deepStrictEqual(errors, []);
+        });
+
+        it('ignores hiding a modal that was never shown', function () {
+            const modal = document.getElementById('testmodal');
+            assert.doesNotThrow(() => PrivateBin.UiHelper.hideModal(modal));
+            assert.ok(!modal.classList.contains('show'));
+        });
+
+        it('keeps the options of the first showing of a modal', function () {
+            const modal = document.getElementById('testmodal');
+            PrivateBin.UiHelper.showModal(modal, {backdrop: 'static', keyboard: false});
+            modal.dispatchEvent(new window.KeyboardEvent('keydown', {key: 'Escape', bubbles: true}));
+            assert.ok(modal.classList.contains('show'), 'modal can not be closed with Escape');
+            PrivateBin.UiHelper.hideModal(modal);
+            PrivateBin.UiHelper.showModal(modal);
+            modal.dispatchEvent(new window.KeyboardEvent('keydown', {key: 'Escape', bubbles: true}));
+            assert.ok(modal.classList.contains('show'), 'modal still can not be closed with Escape');
+        });
+
+        it('lets a modal without options be closed with Escape', function () {
+            const modal = document.getElementById('testmodal');
+            PrivateBin.UiHelper.showModal(modal);
+            modal.dispatchEvent(new window.KeyboardEvent('keydown', {key: 'Escape', bubbles: true}));
+            assert.ok(!modal.classList.contains('show'), 'modal was closed with Escape');
+        });
+    });
 });
 });

+ 47 - 6
js/test/emailTemplateTest.js

@@ -17,9 +17,11 @@ function buildEmailDomNoShortUrl() {
         '</ul></div></nav>' +
         '</ul></div></nav>' +
         '<input id="burnafterreadingoption" type="checkbox">' +
         '<input id="burnafterreadingoption" type="checkbox">' +
         // include dummy email confirm modal for sendEmail
         // include dummy email confirm modal for sendEmail
-        '<div id="emailconfirmmodal" class="hidden">' +
-            '<div id="emailconfirm-timezone-current"></div>' +
-            '<div id="emailconfirm-timezone-utc"></div>' +
+        '<div id="emailconfirmmodal" tabindex="-1" class="modal" role="dialog" aria-hidden="true">' +
+            '<div class="modal-dialog" role="document"><div class="modal-content"><div class="modal-body row">' +
+                '<button id="emailconfirm-timezone-current" type="button">Use Current Timezone</button>' +
+                '<button id="emailconfirm-timezone-utc" type="button">Convert To UTC</button>' +
+            '</div></div></div>' +
         '</div>'
         '</div>'
 }
 }
 
 
@@ -44,9 +46,11 @@ function buildEmailDomWithShortUrl() {
           '<span id="copyhint">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>' +
           '<span id="copyhint">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>' +
         '</div>' +
         '</div>' +
         // add a minimal email confirmation modal so sendEmail does not crash
         // add a minimal email confirmation modal so sendEmail does not crash
-        '<div id="emailconfirmmodal" class="hidden">' +
-            '<div id="emailconfirm-timezone-current"></div>' +
-            '<div id="emailconfirm-timezone-utc"></div>' +
+        '<div id="emailconfirmmodal" tabindex="-1" class="modal" role="dialog" aria-hidden="true">' +
+            '<div class="modal-dialog" role="document"><div class="modal-content"><div class="modal-body row">' +
+                '<button id="emailconfirm-timezone-current" type="button">Use Current Timezone</button>' +
+                '<button id="emailconfirm-timezone-utc" type="button">Convert To UTC</button>' +
+            '</div></div></div>' +
         '</div>'
         '</div>'
 }
 }
 
 
@@ -252,3 +256,40 @@ describe('Email - mail subject', function () {
         }
         }
     });
     });
 });
 });
+
+describe('Email - expiration confirmation modal', function () {
+    beforeEach(function () {
+        cleanup(); // provided by common
+    });
+
+    it('Shows the modal and hides it again once a timezone was chosen', function () {
+        buildEmailDomNoShortUrl();
+        PrivateBin.TopNav.init();
+        PrivateBin.TopNav.showEmailButton(3600);
+        const modal = document.getElementById('emailconfirmmodal');
+
+        const { getUrl, restore } = makeWindowOpenMock();
+        const errors = [];
+        const consoleError = console.error;
+        console.error = function (...args) {
+            errors.push(args.join(' '));
+        };
+        try {
+            for (const timezone of ['current', 'utc']) {
+                document.getElementById('emaillink').click();
+                assert.ok(modal.classList.contains('show'), 'modal is shown before choosing ' + timezone);
+                assert.strictEqual(getUrl(), null, 'no email is sent before choosing ' + timezone);
+
+                document.getElementById('emailconfirm-timezone-' + timezone).click();
+                assert.ok(!modal.classList.contains('show'), 'modal is hidden after choosing ' + timezone);
+                assert.match(getUrl(), /^mailto:\?/, 'email is sent after choosing ' + timezone);
+                // reset the URL recorded by the mock
+                window.open(null);
+            }
+        } finally {
+            console.error = consoleError;
+            restore();
+        }
+        assert.deepStrictEqual(errors, [], 'reusing the modal logs no errors');
+    });
+});

+ 1 - 1
lib/Configuration.php

@@ -118,7 +118,7 @@ class Configuration
             'js/kjua-0.10.0.js'      => 'sha512-BYj4xggowR7QD150VLSTRlzH62YPfhpIM+b/1EUEr7RQpdWAGKulxWnOvjFx1FUlba4m6ihpNYuQab51H6XlYg==',
             'js/kjua-0.10.0.js'      => 'sha512-BYj4xggowR7QD150VLSTRlzH62YPfhpIM+b/1EUEr7RQpdWAGKulxWnOvjFx1FUlba4m6ihpNYuQab51H6XlYg==',
             'js/legacy.js'           => 'sha512-pRofxsrf5UItjiP22Dcjh3FAcBjF/n7h8U9/W5xqJk17U0N2U1oajhXypq/omo9jhwS1iVGOhWrRepoPeFns+w==',
             'js/legacy.js'           => 'sha512-pRofxsrf5UItjiP22Dcjh3FAcBjF/n7h8U9/W5xqJk17U0N2U1oajhXypq/omo9jhwS1iVGOhWrRepoPeFns+w==',
             'js/prettify.js'         => 'sha512-puO0Ogy++IoA2Pb9IjSxV1n4+kQkKXYAEUtVzfZpQepyDPyXk8hokiYDS7ybMogYlyyEIwMLpZqVhCkARQWLMg==',
             'js/prettify.js'         => 'sha512-puO0Ogy++IoA2Pb9IjSxV1n4+kQkKXYAEUtVzfZpQepyDPyXk8hokiYDS7ybMogYlyyEIwMLpZqVhCkARQWLMg==',
-            'js/privatebin.js'       => 'sha512-3g30E1BKNaDEZWHhD1wVRGiAYwyRp5IvvA4DFBvr4IM0r+i9Gb1YghRiu+U2tOwbseHSs5nyVcIuD1Vk29eEpw==',
+            'js/privatebin.js'       => 'sha512-SGS607lvVI6vT0mEp1b+n7ljPbS7OS4hBvSfvKdXsR+Bd9alKim8QyYQCndqSgxkNa65dO7OeX0S+qjn/CiZSg==',
             'js/purify-3.4.17.js'    => 'sha512-udF2zPdEyVCAxIGeBbeSGFK1Fj5c4eHGi6NqOSsr8M75lGs39POzgUNxO3SFxOg4cHfAoZJgR2EaXdmDi6v6AQ==',
             'js/purify-3.4.17.js'    => 'sha512-udF2zPdEyVCAxIGeBbeSGFK1Fj5c4eHGi6NqOSsr8M75lGs39POzgUNxO3SFxOg4cHfAoZJgR2EaXdmDi6v6AQ==',
             'js/showdown-2.1.0.js'   => 'sha512-WYXZgkTR0u/Y9SVIA4nTTOih0kXMEd8RRV6MLFdL6YU8ymhR528NLlYQt1nlJQbYz4EW+ZsS0fx1awhiQJme1Q==',
             'js/showdown-2.1.0.js'   => 'sha512-WYXZgkTR0u/Y9SVIA4nTTOih0kXMEd8RRV6MLFdL6YU8ymhR528NLlYQt1nlJQbYz4EW+ZsS0fx1awhiQJme1Q==',
             'js/zlib-1.3.2.js'       => 'sha512-RAhJgxg9siMIA8ky4c10Rc2zUgnK80olHB8Tt1IOYWY4Eh1WmrviQkDn+sgBlb38ZHq3tzufGC41kP360gmosQ==',
             'js/zlib-1.3.2.js'       => 'sha512-RAhJgxg9siMIA8ky4c10Rc2zUgnK80olHB8Tt1IOYWY4Eh1WmrviQkDn+sgBlb38ZHq3tzufGC41kP360gmosQ==',