Просмотр исходного кода

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 1 неделя назад
Родитель
Сommit
ebb33eb0bd
10 измененных файлов с 345 добавлено и 151 удалено
  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)
 * 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: 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)
 * 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
 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.
 // The reload is necessary because modules close over DOM element references
 // in their init() methods, and pending async callbacks (e.g. setTimeout in
@@ -16,6 +24,7 @@ global.cleanup = function (...args) {
         currentCleanup();
     }
     currentCleanup = global.jsdom(...args);
+    loadBootstrap();
     delete require.cache[require.resolve('./privatebin')];
     delete require.cache[require.resolve('./legacy')];
     require('./privatebin');
@@ -34,6 +43,7 @@ global.showdown = require('./showdown-2.1.0');
 global.DOMPurify = require('./purify-3.4.17');
 global.baseX = require('./base-x-5.0.1').baseX;
 global.Legacy = require('./legacy').Legacy;
+loadBootstrap();
 require('./privatebin');
 global.PrivateBin = window.PrivateBin;
 

+ 63 - 69
js/privatebin.js

@@ -638,16 +638,6 @@ window.PrivateBin = (function () {
             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
          * to the given bootstrap icon
@@ -1633,6 +1623,46 @@ window.PrivateBin = (function () {
             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
@@ -1719,14 +1749,17 @@ window.PrivateBin = (function () {
             let translationTarget = element;
 
             // handle icon, if template uses one
-            const iconElement = element.querySelector(':first-child');
+            const iconElement = element.querySelector('svg');
             if (iconElement) {
                 // 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');
-                element.innerHTML = ' ';
-                element.prepend(iconElement);
-                element.appendChild(translationTarget);
+                container.innerHTML = ' ';
+                container.prepend(iconElement);
+                container.appendChild(translationTarget);
 
                 if (typeof icon === 'string' && icon.length > 0) {
                     Helper.setIcon(iconElement, icon);
@@ -2150,7 +2183,6 @@ window.PrivateBin = (function () {
 
         let passwordDecrypt,
             passwordModal,
-            bootstrap5PasswordModal = null,
             password = '';
 
         /**
@@ -2167,15 +2199,7 @@ window.PrivateBin = (function () {
             // get input
             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();
         }
@@ -2201,12 +2225,7 @@ window.PrivateBin = (function () {
                 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
          */
         me.requestPassword = function () {
-            // show new bootstrap method (if available)
             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;
             }
 
@@ -2274,19 +2294,7 @@ window.PrivateBin = (function () {
                     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', () => {
                     passwordDecrypt.focus();
                 });
@@ -4158,8 +4166,6 @@ window.PrivateBin = (function () {
                 const emailconfirmTimezoneCurrent = emailconfirmmodal.querySelector('#emailconfirm-timezone-current');
                 const emailconfirmTimezoneUtc = emailconfirmmodal.querySelector('#emailconfirm-timezone-utc');
                 let localeConfiguration = { dateStyle: 'long', timeStyle: 'long' };
-                const bootstrap5EmailConfirmModal = typeof bootstrap !== 'undefined' && bootstrap.Tooltip && bootstrap.Tooltip.VERSION ?
-                    new bootstrap.Modal(emailconfirmmodal) : null;
 
                 function sendEmailAndHideModal() {
                     const emailBody = templateEmailBody(
@@ -4168,9 +4174,7 @@ window.PrivateBin = (function () {
                             [], localeConfiguration
                         ), isBurnafterreading
                     );
-                    if (bootstrap5EmailConfirmModal) {
-                        bootstrap5EmailConfirmModal.hide();
-                    }
+                    UiHelper.hideModal(emailconfirmmodal);
                     triggerEmailSend(emailSubject, emailBody);
                 }
 
@@ -4185,9 +4189,7 @@ window.PrivateBin = (function () {
                     localeConfiguration.timeZone = 'UTC';
                     sendEmailAndHideModal();
                 });
-                if (bootstrap5EmailConfirmModal) {
-                    bootstrap5EmailConfirmModal.show();
-                }
+                UiHelper.showModal(emailconfirmmodal);
             } else {
                 triggerEmailSend(emailSubject, templateEmailBody(null, isBurnafterreading));
             }
@@ -4325,11 +4327,9 @@ window.PrivateBin = (function () {
          */
         me.showRetryButton = function () {
             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 () {
             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
             const attachDropdownToggle = attach && attach.querySelector('.dropdown-toggle');
             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');
             setTimeout(function () {

+ 27 - 4
js/test/Alert.js

@@ -41,8 +41,8 @@ describe('Alert', function () {
                     message = message.join('');
                     const expected = '<div id="status" role="alert" ' +
                         '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 =
                         '<div id="status" role="alert" class="d-flex ' +
                         'align-items-center gap-2 alert alert-info hidden">' +
@@ -64,8 +64,8 @@ describe('Alert', function () {
                     message = message.join('');
                     const expected = '<div id="status" role="alert" ' +
                         '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 =
                         '<div id="status" role="alert" class="d-flex ' +
                         '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 () {

+ 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 () {
         afterEach(function () {
             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 = [];
                 document.documentElement.innerHTML =
                     '<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>' +
                     '</li><li id="customattachment" class="hidden"></li><li>' +
                     '<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 () {
         before(function () {
             cleanup();
@@ -446,10 +492,10 @@ describe('TopNav', function () {
                 let results = [];
                 document.documentElement.innerHTML =
                     '<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>' +
                     '</li><li id="customattachment" class="hidden"></li><li>' +
                     '<a id="fileremovebutton"  href="#">Remove attachment</a>' +
@@ -507,6 +553,7 @@ describe('TopNav', function () {
                 });
                 PrivateBin.TopNav.collapseBar();
                 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();
                 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 = [];
                 document.documentElement.innerHTML =
                     '<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>' +
                     '</li><li id="customattachment" class="hidden"></li><li>' +
                     '<a id="fileremovebutton"  href="#">Remove attachment</a>' +
@@ -981,10 +1029,10 @@ describe('TopNav', function () {
                 let results = [];
                 document.documentElement.innerHTML =
                     '<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>' +
                     '</li><li id="customattachment" class="hidden"></li><li>' +
                     '<a id="fileremovebutton"  href="#">Remove attachment</a>' +
@@ -1048,15 +1096,13 @@ describe('TopNav', function () {
 
     describe('showRetryButton & hideRetryButton', function () {
         afterEach(function () {
-            delete global.bootstrap;
             cleanup();
         });
 
         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 () {
                 cleanup();
-                global.bootstrap = {};
                 document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul class="navbar-nav">' +
                     '<li class="nav-item hidden"><button id="retrybutton" ' +
@@ -1092,46 +1138,6 @@ describe('TopNav', function () {
                 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 () {

+ 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>' +
         '<input id="burnafterreadingoption" type="checkbox">' +
         // 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>'
 }
 
@@ -44,9 +46,11 @@ function buildEmailDomWithShortUrl() {
           '<span id="copyhint">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>' +
         '</div>' +
         // 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>'
 }
 
@@ -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/legacy.js'           => 'sha512-pRofxsrf5UItjiP22Dcjh3FAcBjF/n7h8U9/W5xqJk17U0N2U1oajhXypq/omo9jhwS1iVGOhWrRepoPeFns+w==',
             '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/showdown-2.1.0.js'   => 'sha512-WYXZgkTR0u/Y9SVIA4nTTOih0kXMEd8RRV6MLFdL6YU8ymhR528NLlYQt1nlJQbYz4EW+ZsS0fx1awhiQJme1Q==',
             'js/zlib-1.3.2.js'       => 'sha512-RAhJgxg9siMIA8ky4c10Rc2zUgnK80olHB8Tt1IOYWY4Eh1WmrviQkDn+sgBlb38ZHq3tzufGC41kP360gmosQ==',