Sfoglia il codice sorgente

Update to boostrap5

- Remove unused form classes
- Add `form-label` class
- Set custom bootstrap prefix (to `converse-`)
- Re-style the minimized chats toggle
- Remove old CSS and use Boostrap markup.
- Re-style the controlbox toggle
- Use bootstrap grid classes for chat boxes and controlbox toggle.
- Remove unused css from core
- Use bootstrap for dropdown/modal/popover instead of bootstrap.native
- Namespace bootstrap files
- Fix closing of confirm alert
JC Brand 1 anno fa
parent
commit
80555d8a38
100 ha cambiato i file con 515 aggiunte e 665 eliminazioni
  1. 3 1
      CHANGES.md
  2. 22 94
      package-lock.json
  3. 2 3
      package.json
  4. 1 2
      src/index.js
  5. 1 1
      src/plugins/adhoc-views/templates/ad-hoc-command-form.js
  6. 4 4
      src/plugins/adhoc-views/templates/ad-hoc.js
  7. 5 5
      src/plugins/bookmark-views/components/templates/form.js
  8. 1 1
      src/plugins/chatboxviews/styles/chats.scss
  9. 6 6
      src/plugins/chatboxviews/templates/chats.js
  10. 1 1
      src/plugins/chatview/bottom-panel.js
  11. 1 1
      src/plugins/chatview/message-form.js
  12. 0 9
      src/plugins/chatview/styles/index.scss
  13. 1 1
      src/plugins/chatview/templates/chat-head.js
  14. 1 1
      src/plugins/chatview/templates/chat.js
  15. 18 15
      src/plugins/controlbox/loginform.js
  16. 12 8
      src/plugins/controlbox/styles/_controlbox.scss
  17. 1 1
      src/plugins/controlbox/templates/controlbox.js
  18. 12 15
      src/plugins/controlbox/templates/loginform.js
  19. 12 4
      src/plugins/controlbox/templates/toggle.js
  20. 1 5
      src/plugins/controlbox/toggle.js
  21. 1 1
      src/plugins/headlines-view/templates/chat-head.js
  22. 1 1
      src/plugins/headlines-view/templates/headlines.js
  23. 1 1
      src/plugins/minimize/components/minimized-chat.js
  24. 2 47
      src/plugins/minimize/styles/minimize.scss
  25. 0 18
      src/plugins/minimize/templates/chats-panel.js
  26. 27 0
      src/plugins/minimize/templates/toggle.js
  27. 1 1
      src/plugins/minimize/templates/trimmed_chat.js
  28. 4 6
      src/plugins/minimize/tests/minchats.js
  29. 5 8
      src/plugins/minimize/view.js
  30. 2 2
      src/plugins/modal/confirm.js
  31. 45 11
      src/plugins/modal/modal.js
  32. 1 2
      src/plugins/modal/styles/_modal.scss
  33. 2 2
      src/plugins/modal/templates/buttons.js
  34. 7 4
      src/plugins/modal/templates/modal.js
  35. 8 5
      src/plugins/modal/templates/prompt.js
  36. 3 3
      src/plugins/muc-views/modals/nickname.js
  37. 6 6
      src/plugins/muc-views/modals/templates/add-muc.js
  38. 2 2
      src/plugins/muc-views/modals/templates/muc-config.js
  39. 10 8
      src/plugins/muc-views/modals/templates/muc-invite.js
  40. 9 7
      src/plugins/muc-views/modals/templates/occupant.js
  41. 3 3
      src/plugins/muc-views/nickname-form.js
  42. 10 3
      src/plugins/muc-views/styles/muc-bottom-panel.scss
  43. 0 29
      src/plugins/muc-views/styles/muc-bottompanel.scss
  44. 0 1
      src/plugins/muc-views/styles/muc-forms.scss
  45. 1 1
      src/plugins/muc-views/styles/muc-occupants.scss
  46. 5 5
      src/plugins/muc-views/templates/affiliation-form.js
  47. 6 6
      src/plugins/muc-views/templates/moderator-tools.js
  48. 1 1
      src/plugins/muc-views/templates/muc-head.js
  49. 2 2
      src/plugins/muc-views/templates/muc-list.js
  50. 3 3
      src/plugins/muc-views/templates/muc-nickname-form.js
  51. 3 3
      src/plugins/muc-views/templates/muc-password-form.js
  52. 3 1
      src/plugins/muc-views/templates/muc-sidebar.js
  53. 2 2
      src/plugins/muc-views/templates/muc.js
  54. 1 1
      src/plugins/muc-views/templates/occupant.js
  55. 1 1
      src/plugins/muc-views/templates/occupants-filter.js
  56. 5 5
      src/plugins/muc-views/templates/role-form.js
  57. 1 1
      src/plugins/muc-views/tests/muc.js
  58. 8 8
      src/plugins/omemo/templates/profile.js
  59. 3 3
      src/plugins/profile/modals/chat-status.js
  60. 2 0
      src/plugins/profile/modals/profile.js
  61. 27 28
      src/plugins/profile/modals/styles/profile.scss
  62. 2 2
      src/plugins/profile/templates/chat-status-modal.js
  63. 6 5
      src/plugins/profile/templates/password-reset.js
  64. 9 9
      src/plugins/profile/templates/profile_modal.js
  65. 2 2
      src/plugins/register/templates/register_panel.js
  66. 1 1
      src/plugins/roomslist/templates/roomslist.js
  67. 1 0
      src/plugins/rootview/root.js
  68. 1 1
      src/plugins/rootview/templates/root.js
  69. 4 4
      src/plugins/rosterview/modals/templates/add-contact.js
  70. 3 1
      src/plugins/rosterview/templates/roster.js
  71. 1 1
      src/plugins/rosterview/templates/roster_filter.js
  72. 1 1
      src/plugins/rosterview/tests/roster.js
  73. 0 1
      src/plugins/singleton/singleton.scss
  74. 32 39
      src/shared/chat/emoji-dropdown.js
  75. 2 2
      src/shared/chat/emoji-picker.js
  76. 2 2
      src/shared/chat/message-actions.js
  77. 4 2
      src/shared/chat/styles/emoji.scss
  78. 3 1
      src/shared/chat/utils.js
  79. 16 21
      src/shared/components/dropdown.js
  80. 2 51
      src/shared/components/dropdownbase.js
  81. 12 9
      src/shared/components/styles/dropdown.scss
  82. 0 3
      src/shared/modals/user-details.js
  83. 0 32
      src/shared/styles/_core.scss
  84. 2 2
      src/shared/styles/_variables.scss
  85. 0 3
      src/shared/styles/buttons.scss
  86. 6 19
      src/shared/styles/forms.scss
  87. 34 6
      src/shared/styles/index.scss
  88. 1 0
      src/shared/styles/lists.scss
  89. 0 1
      src/shared/styles/themes/classic.scss
  90. 23 6
      src/shared/styles/themes/dracula.scss
  91. 0 3
      src/shared/styles/website.scss
  92. 2 2
      src/templates/form_captcha.js
  93. 1 1
      src/templates/form_checkbox.js
  94. 2 2
      src/templates/form_date.js
  95. 2 2
      src/templates/form_input.js
  96. 2 2
      src/templates/form_select.js
  97. 2 2
      src/templates/form_textarea.js
  98. 2 2
      src/templates/form_url.js
  99. 2 2
      src/templates/form_username.js
  100. 1 0
      src/types/plugins/bookmark-views/modals/bookmark-form.d.ts

+ 3 - 1
CHANGES.md

@@ -23,11 +23,13 @@
 - Fix: unhandled exception on new message arriving when user has not permitted playing audio in the browser
 - Fix: incorrect unread messages counter badge on the application icon after switching to new XMPP user
 - Fix: unhandled exception in disconnect function when controlbox is not shown by UI
+- Fix: "Click to mention..." title was misplaced in MUC occupant list.
 - Add an occupants filter to the MUC sidebar
 - Change contacts filter to rename the anachronistic `Online` state to `Available`.
 - Enable [reuse_scram_keys](https://conversejs.org/docs/html/configuration.html#reuse-scram-keys) by default.
 - New loadEmojis hook, to customize emojis at runtime.
-- Fix: "Click to mention..." title was misplaced in MUC occupant list.
+- New `loadEmojis` hook, to customize emojis at runtime.
+- Upgrade to Bootstrap 5
 
 ### Breaking changes:
 

+ 22 - 94
package-lock.json

@@ -12,8 +12,7 @@
         "src/headless"
       ],
       "dependencies": {
-        "bootstrap": "^4.6.0",
-        "bootstrap.native": "^2.0.27",
+        "bootstrap": "^5.3.3",
         "client-compress": "^2.2.2",
         "dayjs": "^1.11.8",
         "dompurify": "^3.0.8",
@@ -35,12 +34,12 @@
         "@babel/core": "^7.18.5",
         "@babel/preset-env": "^7.18.2",
         "@converse/headless": "file:src/headless",
+        "@types/bootstrap": "^5.2.10",
         "@types/webappsec-credential-management": "^0.6.8",
         "@typescript-eslint/eslint-plugin": "^7.12.0",
         "@typescript-eslint/parser": "^7.12.0",
         "autoprefixer": "^10.4.5",
         "babel-loader": "^9.1.0",
-        "bootstrap.native-loader": "2.0.0",
         "circular-dependency-plugin": "^5.2.2",
         "clean-css-cli": "^5.6.2",
         "copy-webpack-plugin": "^12.0.2",
@@ -2276,14 +2275,13 @@
         "node": ">= 8"
       }
     },
-    "node_modules/@pkgjs/parseargs": {
-      "version": "0.11.0",
-      "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
-      "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
-      "dev": true,
-      "optional": true,
-      "engines": {
-        "node": ">=14"
+    "node_modules/@popperjs/core": {
+      "version": "2.11.8",
+      "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
+      "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/popperjs"
       }
     },
     "node_modules/@sindresorhus/merge-streams": {
@@ -2323,6 +2321,15 @@
         "@types/node": "*"
       }
     },
+    "node_modules/@types/bootstrap": {
+      "version": "5.2.10",
+      "resolved": "https://registry.npmjs.org/@types/bootstrap/-/bootstrap-5.2.10.tgz",
+      "integrity": "sha512-F2X+cd6551tep0MvVZ6nM8v7XgGN/twpdNDjqS1TUM7YFNEtQYWk+dKAnH+T1gr6QgCoGMPl487xw/9hXooa2g==",
+      "dev": true,
+      "dependencies": {
+        "@popperjs/core": "^2.9.2"
+      }
+    },
     "node_modules/@types/clean-css": {
       "version": "4.2.11",
       "resolved": "https://registry.npmjs.org/@types/clean-css/-/clean-css-4.2.11.tgz",
@@ -3331,15 +3338,6 @@
       "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==",
       "dev": true
     },
-    "node_modules/big.js": {
-      "version": "5.2.2",
-      "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
-      "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==",
-      "dev": true,
-      "engines": {
-        "node": "*"
-      }
-    },
     "node_modules/binary-extensions": {
       "version": "2.3.0",
       "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -3414,9 +3412,9 @@
       "dev": true
     },
     "node_modules/bootstrap": {
-      "version": "4.6.2",
-      "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.6.2.tgz",
-      "integrity": "sha512-51Bbp/Uxr9aTuy6ca/8FbFloBUJZLHwnhTcnjIeRn2suQWsWzcuJhGjKDB5eppVte/8oCdOL3VuwxvZDUggwGQ==",
+      "version": "5.3.3",
+      "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
+      "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==",
       "funding": [
         {
           "type": "github",
@@ -3428,25 +3426,7 @@
         }
       ],
       "peerDependencies": {
-        "jquery": "1.9.1 - 3",
-        "popper.js": "^1.16.1"
-      }
-    },
-    "node_modules/bootstrap.native": {
-      "version": "2.0.27",
-      "resolved": "https://registry.npmjs.org/bootstrap.native/-/bootstrap.native-2.0.27.tgz",
-      "integrity": "sha512-gv2eN4zXHOLN/oPotTxb8CJr9Dk0xlM9YURyHCWjq1Lyt2I669bri/Bp8b0HPOKX7JqRVh+Sk/VwEe0OcQN2fw=="
-    },
-    "node_modules/bootstrap.native-loader": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/bootstrap.native-loader/-/bootstrap.native-loader-2.0.0.tgz",
-      "integrity": "sha512-Olau+W5+mPotQJ5BCfIiP/SWQd8/zXqnTwhrTiyViciMvqBI+RgmtJD5aHOKhdBnHz5H1korn7Pf2s4dhAZ1RA==",
-      "dev": true,
-      "dependencies": {
-        "loader-utils": "^1.2.3"
-      },
-      "peerDependencies": {
-        "bootstrap.native": "^2.0.26"
+        "@popperjs/core": "^2.11.8"
       }
     },
     "node_modules/brace-expansion": {
@@ -4488,15 +4468,6 @@
       "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
       "dev": true
     },
-    "node_modules/emojis-list": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz",
-      "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==",
-      "dev": true,
-      "engines": {
-        "node": ">= 4"
-      }
-    },
     "node_modules/encodeurl": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
@@ -6655,12 +6626,6 @@
         "jiti": "bin/jiti.js"
       }
     },
-    "node_modules/jquery": {
-      "version": "3.7.1",
-      "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
-      "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==",
-      "peer": true
-    },
     "node_modules/js-binary-schema-parser": {
       "version": "2.0.3",
       "resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz",
@@ -7092,32 +7057,6 @@
         "node": ">=6.11.5"
       }
     },
-    "node_modules/loader-utils": {
-      "version": "1.4.2",
-      "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
-      "integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
-      "dev": true,
-      "dependencies": {
-        "big.js": "^5.2.2",
-        "emojis-list": "^3.0.0",
-        "json5": "^1.0.1"
-      },
-      "engines": {
-        "node": ">=4.0.0"
-      }
-    },
-    "node_modules/loader-utils/node_modules/json5": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
-      "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
-      "dev": true,
-      "dependencies": {
-        "minimist": "^1.2.0"
-      },
-      "bin": {
-        "json5": "lib/cli.js"
-      }
-    },
     "node_modules/localforage": {
       "version": "1.10.0",
       "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz",
@@ -8155,17 +8094,6 @@
         "gettext-to-messageformat": "0.3.1"
       }
     },
-    "node_modules/popper.js": {
-      "version": "1.16.1",
-      "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
-      "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==",
-      "deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1",
-      "peer": true,
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/popperjs"
-      }
-    },
     "node_modules/portfinder": {
       "version": "1.0.32",
       "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.32.tgz",

+ 2 - 3
package.json

@@ -76,12 +76,12 @@
     "@babel/core": "^7.18.5",
     "@babel/preset-env": "^7.18.2",
     "@converse/headless": "file:src/headless",
+    "@types/bootstrap": "^5.2.10",
     "@types/webappsec-credential-management": "^0.6.8",
     "@typescript-eslint/eslint-plugin": "^7.12.0",
     "@typescript-eslint/parser": "^7.12.0",
     "autoprefixer": "^10.4.5",
     "babel-loader": "^9.1.0",
-    "bootstrap.native-loader": "2.0.0",
     "circular-dependency-plugin": "^5.2.2",
     "clean-css-cli": "^5.6.2",
     "copy-webpack-plugin": "^12.0.2",
@@ -117,8 +117,7 @@
     "webpack-merge": "^5.10.0"
   },
   "dependencies": {
-    "bootstrap": "^4.6.0",
-    "bootstrap.native": "^2.0.27",
+    "bootstrap": "^5.3.3",
     "client-compress": "^2.2.2",
     "dayjs": "^1.11.8",
     "dompurify": "^3.0.8",

+ 1 - 2
src/index.js

@@ -3,6 +3,7 @@
  * @copyright 2021, The Converse developers
  * @license Mozilla Public License (MPLv2)
  */
+import 'shared/styles/index.scss';
 
 import "./i18n/index.js";
 import "shared/registry.js";
@@ -10,8 +11,6 @@ import { CustomElement } from 'shared/components/element';
 import { VIEW_PLUGINS } from './shared/constants.js';
 import { _converse, converse } from "@converse/headless";
 
-import 'shared/styles/index.scss';
-
 /* START: Removable plugins
  * ------------------------
  * Any of the following plugin imports may be removed if the plugin is not needed

+ 1 - 1
src/plugins/adhoc-views/templates/ad-hoc-command-form.js

@@ -65,7 +65,7 @@ export default (el, command) => {
 
                 ${command.type === 'form' && command.title ? html`<h6>${command.title}</h6>` : ''}
 
-                <fieldset class="form-group">
+                <fieldset>
                     <input type="hidden" name="command_node" value="${command.node}" />
                     <input type="hidden" name="command_jid" value="${command.jid}" />
                     ${command.instructions ? html`<p class="form-instructions">${command.instructions}</p>` : ''}

+ 4 - 4
src/plugins/adhoc-views/templates/ad-hoc.js

@@ -24,8 +24,8 @@ export default (el) => {
         ${el.note ? html`<p class="form-help">${el.note}</p>` : ''}
 
         <form class="converse-form" @submit=${el.fetchCommands}>
-            <fieldset class="form-group">
-                <label>
+            <fieldset>
+                <label class="form-label">
                     ${i18n_choose_service}
                     <p class="form-help">${i18n_choose_service_instructions}</p>
                     <converse-autocomplete
@@ -37,13 +37,13 @@ export default (el) => {
                     </converse-autocomplete>
                 </label>
             </fieldset>
-            <fieldset class="form-group">
+            <fieldset>
                 ${el.fetching
                     ? tplSpinner()
                     : html`<input type="submit" class="btn btn-primary" value="${i18n_fetch_commands}" />`}
             </fieldset>
             ${el.view === 'list-commands'
-                ? html` <fieldset class="form-group">
+                ? html` <fieldset>
                       <ul class="list-group">
                           <li class="list-group-item active">
                               ${el.commands.length ? i18n_commands_found : i18n_no_commands_found}:

+ 5 - 5
src/plugins/bookmark-views/components/templates/form.js

@@ -16,19 +16,19 @@ export default (el) => {
     return html`
         <form class="converse-form chatroom-form" @submit=${(ev) => el.onBookmarkFormSubmitted(ev)}>
             <legend>${i18n_heading}</legend>
-            <fieldset class="form-group">
-                <label for="converse_muc_bookmark_name">${i18n_name}</label>
+            <fieldset>
+                <label class="form-label" for="converse_muc_bookmark_name">${i18n_name}</label>
                 <input class="form-control" type="text" value="${name}" name="name" required="required" id="converse_muc_bookmark_name"/>
             </fieldset>
-            <fieldset class="form-group">
-                <label for="converse_muc_bookmark_nick">${i18n_nick}</label>
+            <fieldset>
+                <label class="form-label" for="converse_muc_bookmark_nick">${i18n_nick}</label>
                 <input class="form-control" type="text" name="nick" value="${nick || ''}" id="converse_muc_bookmark_nick"/>
             </fieldset>
             <fieldset class="form-group form-check">
                 <input class="form-check-input" id="converse_muc_bookmark_autojoin" type="checkbox" ?checked=${el.bookmark?.get('autojoin')} name="autojoin"/>
                 <label class="form-check-label" for="converse_muc_bookmark_autojoin">${i18n_autojoin}</label>
             </fieldset>
-            <fieldset class="form-group">
+            <fieldset>
                 <input class="btn btn-primary" type="submit" value="${i18n_submit}">
                     ${el.bookmark ? html`<input class="btn btn-secondary button-remove" type="button" value="${i18n_remove}" @click=${(ev) => el.removeBookmark(ev)}>` : '' }
             </fieldset>

+ 1 - 1
src/plugins/chatboxviews/styles/chats.scss

@@ -5,9 +5,9 @@
             position: fixed;
             bottom: 0;
             right: 0;
+            left: 1em;
         }
         &.converse-overlayed {
-            height: 3em;
             > .row {
                 flex-direction: row-reverse;
             }

+ 6 - 6
src/plugins/chatboxviews/templates/chats.js

@@ -15,7 +15,7 @@ export default () => {
     const connection = api.connection.get();
     const logged_out = !connection?.connected || !connection?.authenticated || connection?.disconnecting;
     return html`
-        ${!logged_out && view_mode === 'overlayed' ? html`<converse-minimized-chats></converse-minimized-chats>` : ''}
+        ${!logged_out && view_mode === 'overlayed' ? html`<converse-minimized-chats class="col-auto"></converse-minimized-chats>` : ''}
         ${repeat(
             chatboxes.filter(shouldShowChat),
             (m) => m.get('jid'),
@@ -24,25 +24,25 @@ export default () => {
                     return html`
                         ${view_mode === 'overlayed'
                             ? html`<converse-controlbox-toggle
-                                  class="${!m.get('closed') ? 'hidden' : ''}"
+                                  class="${!m.get('closed') ? 'hidden' : 'col-auto'}"
                               ></converse-controlbox-toggle>`
                             : ''}
                         <converse-controlbox
                             id="controlbox"
-                            class="chatbox ${view_mode === 'overlayed' && m.get('closed') ? 'hidden' : ''} ${logged_out
+                            class="col-auto chatbox ${view_mode === 'overlayed' && m.get('closed') ? 'hidden' : ''} ${logged_out
                                 ? 'logged-out'
                                 : ''}"
                             style="${m.get('width') ? `width: ${m.get('width')}` : ''}"
                         ></converse-controlbox>
                     `;
                 } else if (m.get('type') === CHATROOMS_TYPE) {
-                    return html` <converse-muc jid="${m.get('jid')}" class="chatbox chatroom"></converse-muc> `;
+                    return html` <converse-muc jid="${m.get('jid')}" class="col-auto chatbox chatroom"></converse-muc> `;
                 } else if (m.get('type') === HEADLINES_TYPE) {
                     return html`
-                        <converse-headlines jid="${m.get('jid')}" class="chatbox headlines"></converse-headlines>
+                        <converse-headlines jid="${m.get('jid')}" class="col-auto chatbox headlines"></converse-headlines>
                     `;
                 } else {
-                    return html` <converse-chat jid="${m.get('jid')}" class="chatbox"></converse-chat> `;
+                    return html` <converse-chat jid="${m.get('jid')}" class="col-auto chatbox"></converse-chat> `;
                 }
             }
         )}

+ 1 - 1
src/plugins/chatview/bottom-panel.js

@@ -82,7 +82,7 @@ export default class ChatBottomPanel extends CustomElement {
                 'query': value
             });
             const emoji_dropdown = /** @type {EmojiDropdown} */(this.querySelector('converse-emoji-dropdown'));
-            emoji_dropdown?.showMenu();
+            emoji_dropdown?.dropdown.show();
         }
     }
 }

+ 1 - 1
src/plugins/chatview/message-form.js

@@ -213,7 +213,7 @@ export default class MessageForm extends CustomElement {
         }
         u.addClass('disabled', textarea);
         textarea.setAttribute('disabled', 'disabled');
-        /** @type {EmojiDropdown} */(this.querySelector('converse-emoji-dropdown'))?.hideMenu();
+        /** @type {EmojiDropdown} */(this.querySelector('converse-emoji-dropdown'))?.dropdown.hide();
 
         const is_command = await parseMessageForCommands(this.model, message_text);
         const message = is_command ? null : await this.model.sendMessage({'body': message_text, spoiler_hint});

+ 0 - 9
src/plugins/chatview/styles/index.scss

@@ -2,7 +2,6 @@
 @import "bootstrap/scss/variables";
 @import "bootstrap/scss/mixins";
 @import "shared/styles/_variables.scss";
-@import "bootstrap/scss/media";
 @import "./chatbox.scss";
 
 
@@ -77,14 +76,10 @@
     .conversejs.converse-overlayed {
         > .row {
             flex-direction: column;
-            &.no-gutters {
-                margin: -1em;
-            }
         }
     }
 }
 
-
 .conversejs {
     converse-chats.converse-embedded,
     converse-chats.converse-fullscreen  {
@@ -95,12 +90,9 @@
         }
 
         .chatbox {
-            margin: 0;
-            margin-left: 15px;
             .box-flyout {
                 box-shadow: none;
                 overflow: hidden;
-                margin-left: 0;
             }
         }
     }
@@ -161,7 +153,6 @@
     converse-chats.converse-fullscreen  {
         .chatbox-btn {
             font-size: var(--fullpage-chatbox-button-size);
-            margin: 0 0.3em;
         }
         .chat-head {
             font-size: var(--font-size-huge);

+ 1 - 1
src/plugins/chatview/templates/chat-head.js

@@ -26,7 +26,7 @@ export default (o) => {
                     ${ (o.type !== HEADLINES_TYPE) ? html`<a class="user show-msg-author-modal" @click=${o.showUserDetailsModal}>${ display_name }</a>` : display_name }
                 </div>
             </div>
-            <div class="chatbox-title__buttons row no-gutters">
+            <div class="chatbox-title__buttons row g-0">
                 ${ until(getDropdownButtons(o.heading_buttons_promise), '') }
                 ${ until(getStandaloneButtons(o.heading_buttons_promise), '') }
             </div>

+ 1 - 1
src/plugins/chatview/templates/chat.js

@@ -7,7 +7,7 @@ export default (o) => html`
     <div class="flyout box-flyout">
         <converse-dragresize></converse-dragresize>
         ${ o.model ? html`
-            <converse-chat-heading jid="${o.jid}" class="chat-head chat-head-chatbox row no-gutters"></converse-chat-heading>
+            <converse-chat-heading jid="${o.jid}" class="chat-head chat-head-chatbox row g-0"></converse-chat-heading>
             <div class="chat-body">
                 <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
                     <converse-chat-content

+ 18 - 15
src/plugins/controlbox/loginform.js

@@ -1,8 +1,8 @@
-import bootstrap from 'bootstrap.native';
+import { Popover } from 'bootstrap';
 import { _converse, api, converse, constants } from '@converse/headless';
-import tplLoginPanel from './templates/loginform.js';
 import { CustomElement } from 'shared/components/element.js';
 import { updateSettingsWithFormData, validateJID } from './utils.js';
+import tplLoginPanel from './templates/loginform.js';
 
 const { Strophe } = converse.env;
 const { ANONYMOUS } = constants;
@@ -33,6 +33,9 @@ class LoginForm extends CustomElement {
         this.initPopovers();
     }
 
+    /**
+     * @param {SubmitEvent} ev
+     */
     async onLoginFormSubmitted (ev) {
         ev?.preventDefault();
 
@@ -41,16 +44,17 @@ class LoginForm extends CustomElement {
             return this.connect(jid);
         }
 
-        if (!validateJID(ev.target)) {
+        const form = /** @type {HTMLFormElement} */(ev.target);
+        if (!validateJID(form)) {
             return;
         }
-        updateSettingsWithFormData(ev.target);
+        updateSettingsWithFormData(form);
 
         if (!api.settings.get('bosh_service_url') && !api.settings.get('websocket_url')) {
             // We don't have a connection URL available, so we try here to discover
             // XEP-0156 connection methods now, and if not found we present the user
             // with the option to enter their own connection URL
-            await this.discoverConnectionMethods(ev);
+            await this.discoverConnectionMethods(form);
         }
 
         if (api.settings.get('bosh_service_url') || api.settings.get('websocket_url')) {
@@ -61,12 +65,14 @@ class LoginForm extends CustomElement {
         }
     }
 
-    // eslint-disable-next-line class-methods-use-this
-    discoverConnectionMethods (ev) {
+    /**
+     * @param {HTMLFormElement} form
+     */
+    discoverConnectionMethods (form) {
         if (!api.settings.get("discover_connection_methods")) {
             return;
         }
-        const form_data = new FormData(ev.target);
+        const form_data = new FormData(form);
         const jid = form_data.get('jid');
         if (jid instanceof File) throw new Error('Found file instead of string for "jid" field in form');
 
@@ -76,15 +82,12 @@ class LoginForm extends CustomElement {
     }
 
     initPopovers () {
-        Array.from(this.querySelectorAll('[data-title]')).forEach(el => {
-            new bootstrap.Popover(el, {
-                'trigger': (api.settings.get('view_mode') === 'mobile' && 'click') || 'hover',
-                'dismissible': (api.settings.get('view_mode') === 'mobile' && true) || false,
-                'container': this.parentElement.parentElement.parentElement,
-            });
-        });
+        Array.from(this.querySelectorAll('[data-toggle="popover"]')).forEach(el => new Popover(el));
     }
 
+    /**
+     * @param {string} [jid]
+     */
     connect (jid) {
         if (['converse/login', 'converse/register'].includes(location.hash)) {
             history.pushState(null, '', window.location.pathname);

+ 12 - 8
src/plugins/controlbox/styles/_controlbox.scss

@@ -5,6 +5,7 @@
 @import "shared/styles/_mixins.scss";
 
 .conversejs {
+
     .set-xmpp-status,
     .xmpp-status {
         .chat-status--online {
@@ -327,16 +328,8 @@
                 order: -2;
                 text-align: center;
                 background-color: var(--controlbox-head-color);
-                border-top-left-radius: var(--button-border-radius);
-                border-top-right-radius: var(--button-border-radius);
-                color: #0a0a0a;
                 float: right;
-                height: 100%;
                 margin: 0 var(--chat-gutter);
-                padding: 1em;
-                span {
-                    color: var(--inverse-link-color);
-                }
             }
 
             #controlbox {
@@ -524,6 +517,17 @@
                 @include media-breakpoint-down(sm) {
                     margin-left: 0;
                 }
+                .box-flyout {
+                    @include media-breakpoint-up(md) {
+                        max-width: 33.333333%;
+                    }
+                    @include media-breakpoint-up(lg) {
+                        max-width: 25%;
+                    }
+                    @include media-breakpoint-up(xl) {
+                        max-width: 17.666667%;
+                    }
+                }
             }
             .controlbox-panes {
                 padding-top: 2em;

+ 1 - 1
src/plugins/controlbox/templates/controlbox.js

@@ -21,7 +21,7 @@ function whenNotConnected(el) {
     }
     return html`<converse-login-form
         id="converse-login-panel"
-        class="controlbox-pane fade-in row no-gutters"
+        class="controlbox-pane fade-in row g-0"
     ></converse-login-form>`;
 }
 

+ 12 - 15
src/plugins/controlbox/templates/loginform.js

@@ -16,24 +16,21 @@ const trust_checkbox = (checked) => {
     );
     const i18n_trusted = __('This is a trusted device');
     return html`
-        <div class="form-group form-check login-trusted">
+        <div class="form-group form-check login-trusted mt-2">
             <input
                 id="converse-login-trusted"
                 type="checkbox"
-                class="form-check-input"
+                class="form-check-input p-1 me-1"
                 name="trusted"
                 ?checked=${checked}
             />
             <label for="converse-login-trusted" class="form-check-label login-trusted__desc">${i18n_trusted}</label>
-
-            <converse-icon
-                class="fa fa-info-circle"
+            <button type="button" class="btn p-0"
                 data-toggle="popover"
-                data-title="Trusted device?"
-                data-content="${i18n_hint_trusted}"
-                size="1.2em"
                 title="${i18n_hint_trusted}"
-            ></converse-icon>
+                data-content="${i18n_hint_trusted}">
+                <converse-icon class="fa fa-info-circle" size="1.2em"></converse-icon>
+            </button>
         </div>
     `;
 };
@@ -44,7 +41,7 @@ const connection_url_input = () => {
     const i18n_placeholder = __('e.g. wss://example.org/xmpp-websocket');
     return html`
         <div class="form-group fade-in">
-            <label for="converse-conn-url">${i18n_connection_url}</label>
+            <label for="converse-conn-url" class="form-label">${i18n_connection_url}</label>
             <p class="form-help instructions">${i18n_form_help}</p>
             <input
                 id="converse-conn-url"
@@ -60,8 +57,8 @@ const connection_url_input = () => {
 const password_input = () => {
     const i18n_password = __('Password');
     return html`
-        <div class="form-group">
-            <label for="converse-login-password">${i18n_password}</label>
+        <div>
+            <label for="converse-login-password" class="form-label">${i18n_password}</label>
             <input
                 id="converse-login-password"
                 class="form-control"
@@ -106,8 +103,8 @@ const auth_fields = (el) => {
     const show_trust_checkbox = api.settings.get('allow_user_trust_override');
 
     return html`
-        <div class="form-group">
-            <label for="converse-login-jid">${i18n_xmpp_address}:</label>
+        <fieldset class="form-group">
+            <label for="converse-login-jid" class="form-label">${i18n_xmpp_address}:</label>
             <input
                 id="converse-login-jid"
                 ?autofocus=${api.settings.get('auto_focus') ? true : false}
@@ -119,7 +116,7 @@ const auth_fields = (el) => {
                 name="jid"
                 placeholder="${placeholder_username}"
             />
-        </div>
+        </fieldset>
         ${authentication !== EXTERNAL ? password_input() : ''}
         ${api.settings.get('show_connection_url_input') ? connection_url_input() : ''}
         ${show_trust_checkbox ? trust_checkbox(show_trust_checkbox === 'off' ? false : true) : ''}

+ 12 - 4
src/plugins/controlbox/templates/toggle.js

@@ -1,8 +1,16 @@
-import { __ } from 'i18n';
-import { api } from "@converse/headless";
 import { html } from "lit";
+import { api } from "@converse/headless";
+import { __ } from 'i18n';
+import { showControlBox } from '../utils.js';
 
-export default  (o) => {
+/**
+ * @param {import('../toggle').default} el
+ */
+export default (el) => {
     const i18n_toggle = api.connection.connected() ? __('Chat Contacts') : __('Toggle chat');
-    return html`<a id="toggle-controlbox" class="toggle-controlbox ${o.hide ? 'hidden' : ''}" @click=${o.onClick}><span class="toggle-feedback">${i18n_toggle}</span></a>`;
+    return html`<button type="button"
+            class="btn toggle-controlbox ${el.model?.get('closed') ? '' : 'hidden'}"
+            @click=${(ev) => showControlBox(ev)}>
+        <span class="toggle-feedback">${i18n_toggle}</span>
+    </button>`;
 }

+ 1 - 5
src/plugins/controlbox/toggle.js

@@ -1,7 +1,6 @@
 import tplControlboxToggle from "./templates/toggle.js";
 import { CustomElement } from 'shared/components/element.js';
 import { _converse, api } from "@converse/headless";
-import { showControlBox } from './utils.js';
 
 
 class ControlBoxToggle extends CustomElement {
@@ -17,10 +16,7 @@ class ControlBoxToggle extends CustomElement {
     }
 
     render () {
-        return tplControlboxToggle({
-            'onClick': showControlBox,
-            'hide': !this.model?.get('closed')
-        });
+        return tplControlboxToggle(this);
     }
 }
 

+ 1 - 1
src/plugins/headlines-view/templates/chat-head.js

@@ -11,7 +11,7 @@ export default (o) => {
                 ${ (!_converse.api.settings.get("singleton")) ?  html`<converse-controlbox-navback jid="${o.jid}"></converse-controlbox-navback>` : '' }
                 <div class="chatbox-title__text" title="${o.jid}">${ o.display_name }</div>
             </div>
-            <div class="chatbox-title__buttons row no-gutters">
+            <div class="chatbox-title__buttons row g-0">
                 ${ until(getDropdownButtons(o.heading_buttons_promise), '') }
                 ${ until(getStandaloneButtons(o.heading_buttons_promise), '') }
             </div>

+ 1 - 1
src/plugins/headlines-view/templates/headlines.js

@@ -5,7 +5,7 @@ export default (model) => html`
     <div class="flyout box-flyout">
         <converse-dragresize></converse-dragresize>
         ${ model ? html`
-            <converse-headlines-heading jid="${model.get('jid')}" class="chat-head chat-head-chatbox row no-gutters">
+            <converse-headlines-heading jid="${model.get('jid')}" class="chat-head chat-head-chatbox row g-0">
             </converse-headlines-heading>
             <div class="chat-body">
                 <div class="chat-content" aria-live="polite">

+ 1 - 1
src/plugins/minimize/components/minimized-chat.js

@@ -1,6 +1,6 @@
+import { api } from "@converse/headless";
 import tplTrimmedChat from "../templates/trimmed_chat.js";
 import { CustomElement } from 'shared/components/element.js';
-import { api } from "@converse/headless";
 import { maximize } from  '../utils.js';
 
 

+ 2 - 47
src/plugins/minimize/styles/minimize.scss

@@ -6,34 +6,8 @@
             }
 
             #minimized-chats {
-
-                width: var(--minimized-chats-width);
-                margin-bottom: 0;
-                border-top-left-radius: var(--chatbox-border-radius);
-                border-top-right-radius: var(--chatbox-border-radius);
-                color: var(--inverse-link-color);
-                margin-right: var(--chat-gutter);
-                padding: 0;
-
-                .badge {
-                    bottom: 8px;
-                    border: 1px solid var(--overlayed-badge-color);
-                }
-
-                #toggle-minimized-chats {
-                    border-top-left-radius: var(--chatbox-border-radius);
-                    border-top-right-radius: var(--chatbox-border-radius);
-                    background-color: var(--subdued-color);
-                    padding: 1em 0 0 0;
-                    text-align: center;
-                    color: white;
-                    white-space: nowrap;
-                    overflow-y: hidden;
-                    text-overflow: ellipsis;
-                    display: block;
-                    height: 45px;
-                    width: 9em;
-                }
+                min-width: var(--minimized-chats-width);
+                color: var(--chat-head-text-color);
 
                 a.restore-chat {
                     cursor: pointer;
@@ -86,25 +60,6 @@
                         height: auto;
                     }
                 }
-
-                .unread-message-count {
-                    font-weight: bold;
-                    background-color: white;
-                    border: 1px solid;
-                    text-shadow: 1px 1px 0 var(--text-shadow-color);
-                    color: var(--warning-color);
-                    border-radius: 5px;
-                    padding: 2px 4px;
-                    font-size: 16px;
-                    text-align: center;
-                    position: absolute;
-                    right: 116px;
-                    bottom: 10px;
-                }
-                .unread-message-count-hidden,
-                .chat-head-message-count-hidden {
-                    display: none;
-                }
             }
         }
     }

+ 0 - 18
src/plugins/minimize/templates/chats-panel.js

@@ -1,18 +0,0 @@
-import { html } from "lit";
-import { __ } from 'i18n';
-
-export default (o) =>
-    html`<div id="minimized-chats" class="${o.chats.length ? '' : 'hidden'}">
-        <a id="toggle-minimized-chats" class="row no-gutters" @click=${o.toggle}>
-            ${o.num_minimized} ${__('Minimized')}
-            <span class="unread-message-count ${!o.num_unread ? 'unread-message-count-hidden' : ''}" href="#">${o.num_unread}</span>
-        </a>
-        <div class="flyout minimized-chats-flyout row no-gutters ${o.collapsed ? 'hidden' : ''}">
-            ${o.chats.map(chat =>
-                html`<converse-minimized-chat
-                        .model=${chat}
-                        title=${chat.getDisplayName()}
-                        type=${chat.get('type')}
-                        num_unread=${chat.get('num_unread')}></converse-minimized-chat>`)}
-        </div>
-    </div>`;

+ 27 - 0
src/plugins/minimize/templates/toggle.js

@@ -0,0 +1,27 @@
+import { html } from "lit";
+import { __ } from 'i18n';
+
+/**
+ * @param {import('../view').default} el
+ */
+export default (el) => {
+    const chats = el.model.where({'minimized': true});
+    const num_unread = chats.reduce((acc, chat) => (acc + chat.get('num_unread')), 0);
+    const num_minimized = chats.reduce((acc, chat) => (acc + (chat.get('minimized') ? 1 : 0)), 0);
+    const collapsed = el.minchats.get('collapsed');
+
+    return html`<div id="minimized-chats" class="${chats.length ? '' : 'hidden'}">
+        <button type="button" class="btn btn-primary" @click=${(ev) => el.toggle(ev)}>
+            ${num_minimized} ${__('Minimized')}
+            <span class="badge bg-secondary unread-message-count ${!num_unread ? 'hidden' : ''}">${num_unread}</span>
+        </button>
+        <div class="flyout minimized-chats-flyout row g-0 ${collapsed ? 'hidden' : ''}">
+            ${chats.map(chat =>
+                html`<converse-minimized-chat
+                        .model=${chat}
+                        title=${chat.getDisplayName()}
+                        type=${chat.get('type')}
+                        num_unread=${chat.get('num_unread')}></converse-minimized-chat>`)}
+        </div>
+    </div>`;
+}

+ 1 - 1
src/plugins/minimize/templates/trimmed_chat.js

@@ -14,7 +14,7 @@ export default (o) => {
     }
 
     return html`
-    <div class="chat-head-${o.type} chat-head row no-gutters">
+    <div class="chat-head-${o.type} chat-head row g-0">
         <a class="restore-chat w-100 align-self-center" title="${i18n_tooltip}" @click=${o.restore}>
             ${o.num_unread ? html`<span class="message-count badge badge-light">${o.num_unread}</span>` : '' }
             ${o.title}

+ 4 - 6
src/plugins/minimize/tests/minchats.js

@@ -167,7 +167,7 @@ describe("A Chatbox", function () {
 });
 
 
-describe("A Minimized ChatBoxView's Unread Message Count", function () {
+describe("A minimized chat's Unread Message Count", function () {
 
     it("is displayed when scrolled up chatbox is minimized after receiving unread messages",
             mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
@@ -182,9 +182,8 @@ describe("A Minimized ChatBoxView's Unread Message Count", function () {
         await u.waitUntil(() => chatbox.messages.length);
         await u.waitUntil(() => chatbox.get('num_unread') === 1);
         _converse.exports.minimize.minimize(chatbox);
-
         const minimized_chats = await u.waitUntil(() => document.querySelector("converse-minimized-chats"));
-        const unread_count = minimized_chats.querySelector('#toggle-minimized-chats .unread-message-count');
+        const unread_count = minimized_chats.querySelector('#minimized-chats .badge');
         expect(u.isVisible(unread_count)).toBeTruthy();
         expect(unread_count.innerHTML.replace(/<!-.*?->/g, '')).toBe('1');
     }));
@@ -200,7 +199,7 @@ describe("A Minimized ChatBoxView's Unread Message Count", function () {
         _converse.handleMessageStanza(msgFactory());
         await u.waitUntil(() => view.model.messages.length);
         const minimized_chats = await u.waitUntil(() => document.querySelector("converse-minimized-chats"));
-        const unread_count = minimized_chats.querySelector('#toggle-minimized-chats .unread-message-count');
+        const unread_count = minimized_chats.querySelector('#minimized-chats .badge');
         expect(u.isVisible(unread_count)).toBeTruthy();
         expect(unread_count.innerHTML.replace(/<!-.*?->/g, '')).toBe('1');
     }));
@@ -260,7 +259,7 @@ describe("The Minimized Chats Widget", function () {
         minimized_chats = await u.waitUntil(() => document.querySelector("converse-minimized-chats"));
         expect(u.isVisible(minimized_chats.querySelector('.minimized-chats-flyout'))).toBeTruthy();
         expect(minimized_chats.minchats.get('collapsed')).toBeFalsy();
-        minimized_chats.querySelector('#toggle-minimized-chats').click();
+        minimized_chats.querySelector('#minimized-chats button').click();
         await u.waitUntil(() => u.isVisible(minimized_chats.querySelector('.minimized-chats-flyout')));
         expect(minimized_chats.minchats.get('collapsed')).toBeTruthy();
     }));
@@ -300,7 +299,6 @@ describe("The Minimized Chats Widget", function () {
         }
         await u.waitUntil(() => chatview.model.messages.length === 3, 500);
 
-
         expect(u.isVisible(minimized_chats.querySelector('.unread-message-count'))).toBeTruthy();
         expect(minimized_chats.querySelector('.unread-message-count').textContent).toBe((3).toString());
         // Chat state notifications don't increment the unread messages counter

+ 5 - 8
src/plugins/minimize/view.js

@@ -1,7 +1,7 @@
 import { _converse, api, u } from '@converse/headless';
 import { CustomElement } from 'shared/components/element';
 import MinimizedChatsToggle from './toggle.js';
-import tplChatsPanel from './templates/chats-panel.js';
+import tplToggle from './templates/toggle.js';
 
 
 export default class MinimizedChats extends CustomElement {
@@ -24,13 +24,7 @@ export default class MinimizedChats extends CustomElement {
     }
 
     render () {
-        const chats = this.model.where({'minimized': true});
-        const num_unread = chats.reduce((acc, chat) => (acc + chat.get('num_unread')), 0);
-        const num_minimized = chats.reduce((acc, chat) => (acc + (chat.get('minimized') ? 1 : 0)), 0);
-        const collapsed = this.minchats.get('collapsed');
-        const data = { chats, num_unread, num_minimized, collapsed };
-        data.toggle = ev => this.toggle(ev);
-        return tplChatsPanel(data);
+        return tplToggle(this);
     }
 
     async initToggle () {
@@ -41,6 +35,9 @@ export default class MinimizedChats extends CustomElement {
         await new Promise(resolve => this.minchats.fetch({'success': resolve, 'error': resolve}));
     }
 
+    /**
+     * @param {Event} [ev]
+     */
     toggle (ev) {
         ev?.preventDefault();
         this.minchats.save({'collapsed': !this.minchats.get('collapsed')});

+ 2 - 2
src/plugins/modal/confirm.js

@@ -1,7 +1,7 @@
-import BaseModal from "plugins/modal/modal.js";
-import tplPrompt from "./templates/prompt.js";
 import { getOpenPromise } from '@converse/openpromise';
 import { api } from "@converse/headless";
+import BaseModal from "plugins/modal/modal.js";
+import tplPrompt from "./templates/prompt.js";
 
 export default class Confirm extends BaseModal {
 

+ 45 - 11
src/plugins/modal/modal.js

@@ -1,36 +1,63 @@
 /**
  * @typedef {import('lit-html').TemplateResult} TemplateResult
  */
-import bootstrap from "bootstrap.native";
-import tplModal from './templates/modal.js';
-import { ElementView } from '@converse/skeletor';
+import { html } from 'lit-html';
 import { getOpenPromise } from '@converse/openpromise';
-
+import { Modal } from "bootstrap";
+import { ElementView } from '@converse/skeletor';
+import { u } from '@converse/headless';
+import { modal_close_button } from "./templates/buttons.js";
+import tplModal from './templates/modal.js';
 
 import './styles/_modal.scss';
 
 class BaseModal extends ElementView {
 
+    /**
+     * @param {Object} options
+     */
     constructor (options) {
         super();
         this.model = null;
-        this.className = 'modal';
+        this.className = u.isTestEnv() ? 'modal' : 'modal fade';
+        this.tabIndex = -1;
+        this.ariaHidden = 'true';
+
         this.initialized = getOpenPromise();
 
         // Allow properties to be set via passed in options
         Object.assign(this, options);
         setTimeout(() => this.insertIntoDOM());
 
-        this.addEventListener('hide.bs.modal', () => this.onHide(), false);
+        this.addEventListener('shown.bs.modal', () => {
+            this.ariaHidden = 'false';
+        });
+        this.addEventListener('hidden.bs.modal', () => {
+            this.ariaHidden = 'true';
+        });
     }
 
     initialize () {
-        this.modal = new bootstrap.Modal(this, {
-            backdrop: true,
+        this.render()
+        this.modal = new Modal(this, {
+            backdrop: u.isTestEnv() ? false : true,
             keyboard: true
         });
         this.initialized.resolve();
-        this.render()
+    }
+
+    /**
+     * @returns {TemplateResult|string}
+     */
+    renderModal () {
+        return '';
+    }
+
+    /**
+     * @returns {TemplateResult|string}
+     */
+    renderModalFooter() {
+        return html`<div class="modal-footer">${ modal_close_button }</div>`;
     }
 
     toHTML () {
@@ -45,14 +72,17 @@ class BaseModal extends ElementView {
         return '';
     }
 
+    /**
+     * @param {Event} [ev]
+     */
     switchTab (ev) {
         ev?.stopPropagation();
         ev?.preventDefault();
-        this.tab = ev.target.getAttribute('data-name');
+        this.tab = /** @type {HTMLElement} */(ev.target).getAttribute('data-name');
         this.render();
     }
 
-    onHide () {
+    close () {
         this.modal.hide();
     }
 
@@ -61,6 +91,10 @@ class BaseModal extends ElementView {
         container_el.insertAdjacentElement('beforeend', this);
     }
 
+    /**
+     * @param {string} message
+     * @param {'primary'|'secondary'|'danger'} type
+     */
     alert (message, type='primary') {
         this.model.set('alert', { message, type });
         setTimeout(() => {

+ 1 - 2
src/plugins/modal/styles/_modal.scss

@@ -1,10 +1,9 @@
+$prefix: 'converse-';
 @import "bootstrap/scss/functions";
 @import "bootstrap/scss/variables";
 @import "bootstrap/scss/mixins";
 
 .conversejs {
-    @import "bootstrap/scss/modal";
-
     .modal-header {
         &.alert-danger {
             background-color: var(--error-color);

+ 2 - 2
src/plugins/modal/templates/buttons.js

@@ -3,7 +3,7 @@ import { html } from "lit";
 
 
 export const modal_close_button =
-    html`<button type="button" class="btn btn-secondary" data-dismiss="modal">${__('Close')}</button>`;
+    html`<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">${__('Close')}</button>`;
 
 export const modal_header_close_button =
-    html`<button type="button" class="close" data-dismiss="modal" aria-label="${__('Close')}"><span aria-hidden="true">×</span></button>`;
+    html`<button type="button" class="btn btn-close" data-bs-dismiss="modal" aria-label="${__('Close')}"></button>`;

+ 7 - 4
src/plugins/modal/templates/modal.js

@@ -1,13 +1,16 @@
 import tplAlertComponent from "./modal-alert.js";
 import { html } from "lit";
-import { modal_close_button, modal_header_close_button } from "plugins/modal/templates/buttons.js";
+import { modal_header_close_button } from "./buttons.js";
 
 
+/**
+ * @param {import ('../modal').default} el
+ */
 export default (el) => {
     const alert = el.model?.get('alert');
     const level = el.model?.get('level') ?? '';
     return html`
-        <div class="modal-dialog" role="document" tabindex="-1" role="dialog" aria-hidden="true">
+        <div class="modal-dialog" role="document" role="dialog">
             <div class="modal-content">
                 <div class="modal-header ${level}">
                     <h5 class="modal-title">${el.getModalTitle()}</h5>
@@ -17,9 +20,9 @@ export default (el) => {
                     <span class="modal-alert">
                         ${ alert ? tplAlertComponent({'type': `alert-${alert.type}`, 'message': alert.message}) :  ''}
                     </span>
-                    ${ el.renderModal?.() ?? '' }
+                    ${ el.renderModal() }
                 </div>
-                ${ el.renderModalFooter?.() ?? html`<div class="modal-footer">${ modal_close_button }</div>` }
+                ${ el.renderModalFooter() }
             </div>
         </div>
     `;

+ 8 - 5
src/plugins/modal/templates/prompt.js

@@ -3,8 +3,8 @@ import { __ } from 'i18n';
 
 
 const tplField = (f) => html`
-    <div class="form-group">
-        <label>
+    <div>
+        <label class="form-label">
             ${f.label || ''}
             <input type="text"
                 name="${f.name}"
@@ -15,16 +15,19 @@ const tplField = (f) => html`
     </div>
 `;
 
+/**
+ * @param {import('../confirm').default} el
+ */
 export default (el) => {
     return html`
         <form class="converse-form converse-form--modal confirm" action="#" @submit=${ev => el.onConfimation(ev)}>
-            <div class="form-group">
+            <div>
                 ${ el.model.get('messages')?.map(message => html`<p>${message}</p>`) }
             </div>
             ${ el.model.get('fields')?.map(f => tplField(f)) }
-            <div class="form-group">
+            <div>
                 <button type="submit" class="btn btn-primary">${__('OK')}</button>
-                <input type="button" class="btn btn-secondary" data-dismiss="modal" value="${__('Cancel')}"/>
+                <input type="button" class="btn btn-secondary" data-bs-dismiss="modal" value="${__('Cancel')}"/>
             </div>
         </form>`;
 }

+ 3 - 3
src/plugins/muc-views/modals/nickname.js

@@ -1,7 +1,7 @@
-import BaseModal from "plugins/modal/modal.js";
-import { __ } from 'i18n';
-import { api } from "@converse/headless";
 import { html } from 'lit';
+import { api } from "@converse/headless";
+import { __ } from 'i18n';
+import BaseModal from "plugins/modal/modal.js";
 
 export default class MUCNicknameModal extends BaseModal {
 

+ 6 - 6
src/plugins/muc-views/modals/templates/add-muc.js

@@ -9,8 +9,8 @@ const nickname_input = (el) => {
     const i18n_nickname = __('Nickname');
     const i18n_required_field = __('This field is required');
     return html`
-        <div class="form-group">
-            <label for="nickname">${i18n_nickname}:</label>
+        <div>
+            <label for="nickname" class="form-label">${i18n_nickname}:</label>
             <input
                 type="text"
                 title="${i18n_required_field}"
@@ -38,10 +38,10 @@ export default (el) => {
     const muc_search_service = api.settings.get('muc_search_service');
     return html`
         <form class="converse-form add-chatroom" @submit=${(ev) => el.openChatRoom(ev)}>
-            <div class="form-group">
-                <label for="chatroom">${label_room_address}:</label>
+            <div>
+                <label for="chatroom" class="form-label">${label_room_address}:</label>
                 ${muc_roomid_policy_error_msg
-                    ? html`<label class="roomid-policy-error">${muc_roomid_policy_error_msg}</label>`
+                    ? html`<label class="form-label roomid-policy-error">${muc_roomid_policy_error_msg}</label>`
                     : ''}
                 ${muc_search_service
                     ? html` <converse-autocomplete
@@ -57,7 +57,7 @@ export default (el) => {
                     : ''}
             </div>
             ${muc_roomid_policy_hint
-                ? html`<div class="form-group">
+                ? html`<div>
                       ${unsafeHTML(DOMPurify.sanitize(muc_roomid_policy_hint, { 'ALLOWED_TAGS': ['b', 'br', 'em'] }))}
                   </div>`
                 : ''}

+ 2 - 2
src/plugins/muc-views/modals/templates/muc-config.js

@@ -40,11 +40,11 @@ export default (el) => {
             autocomplete="off"
             @submit=${ev => el.submitConfigForm(ev)}
         >
-            <fieldset class="form-group">
+            <fieldset>
                 <legend class="centered">${title}</legend>
                 ${title !== instructions ? html`<p class="form-help">${instructions}</p>` : ''}
 
-                ${fieldTemplates.length && el.model.features.get('vcard-temp') ? html`<div class="row">
+                ${fieldTemplates.length && el.model.features.get('vcard-temp') ? html`<div class="row py-2">
                     <converse-image-picker .model=${el.model} width="96" height="96"></converse-image-picker>
                 </div>` : ''}
 

+ 10 - 8
src/plugins/muc-views/modals/templates/muc-invite.js

@@ -9,8 +9,8 @@ export default (el) => {
     const i18n_reason = __('Optional reason for the invitation');
     return html`
         <form class="converse-form" @submit=${(ev) => el.submitInviteForm(ev)}>
-            <div class="form-group">
-                <label class="clearfix" for="invitee_jids">${i18n_invite_label}:</label>
+            <fieldset>
+                <label class="form-label clearfix" for="invitee_jids">${i18n_invite_label}:</label>
                 ${ el.model.get('invalid_invite_jid') ? html`<div class="error error-feedback">${i18n_error_message}</div>` : '' }
                 <converse-autocomplete
                     .getAutoCompleteList=${() => el.getAutoCompleteList()}
@@ -22,14 +22,16 @@ export default (el) => {
                     id="invitee_jids"
                     placeholder="${i18n_jid_placeholder}">
                 </converse-autocomplete>
-            </div>
-            <div class="form-group">
-                <label>${i18n_reason}:</label>
+            </fieldset>
+
+            <fieldset>
+                <label class="form-label">${i18n_reason}:</label>
                 <textarea class="form-control" name="reason"></textarea>
-            </div>
-            <div class="form-group">
+            </fieldset>
+
+            <fieldset>
                 <input type="submit" class="btn btn-primary" value="${i18n_invite}"/>
-            </div>
+            </fieldset>
         </form>
     `;
 }

+ 9 - 7
src/plugins/muc-views/modals/templates/occupant.js

@@ -4,7 +4,9 @@ import { html } from "lit";
 import { until } from 'lit/directives/until.js';
 import { _converse, api } from "@converse/headless";
 
-
+/**
+ * @param {import('../occupant').default} el
+ */
 export default (el) => {
     const model = el.model ?? el.message;
     const jid = model?.get('jid');
@@ -43,13 +45,13 @@ export default (el) => {
             <div class="col">
                 <ul class="occupant-details">
                     <li>
-                        ${ nick ? html`<div class="row"><strong>${__('Nickname')}:</strong></div><div class="row">${nick}</div>` : '' }
+                        ${ nick ? html`<div class="row"><strong class="g-0">${__('Nickname')}:</strong></div><div class="row">${nick}</div>` : '' }
                     </li>
                     <li>
-                        ${ jid ? html`<div class="row"><strong>${__('XMPP Address')}:</strong></div><div class="row">${jid}</div>` : '' }
+                        ${ jid ? html`<div class="row"><strong class="g-0">${__('XMPP Address')}:</strong></div><div class="row">${jid}</div>` : '' }
                     </li>
                     <li>
-                        <div class="row"><strong>${__('Affiliation')}:</strong></div>
+                        <div class="row"><strong class="g-0">${__('Affiliation')}:</strong></div>
                         <div class="row">${affiliation}&nbsp;
                             ${ may_moderate ? html`
                                 <a href="#"
@@ -63,7 +65,7 @@ export default (el) => {
                         </div>
                     </li>
                     <li>
-                        <div class="row"><strong>${__('Role')}:</strong></div>
+                        <div class="row"><strong class="g-0">${__('Role')}:</strong></div>
                         <div class="row">${role}&nbsp;
                             ${ may_moderate && role ? html`
                                 <a href="#"
@@ -77,10 +79,10 @@ export default (el) => {
                         </div>
                     </li>
                     <li>
-                        ${ hats ? html`<div class="row"><strong>${__('Hats')}:</strong></div><div class="row">${hats}</div>` : '' }
+                        ${ hats ? html`<div class="row"><strong class="g-0">${__('Hats')}:</strong></div><div class="row">${hats}</div>` : '' }
                     </li>
                     <li>
-                        ${ occupant_id ? html`<div class="row"><strong>${__('Occupant Id')}:</strong></div><div class="row">${occupant_id}</div>` : '' }
+                        ${ occupant_id ? html`<div class="row"><strong class="g-0">${__('Occupant Id')}:</strong></div><div class="row">${occupant_id}</div>` : '' }
                     </li>
                     ${ until(add_to_contacts, '') }
                 </ul>

+ 3 - 3
src/plugins/muc-views/nickname-form.js

@@ -43,9 +43,9 @@ class MUCNicknameForm extends CustomElement {
     }
 
     closeModal () {
-        const evt = document.createEvent('Event');
-        evt.initEvent('hide.bs.modal', true, true);
-        this.dispatchEvent(evt);
+        /** @type {import('plugins/modal/modal').default} */ (
+            document.querySelector('converse-muc-nickname-modal')
+        ).close();
     }
 }
 

+ 10 - 3
src/plugins/muc-views/styles/muc-bottom-panel.scss

@@ -1,8 +1,9 @@
 .conversejs {
-    converse-muc.chatroom {
-        converse-muc-bottom-panel.bottom-panel {
+    converse-muc {
+
+        .muc-bottom-panel,
+        converse-muc-bottom-panel {
             display: contents;
-            height: 3em;
             padding: 0.5em;
             text-align: center;
             font-size: var(--font-size-small);
@@ -25,7 +26,13 @@
                     }
                 }
             }
+        }
+
+        .muc-bottom-panel {
+            height: 3em;
+        }
 
+        converse-muc-bottom-panel {
             .sendXMPPMessage {
                 .suggestion-box__results--above {
                     bottom: 4.5em;

+ 0 - 29
src/plugins/muc-views/styles/muc-bottompanel.scss

@@ -1,29 +0,0 @@
-converse-muc-bottom-panel {
-    display: contents;
-}
-
-.muc-bottom-panel {
-    height: 3em;
-    padding: 0.5em;
-    text-align: center;
-    font-size: var(--font-size-small);
-    background-color: var(--chatroom-head-bg-color);
-    color: white;
-
-    &.muc-bottom-panel--muted {
-        height: 4em;
-        width: 100%;
-    }
-
-    &.muc-bottom-panel--nickname {
-        padding: 0;
-        height: 16em;
-
-        .muc-form-container {
-            .chatroom-form {
-                padding-top: 2em;
-                padding-bottom: 0;
-            }
-        }
-    }
-}

+ 0 - 1
src/plugins/muc-views/styles/muc-forms.scss

@@ -27,7 +27,6 @@
                 display: flex;
                 flex-direction: column;
                 justify-content: center;
-                padding: 2em;
             }
         }
     }

+ 1 - 1
src/plugins/muc-views/styles/muc-occupants.scss

@@ -111,7 +111,7 @@
                                 }
                             }
 
-                            div.row.no-gutters {
+                            div.row.g-0{
                                 flex-wrap: nowrap;
                                 min-height: 1.5em;
                             }

+ 5 - 5
src/plugins/muc-views/templates/affiliation-form.js

@@ -11,21 +11,21 @@ export default (el) => {
     return html`
         <form class="affiliation-form" @submit=${ev => el.assignAffiliation(ev)}>
             ${el.alert_message ? html`<div class="alert alert-${el.alert_type}" role="alert">${el.alert_message}</div>` : '' }
-            <div class="form-group">
+            <div>
                 <div class="row">
                     <div class="col">
-                        <label><strong>${i18n_new_affiliation}:</strong></label>
-                        <select class="custom-select select-affiliation" name="affiliation">
+                        <label class="form-label"><strong>${i18n_new_affiliation}:</strong></label>
+                        <select class="form-select select-affiliation" name="affiliation">
                             ${ assignable_affiliations.map(aff => html`<option value="${aff}" ?selected=${aff === el.affiliation}>${aff}</option>`) }
                         </select>
                     </div>
                     <div class="col">
-                        <label><strong>${i18n_reason}:</strong></label>
+                        <label class="form-label"><strong>${i18n_reason}:</strong></label>
                         <input class="form-control" type="text" name="reason"/>
                     </div>
                 </div>
             </div>
-            <div class="form-group">
+            <div>
                 <div class="col">
                     <input type="submit" class="btn btn-primary" name="change" value="${i18n_change_affiliation}"/>
                 </div>

+ 6 - 6
src/plugins/muc-views/templates/moderator-tools.js

@@ -142,13 +142,13 @@ export default (el, o) => {
             <div class="tab-pane tab-pane--columns ${ o.tab === 'affiliations' ? 'active' : ''}" id="affiliations-tabpanel" role="tabpanel" aria-labelledby="affiliations-tab">
                 <form class="converse-form query-affiliation" @submit=${o.queryAffiliation}>
                     <p class="helptext pb-3">${i18n_helptext_affiliation}</p>
-                    <div class="form-group">
-                        <label for="affiliation">
+                    <div>
+                        <label class="form-label" for="affiliation">
                             <strong>${i18n_affiliation}:</strong>
                         </label>
                         <div class="row">
                             <div class="col">
-                                <select class="custom-select select-affiliation" name="affiliation">
+                                <select class="form-select select-affiliation" name="affiliation">
                                     ${o.queryable_affiliations.map(item => affiliation_option(Object.assign({item}, o)))}
                                 </select>
                             </div>
@@ -184,11 +184,11 @@ export default (el, o) => {
             <div class="tab-pane tab-pane--columns ${ o.tab === 'roles' ? 'active' : ''}" id="roles-tabpanel" role="tabpanel" aria-labelledby="roles-tab">
                 <form class="converse-form query-role" @submit=${o.queryRole}>
                     <p class="helptext pb-3">${i18n_helptext_role}</p>
-                    <div class="form-group">
-                        <label for="role"><strong>${i18n_role}:</strong></label>
+                    <div>
+                        <label class="form-label" for="role"><strong>${i18n_role}:</strong></label>
                         <div class="row">
                             <div class="col">
-                                <select class="custom-select select-role" name="role">
+                                <select class="form-select select-role" name="role">
                                     ${o.queryable_roles.map(item => role_option(Object.assign({item}, o)))}
                                 </select>
                             </div>

+ 1 - 1
src/plugins/muc-views/templates/muc-head.js

@@ -45,7 +45,7 @@ export default (el) => {
                             </converse-icon>` : '' }
                 </div>
             </div>
-            <div class="chatbox-title__buttons row no-gutters">
+            <div class="chatbox-title__buttons row g-0">
                 ${ until(getStandaloneButtons(heading_buttons_promise), '') }
                 ${ until(getDropdownButtons(heading_buttons_promise), '') }
             </div>

+ 2 - 2
src/plugins/muc-views/templates/muc-list.js

@@ -10,8 +10,8 @@ const form = (o) => {
     return html`
         <form class="converse-form list-chatrooms"
             @submit=${o.submitForm}>
-            <div class="form-group">
-                <label for="chatroom">${i18n_server_address}:</label>
+            <div>
+                <label class="form-label" for="chatroom">${i18n_server_address}:</label>
                 <input type="text"
                     autofocus
                     @change=${o.setDomainFromEvent}

+ 3 - 3
src/plugins/muc-views/templates/muc-nickname-form.js

@@ -15,8 +15,8 @@ export default (el) => {
         <div class="chatroom-form-container muc-nickname-form">
                 <form class="converse-form chatroom-form converse-centered-form"
                         @submit=${ev => el.submitNickname(ev)}>
-                <fieldset class="form-group">
-                    <label>${i18n_heading}</label>
+                <fieldset>
+                    <label class="form-label">${i18n_heading}</label>
                     <p class="validation-message">${validation_message}</p>
                     <input type="text"
                         required="required"
@@ -25,7 +25,7 @@ export default (el) => {
                         class="form-control ${validation_message ? 'error': ''}"
                         placeholder="${i18n_nickname}"/>
                 </fieldset>
-                <fieldset class="form-group">
+                <fieldset>
                     <input type="submit"
                         class="btn btn-primary"
                         name="join"

+ 3 - 3
src/plugins/muc-views/templates/muc-password-form.js

@@ -8,8 +8,8 @@ export default (o) => {
     const i18n_submit = __('Submit');
     return html`
         <form class="converse-form chatroom-form converse-centered-form" @submit=${o.submitPassword}>
-            <fieldset class="form-group">
-                <label>${i18n_heading}</label>
+            <fieldset>
+                <label class="form-label">${i18n_heading}</label>
                 <p class="validation-message">${o.validation_message}</p>
                 <input class="hidden-username" type="text" autocomplete="username" value="${o.jid}"></input>
                 <input type="password"
@@ -18,7 +18,7 @@ export default (o) => {
                     class="form-control ${o.validation_message ? 'error': ''}"
                     placeholder="${i18n_password}"/>
             </fieldset>
-            <fieldset class="form-group">
+            <fieldset>
                 <input class="btn btn-primary" type="submit" value="${i18n_submit}"/>
             </fieldset>
         </form>

+ 3 - 1
src/plugins/muc-views/templates/muc-sidebar.js

@@ -78,7 +78,9 @@ export default (el, o) => {
                 <span class="occupants-heading">${el.model.occupants.length} ${i18n_participants}</span>
                 ${btns.length === 1
                     ? btns[0]
-                    : html`<converse-dropdown class="chatbox-btn dropleft" .items=${btns}></converse-dropdown>`}
+                        : html`<converse-dropdown
+                            class="chatbox-btn btn-group dropstart"
+                            .items=${btns}></converse-dropdown>`}
             </div>
         </div>
         <div class="dragresize dragresize-occupants-left"></div>

+ 2 - 2
src/plugins/muc-views/templates/muc.js

@@ -13,9 +13,9 @@ export default (o) => {
         <div class="flyout box-flyout">
             <converse-dragresize></converse-dragresize>
             ${ o.model ? html`
-                <converse-muc-heading jid="${o.model.get('jid')}" class="chat-head chat-head-chatroom row no-gutters">
+                <converse-muc-heading jid="${o.model.get('jid')}" class="chat-head chat-head-chatroom row g-0">
                 </converse-muc-heading>
-                <div class="chat-body chatroom-body row no-gutters">${getChatRoomBodyTemplate(o)}</div>
+                <div class="chat-body chatroom-body row g-0">${getChatRoomBodyTemplate(o)}</div>
             ` : '' }
         </div>`;
 }

+ 1 - 1
src/plugins/muc-views/templates/occupant.js

@@ -58,7 +58,7 @@ export default (o, chat) => {
 
     return html`
         <li class="occupant" id="${o.id}">
-            <div class="row no-gutters">
+            <div class="row g-0">
                 <div class="col-auto">
                     <a class="show-msg-author-modal" @click=${(ev) => showOccupantModal(ev, o)}>
                         <converse-avatar

+ 1 - 1
src/plugins/muc-views/templates/occupants-filter.js

@@ -26,7 +26,7 @@ export default (el) => {
     return html`
         <form class="items-filter-form input-button-group ${ (!el.shouldBeVisible()) ? 'hidden' : 'fade-in' }"
               @submit=${ev => el.submitFilter(ev)}>
-            <div class="form-inline ${is_overlay_mode ? '' : 'flex-nowrap'}">
+            <div class="${is_overlay_mode ? '' : 'flex-nowrap'}">
                 <div class="filter-by d-flex flex-nowrap">
                     <converse-icon
                             size="1em"

+ 5 - 5
src/plugins/muc-views/templates/role-form.js

@@ -10,23 +10,23 @@ export default (el) => {
 
     return html`
         <form class="role-form" @submit=${el.assignRole}>
-            <div class="form-group">
+            <div>
                 <input type="hidden" name="jid" value="${el.jid}"/>
                 <input type="hidden" name="nick" value="${el.nick}"/>
                 <div class="row">
                     <div class="col">
-                        <label><strong>${i18n_new_role}:</strong></label>
-                        <select class="custom-select select-role" name="role">
+                        <label class="form-label"><strong>${i18n_new_role}:</strong></label>
+                        <select class="form-select select-role" name="role">
                         ${ assignable_roles.map(role => html`<option value="${role}" ?selected=${role === el.role}>${role}</option>`) }
                         </select>
                     </div>
                     <div class="col">
-                        <label><strong>${i18n_reason}:</strong></label>
+                        <label class="form-label"><strong>${i18n_reason}:</strong></label>
                         <input class="form-control" type="text" name="reason"/>
                     </div>
                 </div>
             </div>
-            <div class="form-group">
+            <div>
                 <div class="col">
                     <input type="submit" class="btn btn-primary" value="${i18n_change_role}"/>
                 </div>

+ 1 - 1
src/plugins/muc-views/tests/muc.js

@@ -1946,7 +1946,7 @@ describe("Groupchats", function () {
             expect(view.model.features.get('unsecured')).toBe(false);
             await u.waitUntil(() => view.querySelector('.chatbox-title__text').textContent.trim() === 'Room');
 
-            modal.querySelector('.close').click();
+            modal.querySelector('.btn-close').click();
             view.querySelector('.configure-chatroom-button').click();
 
             const IQs = _converse.api.connection.get().IQ_stanzas;

+ 8 - 8
src/plugins/omemo/templates/profile.js

@@ -12,10 +12,10 @@ const device_with_fingerprint = (el) => {
     const i18n_fingerprint_checkbox_label = __('Checkbox for selecting the following fingerprint');
     return html`
         <li class="fingerprint-removal-item list-group-item">
-            <label>
-            <input type="checkbox" value="${el.device.get('id')}"
-                aria-label="${i18n_fingerprint_checkbox_label}"/>
-            <span class="fingerprint">${formatFingerprint(el.device.get('bundle').fingerprint)}</span>
+            <label class="form-label">
+                <input type="checkbox" value="${el.device.get('id')}"
+                    aria-label="${i18n_fingerprint_checkbox_label}"/>
+                <span class="fingerprint">${formatFingerprint(el.device.get('bundle').fingerprint)}</span>
             </label>
         </li>
     `;
@@ -27,7 +27,7 @@ const device_without_fingerprint = (el) => {
     const i18n_fingerprint_checkbox_label = __('Checkbox for selecting the following device');
     return html`
         <li class="fingerprint-removal-item list-group-item">
-            <label>
+            <label class="form-label">
             <input type="checkbox" value="${el.device.get('id')}"
                 aria-label="${i18n_fingerprint_checkbox_label}"/>
             <span>${i18n_device_without_fingerprint}</span>
@@ -50,14 +50,14 @@ const device_list = (el) => {
     return html`
         <ul class="list-group fingerprints">
             <li class="list-group-item active">
-                <label>
+                <label class="form-label">
                     <input type="checkbox" class="select-all" @change=${el.selectAll} title="${i18n_select_all}" aria-label="${i18n_other_devices_label}"/>
                     ${i18n_other_devices}
                 </label>
             </li>
             ${ el.other_devices?.map(device => device_item(Object.assign({device}, el))) }
         </ul>
-        <div class="form-group"><button type="submit" class="save-form btn btn-primary">${i18n_remove_devices}</button></div>
+        <div><button type="submit" class="save-form btn btn-primary">${i18n_remove_devices}</button></div>
     `;
 }
 
@@ -73,7 +73,7 @@ export default (el) => {
                     ${ (el.current_device && el.current_device.get('bundle') && el.current_device.get('bundle').fingerprint) ? fingerprint(el) : spinner() }
                 </li>
             </ul>
-            <div class="form-group">
+            <div class="pb-3">
                 <button type="button" class="generate-bundle btn btn-danger" @click=${el.generateOMEMODeviceBundle}>${i18n_generate}</button>
             </div>
             ${ el.other_devices?.length ? device_list(el) : '' }

+ 3 - 3
src/plugins/profile/modals/chat-status.js

@@ -40,10 +40,10 @@ export default class ChatStatusModal extends BaseModal {
         ev.preventDefault();
         const data = new FormData(ev.target);
         this.model.save({
-            'status_message': data.get('status_message'),
-            'status': data.get('chat_status'),
+            status_message: data.get('status_message'),
+            status: data.get('chat_status'),
         });
-        this.modal.hide();
+        this.close();
     }
 }
 

+ 2 - 0
src/plugins/profile/modals/profile.js

@@ -10,6 +10,8 @@ import { __ } from 'i18n';
 import '../password-reset.js';
 import { compressImage, isImageWithAlphaChannel } from 'utils/file.js';
 
+import './styles/profile.scss';
+
 
 export default class ProfileModal extends BaseModal {
 

+ 27 - 28
src/plugins/profile/modals/styles/profile.scss

@@ -1,38 +1,37 @@
-converse-profile-modal {
-    .profile-form {
-        label {
-            font-weight: bold;
-        }
-    }
+.conversejs {
+    converse-profile-modal {
 
-    .fingerprint-removal {
-        label {
-            display: flex;
-            padding: 0.75rem 1.25rem;
+        .fingerprint {
+            color: var(--text-color);
         }
-    }
 
-    .list-group-item {
-        display: flex;
-        justify-content: left;
-        font-size: 95%;
+        .fingerprint-removal-item {
+            label {
+                font-weight: normal;
+                color: var(--text-color);
+            }
+        }
 
-        input[type="checkbox"] {
-            margin-right: 1em;
+        .list-group-item {
+            font-size: 95%;
+            border-color: var(--primary-color);
+            input[type="checkbox"] {
+                margin-right: 1em;
+            }
         }
-    }
 
-    .fingerprints {
-        width: 100%;
-        margin-bottom: 1em;
-    }
+        .fingerprints {
+            width: 100%;
+            margin-bottom: 1em;
+        }
 
-    .fingerprint-trust {
-        display: flex;
-        justify-content: space-between;
-        font-size: 95%;
-        .fingerprint {
-            margin-left: 1em;
+        .fingerprint-trust {
+            display: flex;
+            justify-content: space-between;
+            font-size: 95%;
+            .fingerprint {
+                margin-left: 1em;
+            }
         }
     }
 }

+ 2 - 2
src/plugins/profile/templates/chat-status-modal.js

@@ -14,7 +14,7 @@ export default (el) => {
 
     return html`
     <form class="converse-form set-xmpp-status" id="set-xmpp-status" @submit=${ev => el.onFormSubmitted(ev)}>
-        <div class="form-group">
+        <div>
             <div class="custom-control custom-radio">
                 <input ?checked=${status === 'online'}
                     type="radio" id="radio-online" value="online" name="chat_status" class="custom-control-input"/>
@@ -40,7 +40,7 @@ export default (el) => {
                     <converse-icon size="1em" class="far fa-circle chat-status chat-status--xa"></converse-icon>${label_xa}</label>
             </div>
         </div>
-        <div class="form-group">
+        <div>
             <div class="btn-group w-100">
                 <input name="status_message" type="text" class="form-control" autofocus
                     value="${status_message || ''}" placeholder="${placeholder_status_message}"/>

+ 6 - 5
src/plugins/profile/templates/password-reset.js

@@ -10,8 +10,8 @@ export default el => {
     return html`<form class="converse-form passwordreset-form" method="POST" @submit=${ev => el.onSubmit(ev)}>
         ${el.alert_message ? html`<div class="alert alert-danger" role="alert">${el.alert_message}</div>` : ''}
 
-        <div class="form-group">
-            <label for="converse_password_reset_new">${i18n_new_password}</label>
+        <div class="py-2">
+            <label for="converse_password_reset_new" class="form-label">${i18n_new_password}</label>
             <input
                 class="form-control ${el.passwords_mismatched ? 'error' : ''}"
                 type="password"
@@ -24,8 +24,9 @@ export default el => {
                 ?disabled="${el.alert_message}"
             />
         </div>
-        <div class="form-group">
-            <label for="converse_password_reset_check">${i18n_confirm_password}</label>
+
+        <div class="py-2">
+            <label for="converse_password_reset_check" class="form-label">${i18n_confirm_password}</label>
             <input
                 class="form-control ${el.passwords_mismatched ? 'error' : ''}"
                 type="password"
@@ -41,7 +42,7 @@ export default el => {
             ${el.passwords_mismatched ? html`<span class="error">${i18n_passwords_must_match}</span>` : ''}
         </div>
 
-        <input class="save-form btn btn-primary"
+        <input class="py-3 save-form btn btn-primary"
                type="submit"
                value=${i18n_submit}
                ?disabled="${el.alert_message}" />

+ 9 - 9
src/plugins/profile/templates/profile_modal.js

@@ -70,41 +70,41 @@ export default (el) => {
         <ul class="nav nav-pills justify-content-center">${navigation_tabs}</ul>
         <div class="tab-content">
             <div class="tab-pane ${ el.tab === 'profile' ? 'active' : ''}" id="profile-tabpanel" role="tabpanel" aria-labelledby="profile-tab">
-                <form class="converse-form converse-form--modal profile-form" action="#" @submit=${ev => el.onFormSubmitted(ev)}>
-                    <div class="row">
+                <form class="converse-form converse-form--modal" action="#" @submit=${ev => el.onFormSubmitted(ev)}>
+                    <div class="row py-2">
                         <div class="col-auto">
                             <converse-image-picker .model=${el.model} width="128" height="128"></converse-image-picker>
                         </div>
                         <div class="col">
-                            <div class="form-group">
+                            <div class="px-3">
                                 <label class="col-form-label">${i18n_jid}:</label>
                                 <div>${o.jid}</div>
                             </div>
                         </div>
                     </div>
-                    <div class="form-group">
+                    <div>
                         <label for="vcard-fullname" class="col-form-label">${i18n_fullname}:</label>
                         <input id="vcard-fullname" type="text" class="form-control" name="fn" value="${o.fullname || ''}"/>
                     </div>
-                    <div class="form-group">
+                    <div>
                         <label for="vcard-nickname" class="col-form-label">${i18n_nickname}:</label>
                         <input id="vcard-nickname" type="text" class="form-control" name="nickname" value="${o.nickname || ''}"/>
                     </div>
-                    <div class="form-group">
+                    <div>
                         <label for="vcard-url" class="col-form-label">${i18n_url}:</label>
                         <input id="vcard-url" type="url" class="form-control" name="url" value="${o.url || ''}"/>
                     </div>
-                    <div class="form-group">
+                    <div>
                         <label for="vcard-email" class="col-form-label">${i18n_email}:</label>
                         <input id="vcard-email" type="email" class="form-control" name="email" value="${o.email || ''}"/>
                     </div>
-                    <div class="form-group">
+                    <div>
                         <label for="vcard-role" class="col-form-label">${i18n_role}:</label>
                         <input id="vcard-role" type="text" class="form-control" name="role" value="${o.role || ''}" aria-describedby="vcard-role-help"/>
                         <small id="vcard-role-help" class="form-text text-muted">${i18n_role_help}</small>
                     </div>
                     <hr/>
-                    <div class="form-group">
+                    <div>
                         <button type="submit" class="save-form btn btn-primary">${i18n_save}</button>
                     </div>
                 </form>

+ 2 - 2
src/plugins/register/templates/register_panel.js

@@ -59,8 +59,8 @@ const tplChooseProvider = (el) => {
     return html`
         <form id="converse-register" class="converse-form" @submit=${ev => el.onFormSubmission(ev)}>
             <legend class="col-form-label">${i18n_create_account}</legend>
-            <div class="form-group">
-                <label>${i18n_choose_provider}</label>
+            <div>
+                <label class="form-label">${i18n_choose_provider}</label>
 
                 ${default_domain ? default_domain : tplDomainInput()}
             </div>

+ 1 - 1
src/plugins/roomslist/templates/roomslist.js

@@ -190,7 +190,7 @@ export default (el) => {
                         color="var(--muc-color)"></converse-icon>` : '' }
                 </a>
             </span>
-            <converse-dropdown class="dropleft" .items=${btns}></converse-dropdown>
+            <converse-dropdown class="btn-group dropstart" .items=${btns}></converse-dropdown>
         </div>
 
         <div class="list-container list-container--openrooms ${ rooms.length ? '' : 'hidden' }">

+ 1 - 0
src/plugins/rootview/root.js

@@ -31,6 +31,7 @@ export default class ConverseRoot extends CustomElement {
 
     setClasses () {
         this.className = "";
+        this.classList.add('container-fluid');
         this.classList.add('conversejs');
         this.classList.add(`converse-${api.settings.get('view_mode')}`);
         this.classList.add(`theme-${getTheme()}`);

+ 1 - 1
src/plugins/rootview/templates/root.js

@@ -6,7 +6,7 @@ export default () => {
     const extra_classes = api.settings.get('singleton') ? ['converse-singleton'] : [];
     extra_classes.push(`converse-${api.settings.get('view_mode')}`);
     return html`
-        <converse-chats class="converse-chatboxes row no-gutters ${extra_classes.join(' ')}"></converse-chats>
+        <converse-chats class="converse-chatboxes row justify-content-start g-0 ${extra_classes.join(' ')}"></converse-chats>
         <div id="converse-modals" class="modals"></div>
         <converse-fontawesome></converse-fontawesome>
     `;

+ 4 - 4
src/plugins/rosterview/modals/templates/add-contact.js

@@ -22,7 +22,7 @@ export default (el) => {
             <div class="modal-body">
                 <span class="modal-alert"></span>
                 <div class="form-group add-xmpp-contact__jid">
-                    <label class="clearfix" for="jid">${i18n_xmpp_address}:</label>
+                    <label class="form-label clearfix" for="jid">${i18n_xmpp_address}:</label>
                     ${api.settings.get('autocomplete_add_contact') ?
                         html`<converse-autocomplete
                             .list=${getJIDsAutoCompleteList()}
@@ -43,7 +43,7 @@ export default (el) => {
                 </div>
 
                 <div class="form-group add-xmpp-contact__name">
-                    <label class="clearfix" for="name">${i18n_nickname}:</label>
+                    <label class="form-label clearfix" for="name">${i18n_nickname}:</label>
                     ${api.settings.get('autocomplete_add_contact') && typeof api.settings.get('xhr_user_search_url') === 'string' ?
                         html`<converse-autocomplete
                             .getAutoCompleteList=${getNamesAutoCompleteList}
@@ -59,12 +59,12 @@ export default (el) => {
                     }
                 </div>
                 <div class="form-group add-xmpp-contact__group">
-                    <label class="clearfix" for="name">${i18n_group}:</label>
+                    <label class="form-label clearfix" for="name">${i18n_group}:</label>
                     <converse-autocomplete
                         .list=${getGroupsAutoCompleteList()}
                         name="group"></converse-autocomplete>
                 </div>
-                ${error ? html`<div class="form-group"><div style="display: block" class="invalid-feedback">${error}</div></div>` : ''}
+                ${error ? html`<div><div style="display: block" class="invalid-feedback">${error}</div></div>` : ''}
                 <button type="submit" class="btn btn-primary">${i18n_add}</button>
             </div>
         </form>`;

+ 3 - 1
src/plugins/rosterview/templates/roster.js

@@ -90,7 +90,9 @@ export default (el) => {
                         ></converse-icon>` : '' }
                 </a>
             </span>
-            <converse-dropdown class="chatbox-btn dropleft dropdown--contacts" .items=${btns}></converse-dropdown>
+            <converse-dropdown
+                class="chatbox-btn btn-group dropstart dropdown--contacts"
+                .items=${btns}></converse-dropdown>
         </div>
 
         <div class="list-container roster-contacts ${is_closed ? 'hidden' : ''}">

+ 1 - 1
src/plugins/rosterview/templates/roster_filter.js

@@ -28,7 +28,7 @@ export default (el) => {
     return html`
         <form class="controlbox-padded items-filter-form input-button-group ${ !el.shouldBeVisible() ? 'hidden' : 'fade-in' }"
               @submit=${ev => el.submitFilter(ev)}>
-            <div class="form-inline flex-nowrap">
+            <div class="flex-nowrap">
                 <div class="filter-by d-flex flex-nowrap">
                     <converse-icon size="1em" @click=${ev => el.changeTypeFilter(ev)} class="fa fa-user clickable ${ (filter_type === 'items') ? 'selected' : '' }" data-type="items" title="${title_contact_filter}"></converse-icon>
                     <converse-icon size="1em" @click=${ev => el.changeTypeFilter(ev)} class="fa fa-users clickable ${ (filter_type === 'groups') ? 'selected' : '' }" data-type="groups" title="${title_group_filter}"></converse-icon>

+ 1 - 1
src/plugins/rosterview/tests/roster.js

@@ -1133,7 +1133,7 @@ describe("The Contacts Roster", function () {
                 _converse.roster.get(jid).presence.set('show', 'unavailable');
             }
 
-            await u.waitUntil(() => u.isVisible(rosterview.querySelector('li:first-child')), 900);
+            await u.waitUntil(() => u.isVisible(rosterview.querySelector('li.list-item:first-child')));
             const roster = rosterview;
             const groups = roster.querySelectorAll('.roster-group');
             const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));

+ 0 - 1
src/plugins/singleton/singleton.scss

@@ -1,7 +1,6 @@
 @import "bootstrap/scss/functions";
 @import "bootstrap/scss/variables";
 @import "bootstrap/scss/mixins";
-@import "bootstrap/scss/media";
 @import "shared/styles/_variables.scss";
 @import "shared/styles/_mixins.scss";
 

+ 32 - 39
src/shared/chat/emoji-dropdown.js

@@ -20,9 +20,12 @@ export default class EmojiDropdown extends DropdownBase {
 
     constructor () {
         super();
+        this.id = u.getUniqueId();
+
         // This is an optimization, we lazily render the emoji picker, otherwise tests slow to a crawl.
         this.render_emojis = false;
         this.chatview = null;
+        this.addEventListener('shown.bs.dropdown', () => this.onShown());
     }
 
     initModel () {
@@ -44,32 +47,35 @@ export default class EmojiDropdown extends DropdownBase {
     render() {
         const is_groupchat = this.chatview.model.get('type') === CHATROOMS_TYPE;
         const color = is_groupchat ? '--muc-toolbar-btn-color' : '--chat-toolbar-btn-color';
+
         return html`
-            <div class="dropup">
-                <button class="toggle-emojis"
-                        title="${__('Insert emojis')}"
-                        data-toggle="dropdown"
-                        aria-haspopup="true"
-                        aria-expanded="false">
-                    <converse-icon
-                        color="var(${color})"
-                        class="fa fa-smile "
-                        path-prefix="${api.settings.get('assets_path')}"
-                        size="1em"></converse-icon>
-                </button>
-                <div class="dropdown-menu">
-                    ${until(this.initModel().then(() => html`
-                        <converse-emoji-picker
-                                .chatview=${this.chatview}
-                                .model=${this.model}
-                                @emojiSelected=${() => this.hideMenu()}
-                                ?render_emojis=${this.render_emojis}
-                                current_category="${this.model.get('current_category') || ''}"
-                                current_skintone="${this.model.get('current_skintone') || ''}"
-                                query="${this.model.get('query') || ''}"
-                        ></converse-emoji-picker>`), '')}
-                </div>
-            </div>`;
+            <button class="dropdown-toggle dropdown-toggle--no-caret toggle-emojis"
+                    type="button"
+                    id="${this.id}"
+                    title="${__('Insert emojis')}"
+                    data-bs-toggle="dropdown"
+                    aria-haspopup="true"
+                    aria-expanded="false">
+                <converse-icon
+                    color="var(${color})"
+                    class="fa fa-smile "
+                    path-prefix="${api.settings.get('assets_path')}"
+                    size="1em"></converse-icon>
+            </button>
+            <ul class="dropdown-menu" aria-labelledby="${this.id}">
+                <li>
+                ${until(this.initModel().then(() => html`
+                    <converse-emoji-picker
+                            .chatview=${this.chatview}
+                            .model=${this.model}
+                            @emojiSelected=${() => this.dropdown.hide()}
+                            ?render_emojis=${this.render_emojis}
+                            current_category="${this.model.get('current_category') || ''}"
+                            current_skintone="${this.model.get('current_skintone') || ''}"
+                            query="${this.model.get('query') || ''}"
+                    ></converse-emoji-picker>`), '')}
+                </li>
+            </ul>`;
     }
 
     connectedCallback () {
@@ -77,19 +83,7 @@ export default class EmojiDropdown extends DropdownBase {
         this.render_emojis = false;
     }
 
-    toggleMenu (ev) {
-        ev.stopPropagation();
-        ev.preventDefault();
-        if (u.hasClass('show', this.menu)) {
-            if (u.ancestor(ev.target, '.toggle-emojis')) {
-                this.hideMenu();
-            }
-        } else {
-            this.showMenu();
-        }
-    }
-
-    async showMenu () {
+    async onShown () {
         await this.initModel();
         if (!this.render_emojis) {
             // Trigger an update so that emojis are rendered
@@ -97,7 +91,6 @@ export default class EmojiDropdown extends DropdownBase {
             this.requestUpdate();
             await this.updateComplete;
         }
-        super.showMenu();
         setTimeout(() => /** @type {HTMLInputElement} */(this.querySelector('.emoji-search'))?.focus());
     }
 }

+ 2 - 2
src/shared/chat/emoji-picker.js

@@ -4,13 +4,13 @@
  */
 import debounce from 'lodash-es/debounce';
 import { api, converse, u, constants } from "@converse/headless";
-import "./emoji-picker-content.js";
-import './emoji-dropdown.js';
 import DOMNavigator from "shared/dom-navigator";
 import { CustomElement } from 'shared/components/element.js';
 import { FILTER_CONTAINS } from "shared/autocomplete/utils.js";
 import { getTonedEmojis } from './utils.js';
 import { tplEmojiPicker } from "./templates/emoji-picker.js";
+import "./emoji-picker-content.js";
+import './emoji-dropdown.js';
 
 import './styles/emoji.scss';
 

+ 2 - 2
src/shared/chat/message-actions.js

@@ -78,7 +78,7 @@ class MessageActions extends CustomElement {
             // That's difficult to know from state, so we're making an approximation here.
             const should_drop_up = this.model.collection.length > 3 && this.model === this.model.collection.last();
             return html`<converse-dropdown
-                class="chat-msg__actions ${should_drop_up ? 'dropup dropup--left' : 'dropleft'}"
+                class="chat-msg__actions btn-group ${should_drop_up ? 'dropup dropup--left' : 'dropstart'}"
                 .items=${items}
             ></converse-dropdown>`;
         } else {
@@ -88,7 +88,7 @@ class MessageActions extends CustomElement {
 
     static getActionsDropdownItem (o) {
         return html`
-            <button class="chat-msg__action ${o.button_class}" @click=${o.handler}>
+            <button class="dropdown-item chat-msg__action ${o.button_class}" @click=${o.handler}>
                 <converse-icon
                     class="${o.icon_class}"
                     color="var(--text-color-lighten-15-percent)"

+ 4 - 2
src/shared/chat/styles/emoji.scss

@@ -3,7 +3,6 @@
 @import "bootstrap/scss/mixins";
 
 .conversejs {
-    @import "bootstrap/scss/media";
 
     .chatbox {
         img.emoji {
@@ -25,6 +24,7 @@
             display: inline-block;
             .dropdown-menu {
                 padding: 0;
+                margin-top: -23em !important;
             }
         }
 
@@ -34,6 +34,7 @@
             padding-bottom: 0;
             background-color: var(--chat-head-color);
             overflow-y: hidden;
+
             converse-emoji-picker-content {
                 width: 100%;
                 .emoji-picker__lists {
@@ -125,6 +126,7 @@
                     }
                 }
             }
+
             .emoji-picker__header {
                 width: 100%;
                 display: flex;
@@ -133,7 +135,7 @@
                 background-color: var(--chat-head-color);
                 .emoji-search {
                     width: auto;
-                    margin: 0.25em;
+                    margin: 1em 0.25em 0.5em;
                     height: 2em;
                     font-size: var(--font-size-small);
                 }

+ 3 - 1
src/shared/chat/utils.js

@@ -62,7 +62,9 @@ export function getDropdownButtons (promise) {
     return promise.then((btns) => {
         const dropdown_btns = btns.filter((b) => !b.standalone).map((b) => getHeadingDropdownItem(b));
         return dropdown_btns.length
-            ? html`<converse-dropdown class="chatbox-btn dropleft" .items=${dropdown_btns}></converse-dropdown>`
+            ? html`<converse-dropdown
+                class="chatbox-btn btn-group dropstart"
+                .items=${dropdown_btns}></converse-dropdown>`
             : '';
     });
 }

+ 16 - 21
src/shared/components/dropdown.js

@@ -3,10 +3,10 @@
  */
 import { html } from 'lit';
 import { until } from 'lit/directives/until.js';
-import { api, constants } from "@converse/headless";
-import 'shared/components/icons.js';
+import { api, constants, u } from "@converse/headless";
 import DOMNavigator from "shared/dom-navigator.js";
 import DropdownBase from 'shared/components/dropdownbase.js';
+import 'shared/components/icons.js';
 
 import './styles/dropdown.scss';
 
@@ -26,16 +26,24 @@ export default class Dropdown extends DropdownBase {
         super();
         this.icon_classes = 'fa fa-bars';
         this.items = [];
+        this.id = u.getUniqueId();
+        this.addEventListener('hidden.bs.dropdown', () => this.onHidden());
+        this.addEventListener('keyup', (ev) => this.handleKeyUp(ev));
     }
 
     render () {
         return html`
-            <button type="button" class="btn btn--transparent btn--standalone" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+            <button class="btn btn--transparent btn--standalone dropdown-toggle dropdown-toggle--no-caret"
+                    id="${this.id}"
+                    type="button"
+                    data-bs-toggle="dropdown"
+                    aria-haspopup="true"
+                    aria-expanded="false">
                 <converse-icon size="1em" class="${ this.icon_classes }">
             </button>
-            <div class="dropdown-menu">
-                ${ this.items.map(b => until(b, '')) }
-            </div>
+            <ul class="dropdown-menu" aria-labelledby="${this.id}">
+                ${ this.items.map(b => html`<li>${until(b, '')}</li>`) }
+            </ul>
         `;
     }
 
@@ -44,19 +52,7 @@ export default class Dropdown extends DropdownBase {
         this.initArrowNavigation();
     }
 
-    connectedCallback () {
-        super.connectedCallback();
-        this.hideOnEscape = ev => (ev.keyCode === KEYCODES.ESCAPE && this.hideMenu());
-        document.addEventListener('keydown', this.hideOnEscape);
-    }
-
-    disconnectedCallback() {
-        document.removeEventListener('keydown', this.hideOnEscape);
-        super.disconnectedCallback();
-    }
-
-    hideMenu () {
-        super.hideMenu();
+    onHidden () {
         this.navigator?.disable();
     }
 
@@ -64,7 +60,7 @@ export default class Dropdown extends DropdownBase {
         if (!this.navigator) {
             const options = /** @type DOMNavigatorOptions */({
                 'selector': '.dropdown-item',
-                'onSelected': el => el.focus()
+                'onSelected': (el) => el.focus()
             });
             this.navigator = new DOMNavigator(/** @type HTMLElement */(this.menu), options);
         }
@@ -80,7 +76,6 @@ export default class Dropdown extends DropdownBase {
     }
 
     handleKeyUp (ev) {
-        super.handleKeyUp(ev);
         if (ev.keyCode === KEYCODES.DOWN_ARROW && !this.navigator.enabled) {
             this.enableArrowNavigation(ev);
         }

+ 2 - 51
src/shared/components/dropdownbase.js

@@ -1,61 +1,12 @@
+import { Dropdown as BootstrapDropdown } from 'bootstrap';
 import { CustomElement } from './element.js';
-import { converse } from '@converse/headless';
-
-const u = converse.env.utils;
 
 export default class DropdownBase extends CustomElement {
-    connectedCallback () {
-        super.connectedCallback();
-        this.registerEvents();
-    }
-
-    registerEvents () {
-        this.clickOutside = (ev) => this._clickOutside(ev);
-        document.addEventListener('click', this.clickOutside);
-    }
 
     firstUpdated (changed) {
         super.firstUpdated(changed);
         this.menu = this.querySelector('.dropdown-menu');
         this.button = this.querySelector('button');
-        this.addEventListener('click', (ev) => this.toggleMenu(ev));
-        this.addEventListener('keyup', (ev) => this.handleKeyUp(ev));
-    }
-
-    _clickOutside (ev) {
-        if (!this.contains(ev.composedPath()[0])) {
-            this.hideMenu();
-        }
-    }
-
-    hideMenu () {
-        u.removeClass('show', this.menu);
-        this.button?.setAttribute('aria-expanded', 'false');
-        this.button?.blur();
-    }
-
-    showMenu () {
-        u.addClass('show', this.menu);
-        this.button.setAttribute('aria-expanded', 'true');
-    }
-
-    toggleMenu (ev) {
-        ev.preventDefault();
-        if (u.hasClass('show', this.menu)) {
-            this.hideMenu();
-        } else {
-            this.showMenu();
-        }
-    }
-
-    handleKeyUp (ev) {
-        if (ev.keyCode === converse.keycodes.ESCAPE) {
-            this.hideMenu();
-        }
-    }
-
-    disconnectedCallback () {
-        document.removeEventListener('click', this.clickOutside);
-        super.disconnectedCallback();
+        this.dropdown = new BootstrapDropdown(/** @type {HTMLElement} */(this.button));
     }
 }

+ 12 - 9
src/shared/components/styles/dropdown.scss

@@ -1,9 +1,5 @@
-@import "bootstrap/scss/functions";
-@import "bootstrap/scss/variables";
-@import "bootstrap/scss/mixins";
-
 .conversejs {
-    @import "bootstrap/scss/dropdown";
+    converse-emoji-dropdown,
     converse-dropdown {
 
         &.dropup {
@@ -20,6 +16,13 @@
             margin: 0;
         }
 
+        .dropdown-toggle--no-caret {
+            &:before,
+            &:after {
+                display: none !important;
+            }
+        }
+
         .dropdown-menu {
             background: var(--background);
             margin-top: -0.2em !important;
@@ -27,13 +30,13 @@
         }
 
         .dropdown-item {
-            line-height: 1em;
-            color: var(--text-color);
+            color: var(--text-color) !important;
+            font-size: var(--font-size);
             padding: 0.5rem 1rem;
             converse-icon {
                 margin-top: -0.1em;
                 width: 1.25em;
-                margin-right: 0;
+                margin-right: 0.25rem;
             }
             &:active, &.selected {
                 color: white !important;
@@ -43,7 +46,7 @@
                 }
             }
             &:hover {
-                color: var(--text-color);
+                color: var(--text-color) !important;
                 background-color: var(--list-item-hover-color);
             }
         }

+ 0 - 3
src/shared/modals/user-details.js

@@ -67,9 +67,6 @@ export default class UserDetailsModal extends BaseModal {
         if (!api.settings.get('allow_contact_removal')) { return; }
         const result = await api.confirm(__("Are you sure you want to remove this contact?"));
         if (result) {
-            // XXX: The `dismissHandler` in bootstrap.native tries to
-            // reference the remove button after it's been cleared from
-            // the DOM, so we delay removing the contact to give it time.
             setTimeout(() => removeContact(this.model.contact), 1);
             this.modal.hide();
         }

+ 0 - 32
src/shared/styles/_core.scss

@@ -30,15 +30,6 @@
       }
     }
 
-    .fit-content {
-        width: fit-content !important;
-        max-width: fit-content !important;
-    }
-
-    .nopadding {
-        padding: 0 !important;
-    }
-
     .no-scrolling {
       overflow-x: none;
       overflow-y: none;
@@ -124,10 +115,6 @@
         }
     }
 
-    .popover {
-        position: fixed;
-    }
-
     ::-webkit-input-placeholder { /* Chrome/Opera/Safari */
         color: var(--subdued-color);
     }
@@ -169,17 +156,6 @@
     }
 
     ul li { height: auto; }
-    div, span, h1, h2, h3, h4, h5, h6, p, blockquote,
-    pre, a, em, img, strong, dl, dt, dd, ol, ul, li,
-    fieldset, form, legend, table, caption, tbody,
-    tfoot, thead, tr, th, td, article, aside, details,
-    embed, figure, figcaption, footer, header, hgroup, menu,
-    nav, output, ruby, section, summary, time, mark, audio, video {
-        margin: 0;
-        padding: 0;
-        border: 0;
-        vertical-align: baseline;
-    }
 
     textarea,
     input[type=submit], input[type=button],
@@ -201,10 +177,6 @@
         list-style: none;
     }
 
-    li {
-        height: 10px;
-    }
-
     ul, ol, dl {
         font: inherit;
         margin: 0;
@@ -272,10 +244,6 @@
         }
     }
 
-    .circle {
-        border-radius: 50%;
-    }
-
     .no-text-select {
         -webkit-touch-callout: none;
         user-select: none;

+ 2 - 2
src/shared/styles/_variables.scss

@@ -2,11 +2,11 @@ $mobile_landscape_height: 450px !default;
 $mobile_portrait_length: 480px !default;
 
 .conversejs, .conversejs-bg, #conversejs-bg, body.converse-fullscreen {
-
     --avatar-border-radius: 10%;
     --message-avatar-width: 36px;
     --message-avatar-height: 36px;
 
+    --controlbox-width: 250px;
     --chatroom-width: 500px;
 
     --send-button-height: 27px;
@@ -37,7 +37,7 @@ $mobile_portrait_length: 480px !default;
 
     --embedded-emoji-picker-height: 300px;
 
-    --chat-gutter: 0.5em;
+    --chat-gutter: 1em;
 
     --occupants-padding: 1em;
 

+ 0 - 3
src/shared/styles/buttons.scss

@@ -1,7 +1,4 @@
 .conversejs {
-    @import "bootstrap/scss/buttons";
-    @import "bootstrap/scss/button-group";
-
     .btn {
         font-weight: normal;
         color: var(--button-text-color);

+ 6 - 19
src/shared/styles/forms.scss

@@ -1,19 +1,10 @@
 .conversejs {
-    @import "bootstrap/scss/forms";
-    @import "bootstrap/scss/input-group";
-    @import "bootstrap/scss/custom-forms";
-
     .btn--small {
         font-size: 80%;
         font-weight: normal;
     }
 
     form {
-
-        label {
-            font-weight: bold;
-        }
-
         .form-instructions {
             color: var(--text-color);
             margin-bottom: 1em;
@@ -29,10 +20,6 @@
             margin-bottom: 0.5em;
         }
 
-        .form-check-label {
-            margin-top: $form-check-input-margin-y;
-        }
-
         .form-control[readonly] {
             color: var(--disabled-color);
         }
@@ -103,25 +90,25 @@
 
         }
 
-
         &.converse-form {
             padding: 1.2rem;
+
             legend {
                 color: var(--text-color);
                 font-size: 125%;
                 margin-bottom: 1.5em;
             }
+
+            fieldset {
+                margin-bottom: 1.5em;
+            }
+
             select,
             input[type=password],
             input[type=number],
             input[type=text] {
                 min-width: 50%;
             }
-            input[type=button],
-            input[type=submit] {
-                margin-right: 0.25em;
-                border: none;
-            }
             input.error {
                 border: 1px solid var(--error-color);
                 color: var(--text-color);

+ 34 - 6
src/shared/styles/index.scss

@@ -5,29 +5,56 @@
  * Copyright (c) 2013-2021, JC Brand <jc@opkode.com>
  * Licensed under the Mozilla Public License
  */
+
+// Set custom bootstrap prefix (default is bs-) to avoid clashes
+$prefix: 'converse-';
+
 @import "bootstrap/scss/functions";
 @import "bootstrap/scss/variables";
-
-@import "./fonts.scss";
-@import "mixins";
+@import "bootstrap/scss/variables-dark";
+@import "bootstrap/scss/maps";
+@import "bootstrap/scss/mixins";
+@import "bootstrap/scss/utilities";
+@import "bootstrap/scss/root";
 
 .conversejs {
-    @import "bootstrap/scss/root";
     @import "bootstrap/scss/reboot";
     @import "bootstrap/scss/type";
     @import "bootstrap/scss/images";
+    @import "bootstrap/scss/containers";
     @import "bootstrap/scss/grid";
     @import "bootstrap/scss/tables";
+    @import "bootstrap/scss/forms";
+    @import "bootstrap/scss/buttons";
     @import "bootstrap/scss/transitions";
+    @import "bootstrap/scss/dropdown";
+    @import "bootstrap/scss/button-group";
     @import "bootstrap/scss/nav";
+    @import "bootstrap/scss/navbar";
+    @import "bootstrap/scss/card";
+    // @import "bootstrap/scss/accordion";
+    // @import "bootstrap/scss/breadcrumb";
+    // @import "bootstrap/scss/pagination";
+    @import "bootstrap/scss/badge";
     @import "bootstrap/scss/alert";
-    @import "bootstrap/scss/media";
+    @import "bootstrap/scss/progress";
+    @import "bootstrap/scss/list-group";
     @import "bootstrap/scss/close";
+    // @import "bootstrap/scss/toasts";
+    @import "bootstrap/scss/modal";
+    @import "bootstrap/scss/tooltip";
     @import "bootstrap/scss/popover";
-    @import "bootstrap/scss/utilities";
+    // @import "bootstrap/scss/carousel";
+    @import "bootstrap/scss/spinners";
+    // @import "bootstrap/scss/offcanvas";
+    @import "bootstrap/scss/placeholders";
+    @import "bootstrap/scss/helpers";
+    @import "bootstrap/scss/utilities/api";
 }
 
 @import "variables";
+@import "mixins";
+
 @import "themes/classic";
 @import "themes/concord";
 @import "themes/dracula";
@@ -41,3 +68,4 @@
 @import "lists";
 @import "messages";
 @import "background";
+@import "fonts";

+ 1 - 0
src/shared/styles/lists.scss

@@ -26,6 +26,7 @@
     }
 
     .items-list {
+        padding-left: 0;
         text-align: left;
 
         .list-item {

+ 0 - 1
src/shared/styles/themes/classic.scss

@@ -106,7 +106,6 @@
     --groupchats-header-color: var(--chatroom-head-bg-color);
     --groupchats-header-color-dark: var(--chatroom-head-bg-color-dark);
 
-    --controlbox-width: 250px;
     --controlbox-head-color: var(--light-blue);
     --controlbox-head-btn-color: var(--light-blue);
     --controlbox-heading-color: inherit;

+ 23 - 6
src/shared/styles/themes/dracula.scss

@@ -1,9 +1,13 @@
 .conversejs.theme-dracula {
     // Theme-defined variables:
     // https://draculatheme.com
-    --current-line: #44475a;
+
+    // Hex color values
+    --background: #282a36;
     --comment: #6272a4;
+    --current-line: #44475a;
     --cyan: #8be9fd;
+    --foreground: #f8f8f2;
     --green: #50fa7b;
     --orange: #ffb86c;
     --pink: #ff79c6;
@@ -11,9 +15,24 @@
     --red: #ff5555;
     --yellow: #f1fa8c;
 
-    // Base variables
-    --background: #282a36;
-    --foreground: #f8f8f2;
+    // RGB color values, needed for bootstrap
+    --background-rgb: 40, 42, 54;
+    --comment-rgb: 98, 114, 164;
+    --current-line-rgb: 68, 71, 90;
+    --cyan-rgb: 139, 233, 253;
+    --foreground-rgb: 248, 248, 242;
+    --green-rgb: 80, 250, 123;
+    --orange-rgb: 255, 184, 108;
+    --pink-rgb: 255, 121, 198;
+    --purple-rgb: 189, 147, 249;
+    --red-rgb: 255, 85, 85;
+    --yellow-rgb: 241, 250, 140;
+
+    // Bootstrap variables
+    --converse-primary-rgb: var(--purple-rgb);
+    --converse-secondary-rgb: var(--cyan-rgb);
+
+    // Base theme variables
     --subdued-color: var(--foreground);
     --muc-color: var(--orange);
     --chat-color: var(--green);
@@ -25,8 +44,6 @@
     --header-color: var(--pink);
     --heading-color: var(--purple);
 
-    // ---
-
     --headlines-color: var(--pink);
     --headlines-head-text-color: var(--headlines-color);
     --headlines-head-fg-color: var(--headlines-color);

+ 0 - 3
src/shared/styles/website.scss

@@ -12,12 +12,9 @@
 @import "bootstrap/scss/transitions";
 @import "bootstrap/scss/dropdown";
 @import "bootstrap/scss/button-group";
-@import "bootstrap/scss/input-group";
-@import "bootstrap/scss/custom-forms";
 @import "bootstrap/scss/nav";
 @import "bootstrap/scss/navbar";
 @import "bootstrap/scss/badge";
-@import "bootstrap/scss/media";
 @import "bootstrap/scss/list-group";
 @import "bootstrap/scss/close";
 @import "bootstrap/scss/utilities";

+ 2 - 2
src/templates/form_captcha.js

@@ -1,8 +1,8 @@
 import { html } from "lit";
 
 export default (o) => html`
-    <fieldset class="form-group">
-        ${o.label ? html`<label>${o.label}</label>` : '' }
+    <fieldset class="pb-2">
+        ${o.label ? html`<label class="form-label">${o.label}</label>` : '' }
         <img src="data:${o.type};base64,${o.data}">
         <input name="${o.name}" type="text" ?required="${o.required}" />
     </fieldset>

+ 1 - 1
src/templates/form_checkbox.js

@@ -1,7 +1,7 @@
 import { html } from "lit";
 
 export default  (o) => html`
-    <fieldset class="form-group">
+    <fieldset class="pb-2">
         <input id="${o.id}"
                name="${o.name}"
                type="checkbox"

+ 2 - 2
src/templates/form_date.js

@@ -1,8 +1,8 @@
 import { html } from "lit";
 
 export default  (o) => html`
-    <div class="form-group">
-        <label for="${o.id}">${o.label}
+    <div class="pb-2">
+        <label for="${o.id}" class="form-label">${o.label}
             ${(o.desc) ? html`<small class="form-text text-muted">${o.desc}</small>` : ''}
         </label>
         <input

+ 2 - 2
src/templates/form_input.js

@@ -1,8 +1,8 @@
 import { html } from "lit";
 
 export default  (o) => html`
-    <div class="form-group">
-        ${ o.type !== 'hidden' ? html`<label for="${o.id}">${o.label}
+    <div class="pb-2">
+        ${ o.type !== 'hidden' ? html`<label for="${o.id}" class="form-label">${o.label}
             ${(o.desc) ? html`<small class="form-text text-muted">${o.desc}</small>` : ''}
         </label>` : '' }
         <!-- This is a hack to prevent Chrome from auto-filling the username in

+ 2 - 2
src/templates/form_select.js

@@ -3,8 +3,8 @@ import { html } from "lit";
 const tplOption = (o) => html`<option value="${o.value}" ?selected="${o.selected}">${o.label}</option>`;
 
 export default  (o) => html`
-    <div class="form-group">
-        <label for="${o.id}">${o.label}</label>
+    <div class="pb-2">
+        <label for="${o.id}" class="form-label">${o.label}</label>
         <select class="form-control" id="${o.id}" name="${o.name}" ?multiple="${o.multiple}">
             ${o.options?.map(o => tplOption(o))}
         </select>

+ 2 - 2
src/templates/form_textarea.js

@@ -4,8 +4,8 @@ import { u } from '@converse/headless';
 export default (o) => {
     const id = u.getUniqueId();
     return html`
-        <div class="form-group">
-            <label class="label-ta" for="${o.id}">${o.label}
+        <div class="pb-2">
+            <label class="form-label label-ta" for="${o.id}">${o.label}
                 ${(o.desc) ? html`<small class="form-text text-muted">${o.desc}</small>` : ''}
             </label>
             <textarea name="${o.name}"

+ 2 - 2
src/templates/form_url.js

@@ -1,8 +1,8 @@
 import { html } from "lit";
 
 export default (o) => html`
-    <div class="form-group">
-        <label for="${o.id}">${o.label}
+    <div class="pb-2">
+        <label for="${o.id}" class="form-label">${o.label}
             ${ o.desc ? html`<small id="o.id" class="form-text text-muted">${o.desc}</small>` : '' }
         </label>
         <div>

+ 2 - 2
src/templates/form_username.js

@@ -1,10 +1,10 @@
 import { html } from 'lit';
 
 export default (o) => html`
-    <div class="form-group">
+    <div class="pb-2">
         ${
             o.type !== 'hidden'
-                ? html`<label for="${o.id}"
+                ? html`<label for="${o.id}" class="form-label"
                       >${o.label} ${o.desc ? html`<small class="form-text text-muted">${o.desc}</small>` : ''}
                   </label>`
                 : ''

+ 1 - 0
src/types/plugins/bookmark-views/modals/bookmark-form.d.ts

@@ -1,4 +1,5 @@
 export default class BookmarkFormModal extends BaseModal {
+    constructor(options: any);
     jid: any;
     renderModal(): import("lit").TemplateResult<1>;
     getModalTitle(): any;

Some files were not shown because too many files changed in this diff